mirror of
				https://github.com/superseriousbusiness/gotosocial
				synced 2025-06-05 21:59:39 +02:00 
			
		
		
		
	[feature] Process incoming Move activity (#2724)
				
					
				
			* [feature] Process incoming account Move activity * fix targetAcct typo * put move origin account on fMsg * shift more move functionality back to the worker fn * simplify error logic
This commit is contained in:
		| @@ -846,4 +846,44 @@ GoToSocial will only set `movedTo` on outgoing Actors when an account `Move` has | ||||
|  | ||||
| ### `Move` Activity | ||||
|  | ||||
| TODO: document how `Move` works! | ||||
| To actually trigger account migrations, GoToSocial uses the `Move` Activity with Actor URI as Object and Target, for example: | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "@context": "https://www.w3.org/ns/activitystreams", | ||||
|   "id": "https://example.org/users/1happyturtle/moves/01HR9FDFCAGM7JYPMWNTFRDQE9", | ||||
|   "actor": "https://example.org/users/1happyturtle", | ||||
|   "type": "Move", | ||||
|   "object": "https://example.org/users/1happyturtle", | ||||
|   "target": "https://another-server.com/users/my_new_account_hurray", | ||||
|   "to": "https://example.org/users/1happyturtle/followers" | ||||
| } | ||||
| ``` | ||||
|  | ||||
| In the above `Move`, Actor `https://example.org/users/1happyturtle` indicates that their account is moving to the URI `https://another-server.com/users/my_new_account_hurray`. | ||||
|  | ||||
| #### Incoming | ||||
|  | ||||
| On receiving a `Move` activity in an Actor's Inbox, GoToSocial will first validate the `Move` by making the following checks: | ||||
|  | ||||
| 1. Request was signed by `actor`. | ||||
| 2. `actor` and `object` fields are the same (you can't `Move` someone else's account). | ||||
| 3. `actor` has not already moved somewhere else. | ||||
| 4. `target` is a valid Actor URI: retrievable, not suspended, not already moved, and on a domain that's not defederated by the GoToSocial instance that received the `Move`. | ||||
| 5. `target` has `alsoKnownAs` set to the `actor` that sent the `Move`. In this example, `https://another-server.com/users/my_new_account_hurray` must have an `alsoKnownAs` value that includes `https://example.org/users/1happyturtle`. | ||||
|  | ||||
| If checks pass, then GoToSocial will process the `Move` by redirecting followers to the new account: | ||||
|  | ||||
| 1. Select all followers on this GtS instance of the `actor` doing the `Move`. | ||||
| 2. For each local follower selected in this way, send a follow request from that follower to the `target` of the `Move`. | ||||
| 3. Remove all follows targeting the "old" `actor`. | ||||
|  | ||||
| The end result of this is that all followers of `https://example.org/users/1happyturtle` on the receiving instance will now be following `https://another-server.com/users/my_new_account_hurray` instead. | ||||
|  | ||||
| GoToSocial will also remove all follow and pending follow requests owned by the `actor` doing the `Move`; it's up to the `target` account to send follow requests out again. | ||||
|  | ||||
| To prevent potential DoS vectors, GoToSocial enforces a 7-day cooldown on `Move`s. Once an account has successfully moved, GoToSocial will not process further moves from the new account until 7 days after the previous move. | ||||
|  | ||||
| #### Outgoing | ||||
|  | ||||
| Outgoing account migrations use the `Move` Activity in much the same way. When an Actor on a GoToSocial instance wants to `Move`, GtS will first check and validate the `Move` target, and ensure it has an `alsoKnownAs` entry equal to the Actor doing the `Move`. On successful validation, a `Move` message will be sent out to all of the moving Actor's followers, indicating the `target` of the Move. GoToSocial expects remote instances to transfer the `actor`'s followers to the `target`. | ||||
|   | ||||
| @@ -64,8 +64,8 @@ func accountFresh( | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	if !account.SuspendedAt.IsZero() { | ||||
| 		// Can't refresh | ||||
| 	if account.IsSuspended() { | ||||
| 		// Can't/won't refresh | ||||
| 		// suspended accounts. | ||||
| 		return true | ||||
| 	} | ||||
| @@ -388,8 +388,9 @@ func (d *Dereferencer) enrichAccountSafely( | ||||
| 	account *gtsmodel.Account, | ||||
| 	accountable ap.Accountable, | ||||
| ) (*gtsmodel.Account, ap.Accountable, error) { | ||||
| 	// Noop if account has been suspended. | ||||
| 	if !account.SuspendedAt.IsZero() { | ||||
| 	// Noop if account suspended; | ||||
| 	// we don't want to deref it. | ||||
| 	if account.IsSuspended() { | ||||
| 		return account, nil, nil | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -64,6 +64,16 @@ var ( | ||||
| 	// This is tuned to be quite fresh without | ||||
| 	// causing loads of dereferencing calls. | ||||
| 	Fresh = util.Ptr(FreshnessWindow(5 * time.Minute)) | ||||
|  | ||||
| 	// 10 seconds. | ||||
| 	// | ||||
| 	// Freshest is useful when you want an | ||||
| 	// immediately up to date model of something | ||||
| 	// that's even fresher than Fresh. | ||||
| 	// | ||||
| 	// Be careful using this one; it can cause | ||||
| 	// lots of unnecessary traffic if used unwisely. | ||||
| 	Freshest = util.Ptr(FreshnessWindow(10 * time.Second)) | ||||
| ) | ||||
|  | ||||
| // Dereferencer wraps logic and functionality for doing dereferencing | ||||
|   | ||||
| @@ -49,6 +49,12 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA | ||||
| 	requestingAcct := activityContext.requestingAcct | ||||
| 	receivingAcct := activityContext.receivingAcct | ||||
|  | ||||
| 	if requestingAcct.IsMoving() { | ||||
| 		// A Moving account | ||||
| 		// can't do this. | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Iterate all provided objects in the activity. | ||||
| 	for _, object := range ap.ExtractObjects(accept) { | ||||
|  | ||||
|   | ||||
| @@ -49,6 +49,12 @@ func (f *federatingDB) Announce(ctx context.Context, announce vocab.ActivityStre | ||||
| 	requestingAcct := activityContext.requestingAcct | ||||
| 	receivingAcct := activityContext.receivingAcct | ||||
|  | ||||
| 	if requestingAcct.IsMoving() { | ||||
| 		// A Moving account | ||||
| 		// can't do this. | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Ensure requestingAccount is among | ||||
| 	// the Actors doing the Announce. | ||||
| 	// | ||||
|   | ||||
| @@ -68,6 +68,12 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { | ||||
| 	requestingAcct := activityContext.requestingAcct | ||||
| 	receivingAcct := activityContext.receivingAcct | ||||
|  | ||||
| 	if requestingAcct.IsMoving() { | ||||
| 		// A Moving account | ||||
| 		// can't do this. | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	switch asType.GetTypeName() { | ||||
| 	case ap.ActivityBlock: | ||||
| 		// BLOCK SOMETHING | ||||
|   | ||||
| @@ -31,11 +31,18 @@ import ( | ||||
| // DB wraps the pub.Database interface with | ||||
| // a couple of custom functions for GoToSocial. | ||||
| type DB interface { | ||||
| 	// Default functionality. | ||||
| 	pub.Database | ||||
|  | ||||
| 	/* | ||||
| 		Overridden functionality for calling from federatingProtocol. | ||||
| 	*/ | ||||
|  | ||||
| 	Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error | ||||
| 	Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error | ||||
| 	Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error | ||||
| 	Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error | ||||
| 	Move(ctx context.Context, move vocab.ActivityStreamsMove) error | ||||
| } | ||||
|  | ||||
| // FederatingDB uses the given state interface | ||||
|   | ||||
							
								
								
									
										182
									
								
								internal/federation/federatingdb/move.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								internal/federation/federatingdb/move.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | ||||
| // GoToSocial | ||||
| // Copyright (C) GoToSocial Authors admin@gotosocial.org | ||||
| // SPDX-License-Identifier: AGPL-3.0-or-later | ||||
| // | ||||
| // This program is free software: you can redistribute it and/or modify | ||||
| // it under the terms of the GNU Affero General Public License as published by | ||||
| // the Free Software Foundation, either version 3 of the License, or | ||||
| // (at your option) any later version. | ||||
| // | ||||
| // This program is distributed in the hope that it will be useful, | ||||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
| // GNU Affero General Public License for more details. | ||||
| // | ||||
| // You should have received a copy of the GNU Affero General Public License | ||||
| // along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||
|  | ||||
| // Package gtsmodel contains types used *internally* by GoToSocial and added/removed/selected from the database. | ||||
| // These types should never be serialized and/or sent out via public APIs, as they contain sensitive information. | ||||
| // The annotation used on these structs is for handling them via the bun-db ORM. | ||||
| // See here for more info on bun model annotations: https://bun.uptrace.dev/guide/models.html | ||||
|  | ||||
| package federatingdb | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
|  | ||||
| 	"codeberg.org/gruf/go-logger/v2/level" | ||||
| 	"github.com/superseriousbusiness/activity/streams/vocab" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/ap" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/messages" | ||||
| ) | ||||
|  | ||||
| func (f *federatingDB) Move(ctx context.Context, move vocab.ActivityStreamsMove) error { | ||||
| 	if log.Level() >= level.DEBUG { | ||||
| 		i, err := marshalItem(move) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		l := log.WithContext(ctx). | ||||
| 			WithField("move", i) | ||||
| 		l.Debug("entering Move") | ||||
| 	} | ||||
|  | ||||
| 	activityContext := getActivityContext(ctx) | ||||
| 	if activityContext.internal { | ||||
| 		// Already processed. | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	requestingAcct := activityContext.requestingAcct | ||||
| 	receivingAcct := activityContext.receivingAcct | ||||
|  | ||||
| 	if requestingAcct.IsLocal() { | ||||
| 		// We should not be processing | ||||
| 		// a Move sent from our own | ||||
| 		// instance in the federatingDB. | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Basic Move requirements we can | ||||
| 	// check at this point already: | ||||
| 	// | ||||
| 	//   - Move must have ID/URI set. | ||||
| 	//   - Move `object` and `actor` must | ||||
| 	//     be set, and must be the same | ||||
| 	//     as requesting account. | ||||
| 	//   - Move `target` must be set, and | ||||
| 	//     must *not* be the same as | ||||
| 	//     requesting account. | ||||
| 	//   - Move `target` and `object` must | ||||
| 	//     not have been involved in a | ||||
| 	//     successful Move within the | ||||
| 	//     last 7 days. | ||||
| 	// | ||||
| 	// If the Move looks OK at this point, | ||||
| 	// additional requirements and checks | ||||
| 	// will be processed in FromFediAPI. | ||||
|  | ||||
| 	// Ensure ID/URI set. | ||||
| 	moveURI := ap.GetJSONLDId(move) | ||||
| 	if moveURI == nil { | ||||
| 		err := errors.New("Move ID/URI was nil") | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
| 	moveURIStr := moveURI.String() | ||||
|  | ||||
| 	// Check `object` property. | ||||
| 	objects := ap.GetObjectIRIs(move) | ||||
| 	if l := len(objects); l != 1 { | ||||
| 		err := fmt.Errorf("Move requires exactly 1 object, had %d", l) | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
| 	object := objects[0] | ||||
| 	objectStr := object.String() | ||||
|  | ||||
| 	if objectStr != requestingAcct.URI { | ||||
| 		err := fmt.Errorf( | ||||
| 			"Move was signed by %s but object was %s", | ||||
| 			requestingAcct.URI, objectStr, | ||||
| 		) | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
|  | ||||
| 	// Check `actor` property. | ||||
| 	actors := ap.GetActorIRIs(move) | ||||
| 	if l := len(actors); l != 1 { | ||||
| 		err := fmt.Errorf("Move requires exactly 1 actor, had %d", l) | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
| 	actor := actors[0] | ||||
| 	actorStr := actor.String() | ||||
|  | ||||
| 	if actorStr != requestingAcct.URI { | ||||
| 		err := fmt.Errorf( | ||||
| 			"Move was signed by %s but actor was %s", | ||||
| 			requestingAcct.URI, actorStr, | ||||
| 		) | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
|  | ||||
| 	// Check `target` property. | ||||
| 	targets := ap.GetTargetIRIs(move) | ||||
| 	if l := len(targets); l != 1 { | ||||
| 		err := fmt.Errorf("Move requires exactly 1 target, had %d", l) | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
| 	target := targets[0] | ||||
| 	targetStr := target.String() | ||||
|  | ||||
| 	if targetStr == requestingAcct.URI { | ||||
| 		err := fmt.Errorf( | ||||
| 			"Move target and origin were the same (%s)", | ||||
| 			targetStr, | ||||
| 		) | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
|  | ||||
| 	// If movedToURI is set on requestingAcct, | ||||
| 	// make sure it points to the intended target. | ||||
| 	// | ||||
| 	// If it's not set, that's fine, we don't | ||||
| 	// need it right now. We know by now that the | ||||
| 	// Move was really sent to us by requestingAcct. | ||||
| 	movedToURI := receivingAcct.MovedToURI | ||||
| 	if movedToURI != "" && | ||||
| 		movedToURI != targetStr { | ||||
| 		err := fmt.Errorf( | ||||
| 			"origin account movedTo is set to %s, which differs from Move target; will not process Move", | ||||
| 			movedToURI, | ||||
| 		) | ||||
| 		return gtserror.SetMalformed(err) | ||||
| 	} | ||||
|  | ||||
| 	// Create a stub *gtsmodel.Move with relevant | ||||
| 	// values. This will be updated / stored by the | ||||
| 	// fedi api worker as necessary. | ||||
| 	stubMove := >smodel.Move{ | ||||
| 		OriginURI: objectStr, | ||||
| 		Origin:    object, | ||||
| 		TargetURI: targetStr, | ||||
| 		Target:    target, | ||||
| 		URI:       moveURIStr, | ||||
| 	} | ||||
|  | ||||
| 	// We had a Move already or stored a new Move. | ||||
| 	// Pass back to a worker for async processing. | ||||
| 	f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ | ||||
| 		APObjectType:      ap.ObjectProfile, | ||||
| 		APActivityType:    ap.ActivityMove, | ||||
| 		GTSModel:          stubMove, | ||||
| 		RequestingAccount: requestingAcct, | ||||
| 		ReceivingAccount:  receivingAcct, | ||||
| 	}) | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										201
									
								
								internal/federation/federatingdb/move_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								internal/federation/federatingdb/move_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| // 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 federatingdb_test | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/stretchr/testify/suite" | ||||
| 	"github.com/superseriousbusiness/activity/streams" | ||||
| 	"github.com/superseriousbusiness/activity/streams/vocab" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/ap" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/messages" | ||||
| ) | ||||
|  | ||||
| type MoveTestSuite struct { | ||||
| 	FederatingDBTestSuite | ||||
| } | ||||
|  | ||||
| func (suite *MoveTestSuite) move( | ||||
| 	receivingAcct *gtsmodel.Account, | ||||
| 	requestingAcct *gtsmodel.Account, | ||||
| 	moveStr string, | ||||
| ) error { | ||||
| 	ctx := createTestContext(receivingAcct, requestingAcct) | ||||
|  | ||||
| 	rawMove := make(map[string]interface{}) | ||||
| 	if err := json.Unmarshal([]byte(moveStr), &rawMove); err != nil { | ||||
| 		suite.FailNow(err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	t, err := streams.ToType(ctx, rawMove) | ||||
| 	if err != nil { | ||||
| 		suite.FailNow(err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	move, ok := t.(vocab.ActivityStreamsMove) | ||||
| 	if !ok { | ||||
| 		suite.FailNow("", "couldn't cast %T to Move", t) | ||||
| 	} | ||||
|  | ||||
| 	return suite.federatingDB.Move(ctx, move) | ||||
| } | ||||
|  | ||||
| func (suite *MoveTestSuite) TestMove() { | ||||
| 	var ( | ||||
| 		receivingAcct  = suite.testAccounts["local_account_1"] | ||||
| 		requestingAcct = suite.testAccounts["remote_account_1"] | ||||
| 		moveStr1       = `{ | ||||
|   "@context": "https://www.w3.org/ns/activitystreams", | ||||
|   "id": "http://fossbros-anonymous.io/users/foss_satan/moves/01HR9FDFCAGM7JYPMWNTFRDQE9", | ||||
|   "actor": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "type": "Move", | ||||
|   "object": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "target": "https://turnip.farm/users/turniplover6969", | ||||
|   "to": "http://fossbros-anonymous.io/users/foss_satan/followers" | ||||
| }` | ||||
| 	) | ||||
|  | ||||
| 	// Trigger the move. | ||||
| 	suite.move(receivingAcct, requestingAcct, moveStr1) | ||||
|  | ||||
| 	// Should be a message heading to the processor. | ||||
| 	var msg messages.FromFediAPI | ||||
| 	select { | ||||
| 	case msg = <-suite.fromFederator: | ||||
| 		// Fine. | ||||
| 	case <-time.After(5 * time.Second): | ||||
| 		suite.FailNow("", "timeout waiting for suite.fromFederator") | ||||
| 	} | ||||
| 	suite.Equal(ap.ObjectProfile, msg.APObjectType) | ||||
| 	suite.Equal(ap.ActivityMove, msg.APActivityType) | ||||
|  | ||||
| 	// Stub Move should be on the message. | ||||
| 	move, ok := msg.GTSModel.(*gtsmodel.Move) | ||||
| 	if !ok { | ||||
| 		suite.FailNow("", "could not cast %T to *gtsmodel.Move", msg.GTSModel) | ||||
| 	} | ||||
| 	suite.Equal("http://fossbros-anonymous.io/users/foss_satan", move.OriginURI) | ||||
| 	suite.Equal("https://turnip.farm/users/turniplover6969", move.TargetURI) | ||||
|  | ||||
| 	// Trigger the same move again. | ||||
| 	suite.move(receivingAcct, requestingAcct, moveStr1) | ||||
|  | ||||
| 	// Should be a message heading to the processor | ||||
| 	// since this is just a straight up retry. | ||||
| 	select { | ||||
| 	case msg = <-suite.fromFederator: | ||||
| 		// Fine. | ||||
| 	case <-time.After(5 * time.Second): | ||||
| 		suite.FailNow("", "timeout waiting for suite.fromFederator") | ||||
| 	} | ||||
| 	suite.Equal(ap.ObjectProfile, msg.APObjectType) | ||||
| 	suite.Equal(ap.ActivityMove, msg.APActivityType) | ||||
|  | ||||
| 	// Same as the first Move, but with a different ID. | ||||
| 	moveStr2 := `{ | ||||
|   "@context": "https://www.w3.org/ns/activitystreams", | ||||
|   "id": "http://fossbros-anonymous.io/users/foss_satan/moves/01HR9XWDD25CKXHW82MYD1GDAR", | ||||
|   "actor": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "type": "Move", | ||||
|   "object": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "target": "https://turnip.farm/users/turniplover6969", | ||||
|   "to": "http://fossbros-anonymous.io/users/foss_satan/followers" | ||||
| }` | ||||
|  | ||||
| 	// Trigger the move. | ||||
| 	suite.move(receivingAcct, requestingAcct, moveStr2) | ||||
|  | ||||
| 	// Should be a message heading to the processor | ||||
| 	// since this is just a retry with a different ID. | ||||
| 	select { | ||||
| 	case msg = <-suite.fromFederator: | ||||
| 		// Fine. | ||||
| 	case <-time.After(5 * time.Second): | ||||
| 		suite.FailNow("", "timeout waiting for suite.fromFederator") | ||||
| 	} | ||||
| 	suite.Equal(ap.ObjectProfile, msg.APObjectType) | ||||
| 	suite.Equal(ap.ActivityMove, msg.APActivityType) | ||||
| } | ||||
|  | ||||
| func (suite *MoveTestSuite) TestBadMoves() { | ||||
| 	var ( | ||||
| 		receivingAcct  = suite.testAccounts["local_account_1"] | ||||
| 		requestingAcct = suite.testAccounts["remote_account_1"] | ||||
| 	) | ||||
|  | ||||
| 	type testStruct struct { | ||||
| 		moveStr string | ||||
| 		err     string | ||||
| 	} | ||||
|  | ||||
| 	for _, t := range []testStruct{ | ||||
| 		{ | ||||
| 			// Move signed by someone else. | ||||
| 			moveStr: `{ | ||||
|   "@context": "https://www.w3.org/ns/activitystreams", | ||||
|   "id": "http://fossbros-anonymous.io/users/foss_satan/moves/01HR9FDFCAGM7JYPMWNTFRDQE9", | ||||
|   "actor": "http://fossbros-anonymous.io/users/someone_else", | ||||
|   "type": "Move", | ||||
|   "object": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "target": "https://turnip.farm/users/turniplover6969", | ||||
|   "to": "http://fossbros-anonymous.io/users/foss_satan/followers" | ||||
| }`, | ||||
| 			err: "Move was signed by http://fossbros-anonymous.io/users/foss_satan but actor was http://fossbros-anonymous.io/users/someone_else", | ||||
| 		}, | ||||
| 		{ | ||||
| 			// Actor and object not the same. | ||||
| 			moveStr: `{ | ||||
|   "@context": "https://www.w3.org/ns/activitystreams", | ||||
|   "id": "http://fossbros-anonymous.io/users/foss_satan/moves/01HR9FDFCAGM7JYPMWNTFRDQE9", | ||||
|   "actor": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "type": "Move", | ||||
|   "object": "http://fossbros-anonymous.io/users/someone_else", | ||||
|   "target": "https://turnip.farm/users/turniplover6969", | ||||
|   "to": "http://fossbros-anonymous.io/users/foss_satan/followers" | ||||
| }`, | ||||
| 			err: "Move was signed by http://fossbros-anonymous.io/users/foss_satan but object was http://fossbros-anonymous.io/users/someone_else", | ||||
| 		}, | ||||
| 		{ | ||||
| 			// Object and target the same. | ||||
| 			moveStr: `{ | ||||
|   "@context": "https://www.w3.org/ns/activitystreams", | ||||
|   "id": "http://fossbros-anonymous.io/users/foss_satan/moves/01HR9FDFCAGM7JYPMWNTFRDQE9", | ||||
|   "actor": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "type": "Move", | ||||
|   "object": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "target": "http://fossbros-anonymous.io/users/foss_satan", | ||||
|   "to": "http://fossbros-anonymous.io/users/foss_satan/followers" | ||||
| }`, | ||||
| 			err: "Move target and origin were the same (http://fossbros-anonymous.io/users/foss_satan)", | ||||
| 		}, | ||||
| 	} { | ||||
| 		// Trigger the move. | ||||
| 		err := suite.move(receivingAcct, requestingAcct, t.moveStr) | ||||
| 		if t.err != "" { | ||||
| 			suite.EqualError(err, t.err) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestMoveTestSuite(t *testing.T) { | ||||
| 	suite.Run(t, &MoveTestSuite{}) | ||||
| } | ||||
| @@ -450,7 +450,11 @@ func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er | ||||
| // | ||||
| // Applications are not expected to handle every single ActivityStreams | ||||
| // type and extension. The unhandled ones are passed to DefaultCallback. | ||||
| func (f *Federator) FederatingCallbacks(ctx context.Context) (wrapped pub.FederatingWrappedCallbacks, other []interface{}, err error) { | ||||
| func (f *Federator) FederatingCallbacks(ctx context.Context) ( | ||||
| 	wrapped pub.FederatingWrappedCallbacks, | ||||
| 	other []any, | ||||
| 	err error, | ||||
| ) { | ||||
| 	wrapped = pub.FederatingWrappedCallbacks{ | ||||
| 		// OnFollow determines what action to take for this | ||||
| 		// particular callback if a Follow Activity is handled. | ||||
| @@ -461,7 +465,7 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (wrapped pub.Federa | ||||
| 	} | ||||
|  | ||||
| 	// Override some default behaviors to trigger our own side effects. | ||||
| 	other = []interface{}{ | ||||
| 	other = []any{ | ||||
| 		func(ctx context.Context, undo vocab.ActivityStreamsUndo) error { | ||||
| 			return f.FederatingDB().Undo(ctx, undo) | ||||
| 		}, | ||||
| @@ -476,6 +480,14 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (wrapped pub.Federa | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	// Define some of our own behaviors which are not | ||||
| 	// overrides of the default pub.FederatingWrappedCallbacks. | ||||
| 	other = append(other, []any{ | ||||
| 		func(ctx context.Context, move vocab.ActivityStreamsMove) error { | ||||
| 			return f.FederatingDB().Move(ctx, move) | ||||
| 		}, | ||||
| 	}...) | ||||
|  | ||||
| 	return | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -187,6 +187,12 @@ func (a *Account) IsSuspended() bool { | ||||
| 	return !a.SuspendedAt.IsZero() | ||||
| } | ||||
|  | ||||
| // IsMoving returns true if | ||||
| // account is Moving or has Moved. | ||||
| func (a *Account) IsMoving() bool { | ||||
| 	return a.MovedToURI != "" || a.MoveID != "" | ||||
| } | ||||
|  | ||||
| // AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis. | ||||
| type AccountToEmoji struct { | ||||
| 	AccountID string   `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` | ||||
|   | ||||
| @@ -39,5 +39,6 @@ type FromFediAPI struct { | ||||
| 	APIri             *url.URL | ||||
| 	APObjectModel     interface{}       // Optional AP model of the Object of the Activity. Should be Accountable or Statusable. | ||||
| 	GTSModel          interface{}       // Optional GTS model of the Activity or Object. | ||||
| 	RequestingAccount *gtsmodel.Account // Remote account that posted this Activity to the inbox. | ||||
| 	ReceivingAccount  *gtsmodel.Account // Local account which owns the inbox that this Activity was posted to. | ||||
| } | ||||
|   | ||||
| @@ -145,6 +145,15 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg messages.FromFe | ||||
| 		case ap.ObjectProfile: | ||||
| 			return p.fediAPI.DeleteAccount(ctx, fMsg) | ||||
| 		} | ||||
|  | ||||
| 	// MOVE SOMETHING | ||||
| 	case ap.ActivityMove: | ||||
|  | ||||
| 		// MOVE PROFILE/ACCOUNT | ||||
| 		// fromfediapi_move.go. | ||||
| 		if fMsg.APObjectType == ap.ObjectProfile { | ||||
| 			return p.fediAPI.MoveAccount(ctx, fMsg) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return gtserror.Newf("unhandled: %s %s", fMsg.APActivityType, fMsg.APObjectType) | ||||
|   | ||||
							
								
								
									
										574
									
								
								internal/processing/workers/fromfediapi_move.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										574
									
								
								internal/processing/workers/fromfediapi_move.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,574 @@ | ||||
| // 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 workers | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"time" | ||||
|  | ||||
| 	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" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||
| 	"github.com/superseriousbusiness/gotosocial/internal/messages" | ||||
| ) | ||||
|  | ||||
| // ShouldProcessMove checks whether we should attempt | ||||
| // to process a move with the given object and target, | ||||
| // based on whether or not a move with those values | ||||
| // was attempted or succeeded recently. | ||||
| func (p *fediAPI) ShouldProcessMove( | ||||
| 	ctx context.Context, | ||||
| 	object string, | ||||
| 	target string, | ||||
| ) (bool, error) { | ||||
| 	// 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. | ||||
| 	// | ||||
| 	// This avoids the potential DDOS vector of a given | ||||
| 	// origin account spamming out moves to various | ||||
| 	// target accounts, causing loads of dereferences. | ||||
| 	latestMoveAttempt, err := p.state.DB.GetLatestMoveAttemptInvolvingURIs( | ||||
| 		ctx, object, target, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return false, gtserror.Newf( | ||||
| 			"error checking latest Move attempt involving object %s and target %s: %w", | ||||
| 			object, target, err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	if !latestMoveAttempt.IsZero() && | ||||
| 		time.Since(latestMoveAttempt) < 5*time.Minute { | ||||
| 		log.Infof(ctx, | ||||
| 			"object %s or target %s have been involved in a Move attempt within the last 5 minutes, will not process Move", | ||||
| 			object, target, | ||||
| 		) | ||||
| 		return false, nil | ||||
| 	} | ||||
|  | ||||
| 	// 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, object, target, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return false, gtserror.Newf( | ||||
| 			"error checking latest Move success involving object %s and target %s: %w", | ||||
| 			object, target, err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	if !latestMoveSuccess.IsZero() && | ||||
| 		time.Since(latestMoveSuccess) < 168*time.Hour { | ||||
| 		log.Infof(ctx, | ||||
| 			"object %s or target %s have been involved in a successful Move within the last 7 days, will not process Move", | ||||
| 			object, target, | ||||
| 		) | ||||
| 		return false, nil | ||||
| 	} | ||||
|  | ||||
| 	return true, nil | ||||
| } | ||||
|  | ||||
| // GetOrCreateMove takes a stub move created by the | ||||
| // requesting account, and either retrieves or creates | ||||
| // a corresponding move in the database. If a move is | ||||
| // created in this way, requestingAcct will be updated | ||||
| // with the correct moveID. | ||||
| func (p *fediAPI) GetOrCreateMove( | ||||
| 	ctx context.Context, | ||||
| 	requestingAcct *gtsmodel.Account, | ||||
| 	stubMove *gtsmodel.Move, | ||||
| ) (*gtsmodel.Move, error) { | ||||
| 	var ( | ||||
| 		moveURIStr = stubMove.URI | ||||
| 		objectStr  = stubMove.OriginURI | ||||
| 		object     = stubMove.Origin | ||||
| 		targetStr  = stubMove.TargetURI | ||||
| 		target     = stubMove.Target | ||||
|  | ||||
| 		move *gtsmodel.Move | ||||
| 		err  error | ||||
| 	) | ||||
|  | ||||
| 	// See if we have a move with | ||||
| 	// this ID/URI stored already. | ||||
| 	move, err = p.state.DB.GetMoveByURI(ctx, moveURIStr) | ||||
| 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||
| 		return nil, gtserror.Newf( | ||||
| 			"db error retrieving move with URI %s: %w", | ||||
| 			moveURIStr, err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	if move != nil { | ||||
| 		// We had a Move with this ID/URI. | ||||
| 		// | ||||
| 		// Make sure the Move we already had | ||||
| 		// stored has the same origin + target. | ||||
| 		if move.OriginURI != objectStr || | ||||
| 			move.TargetURI != targetStr { | ||||
| 			return nil, gtserror.Newf( | ||||
| 				"Move object %s and/or target %s differ from stored object and target for this ID (%s)", | ||||
| 				objectStr, targetStr, moveURIStr, | ||||
| 			) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// If we didn't have a move stored for | ||||
| 	// this ID/URI, then see if we have a | ||||
| 	// Move with this origin and target | ||||
| 	// already (but a different ID/URI). | ||||
| 	if move == nil { | ||||
| 		move, err = p.state.DB.GetMoveByOriginTarget(ctx, objectStr, targetStr) | ||||
| 		if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||
| 			return nil, gtserror.Newf( | ||||
| 				"db error retrieving Move with object %s and target %s: %w", | ||||
| 				objectStr, targetStr, err, | ||||
| 			) | ||||
| 		} | ||||
|  | ||||
| 		if move != nil { | ||||
| 			// We had a move for this object and | ||||
| 			// target, but the ID/URI has changed. | ||||
| 			// Update the Move's URI in the db to | ||||
| 			// reflect that this is but the latest | ||||
| 			// attempt with this origin + target. | ||||
| 			// | ||||
| 			// The remote may be trying to retry | ||||
| 			// the Move but their server might | ||||
| 			// not reuse the same Activity URIs, | ||||
| 			// and we don't want to store a brand | ||||
| 			// new Move for each attempt! | ||||
| 			move.URI = moveURIStr | ||||
| 			if err := p.state.DB.UpdateMove(ctx, move, "uri"); err != nil { | ||||
| 				return nil, gtserror.Newf( | ||||
| 					"db error updating Move with object %s and target %s: %w", | ||||
| 					objectStr, targetStr, err, | ||||
| 				) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if move == nil { | ||||
| 		// If Move is still nil then | ||||
| 		// we didn't have this Move | ||||
| 		// stored yet, so it's new. | ||||
| 		// Store it now! | ||||
| 		move = >smodel.Move{ | ||||
| 			ID:          id.NewULID(), | ||||
| 			AttemptedAt: time.Now(), | ||||
| 			OriginURI:   objectStr, | ||||
| 			Origin:      object, | ||||
| 			TargetURI:   targetStr, | ||||
| 			Target:      target, | ||||
| 			URI:         moveURIStr, | ||||
| 		} | ||||
| 		if err := p.state.DB.PutMove(ctx, move); err != nil { | ||||
| 			return nil, gtserror.Newf( | ||||
| 				"db error storing move %s: %w", | ||||
| 				moveURIStr, err, | ||||
| 			) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// If move_id isn't set on the requesting | ||||
| 	// account yet, set it so other processes | ||||
| 	// know there's a Move in progress. | ||||
| 	if requestingAcct.MoveID != move.ID { | ||||
| 		requestingAcct.Move = move | ||||
| 		requestingAcct.MoveID = move.ID | ||||
| 		if err := p.state.DB.UpdateAccount(ctx, | ||||
| 			requestingAcct, "move_id", | ||||
| 		); err != nil { | ||||
| 			return nil, gtserror.Newf( | ||||
| 				"db error updating move_id on account: %w", | ||||
| 				err, | ||||
| 			) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return move, nil | ||||
| } | ||||
|  | ||||
| // MoveAccount processes the given | ||||
| // Move FromFediAPI message: | ||||
| // | ||||
| //	APObjectType:     "Profile" | ||||
| //	APActivityType:   "Move" | ||||
| //	GTSModel:         stub *gtsmodel.Move. | ||||
| //	ReceivingAccount: Account of inbox owner receiving the Move. | ||||
| func (p *fediAPI) MoveAccount(ctx context.Context, fMsg messages.FromFediAPI) error { | ||||
| 	// The account who received the Move message. | ||||
| 	receiver := fMsg.ReceivingAccount | ||||
|  | ||||
| 	// *gtsmodel.Move activity. | ||||
| 	stubMove, ok := fMsg.GTSModel.(*gtsmodel.Move) | ||||
| 	if !ok { | ||||
| 		return gtserror.Newf( | ||||
| 			"%T not parseable as *gtsmodel.Move", | ||||
| 			fMsg.GTSModel, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	// Move origin and target info. | ||||
| 	var ( | ||||
| 		originAcctURIStr = stubMove.OriginURI | ||||
| 		originAcct       = fMsg.RequestingAccount | ||||
| 		targetAcctURIStr = stubMove.TargetURI | ||||
| 		targetAcctURI    = stubMove.Target | ||||
| 	) | ||||
|  | ||||
| 	// Assemble log context. | ||||
| 	l := log. | ||||
| 		WithContext(ctx). | ||||
| 		WithField("originAcct", originAcctURIStr). | ||||
| 		WithField("targetAcct", targetAcctURIStr) | ||||
|  | ||||
| 	// 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 { | ||||
| 		return gtserror.Newf( | ||||
| 			"db error checking if target domain %s blocked: %w", | ||||
| 			targetAcctURI.Host, err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	if targetDomainBlocked { | ||||
| 		l.Info("target domain is blocked, will not process Move") | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Next steps require making calls to remote + | ||||
| 	// setting values that may be attempted by other | ||||
| 	// in-process Moves. To avoid race conditions, | ||||
| 	// ensure we're only trying to process this | ||||
| 	// Move combo one attempt at a time. | ||||
| 	// | ||||
| 	// We use a custom lock because remotes might | ||||
| 	// try to send the same Move several times with | ||||
| 	// different IDs (you never know), but we only | ||||
| 	// want to process them based on origin + target. | ||||
| 	unlock := p.state.FedLocks.Lock( | ||||
| 		"move:" + originAcctURIStr + ":" + targetAcctURIStr, | ||||
| 	) | ||||
| 	defer unlock() | ||||
|  | ||||
| 	// Check if Move is rate limited based | ||||
| 	// on previous attempts / successes. | ||||
| 	shouldProcess, err := p.ShouldProcessMove(ctx, | ||||
| 		originAcctURIStr, targetAcctURIStr, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return gtserror.Newf( | ||||
| 			"error checking if Move should be processed now: %w", | ||||
| 			err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	if !shouldProcess { | ||||
| 		// Move is rate limited, so don't process. | ||||
| 		// Reason why should already be logged. | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Store new or retrieve existing Move. This will | ||||
| 	// also update moveID on originAcct if necessary. | ||||
| 	move, err := p.GetOrCreateMove(ctx, originAcct, stubMove) | ||||
| 	if err != nil { | ||||
| 		return gtserror.Newf( | ||||
| 			"error refreshing target account %s: %w", | ||||
| 			targetAcctURIStr, err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	// Account to which the Move is taking place. | ||||
| 	targetAcct, targetAcctable, err := p.federate.GetAccountByURI( | ||||
| 		ctx, | ||||
| 		receiver.Username, | ||||
| 		targetAcctURI, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return gtserror.Newf( | ||||
| 			"error getting target account %s: %w", | ||||
| 			targetAcctURIStr, err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	// If target is suspended from this instance, | ||||
| 	// then we can't/won't process any move side | ||||
| 	// effects to that account, because: | ||||
| 	// | ||||
| 	//   1. We can't verify that it's aliased correctly | ||||
| 	//      back to originAcct without dereferencing it. | ||||
| 	//   2. We can't/won't forward follows to a suspended | ||||
| 	//      account, since suspension would remove follows | ||||
| 	//      etc. targeting the new account anyways. | ||||
| 	//   3. If someone is moving to a suspended account | ||||
| 	//      they probably totally suck ass (according to | ||||
| 	//      the moderators of this instance, anyway) so | ||||
| 	//      to hell with it. | ||||
| 	if targetAcct.IsSuspended() { | ||||
| 		l.Info("target account is suspended, will not process Move") | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if targetAcct.IsRemote() { | ||||
| 		// Force refresh Move target account | ||||
| 		// to ensure we have up-to-date version. | ||||
| 		targetAcct, _, err = p.federate.RefreshAccount(ctx, | ||||
| 			receiver.Username, | ||||
| 			targetAcct, | ||||
| 			targetAcctable, | ||||
| 			dereferencing.Freshest, | ||||
| 		) | ||||
| 		if err != nil { | ||||
| 			return gtserror.Newf( | ||||
| 				"error refreshing target account %s: %w", | ||||
| 				targetAcctURIStr, err, | ||||
| 			) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Target must not itself have moved somewhere. | ||||
| 	// You can't move to an already-moved account. | ||||
| 	targetAcctMovedTo := targetAcct.MovedToURI | ||||
| 	if targetAcctMovedTo != "" { | ||||
| 		l.Infof( | ||||
| 			"target account has, itself, already moved to %s, will not process Move", | ||||
| 			targetAcctMovedTo, | ||||
| 		) | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Target must be aliased back to origin account. | ||||
| 	// Ie., its alsoKnownAs values must include the | ||||
| 	// origin account, so we know it's for real. | ||||
| 	if !targetAcct.IsAliasedTo(originAcctURIStr) { | ||||
| 		l.Info("target account is not aliased back to origin account, will not process Move") | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	/* | ||||
| 		At this point we know that the move | ||||
| 		looks valid and we should process it. | ||||
| 	*/ | ||||
|  | ||||
| 	// Transfer originAcct's followers | ||||
| 	// on this instance to targetAcct. | ||||
| 	redirectOK := p.RedirectAccountFollowers( | ||||
| 		ctx, | ||||
| 		originAcct, | ||||
| 		targetAcct, | ||||
| 	) | ||||
|  | ||||
| 	// Remove follows on this | ||||
| 	// instance owned by originAcct. | ||||
| 	removeFollowingOK := p.RemoveAccountFollowing( | ||||
| 		ctx, | ||||
| 		originAcct, | ||||
| 	) | ||||
|  | ||||
| 	// Whatever happened above, error or | ||||
| 	// not, we've just at least attempted | ||||
| 	// the Move so we'll need to update it. | ||||
| 	move.AttemptedAt = time.Now() | ||||
| 	updateColumns := []string{"attempted_at"} | ||||
|  | ||||
| 	if redirectOK && removeFollowingOK { | ||||
| 		// All OK means we can mark the | ||||
| 		// Move as definitively succeeded. | ||||
| 		// | ||||
| 		// Take same time so SucceededAt | ||||
| 		// isn't 0.0001s later or something. | ||||
| 		move.SucceededAt = move.AttemptedAt | ||||
| 		updateColumns = append(updateColumns, "succeeded_at") | ||||
| 	} | ||||
|  | ||||
| 	// Update whatever columns we need to update. | ||||
| 	if err := p.state.DB.UpdateMove(ctx, | ||||
| 		move, updateColumns..., | ||||
| 	); err != nil { | ||||
| 		return gtserror.Newf( | ||||
| 			"db error updating Move %s: %w", | ||||
| 			move.URI, err, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // RedirectAccountFollowers redirects all local | ||||
| // followers of originAcct to targetAcct. | ||||
| // | ||||
| // Both accounts must be fully dereferenced | ||||
| // already, and the Move must be valid. | ||||
| // | ||||
| // Callers to this function MUST have obtained | ||||
| // a lock already by calling FedLocks.Lock. | ||||
| // | ||||
| // Return bool will be true if all goes OK. | ||||
| func (p *fediAPI) RedirectAccountFollowers( | ||||
| 	ctx context.Context, | ||||
| 	originAcct *gtsmodel.Account, | ||||
| 	targetAcct *gtsmodel.Account, | ||||
| ) bool { | ||||
| 	// Any local followers of originAcct should | ||||
| 	// send follow requests to targetAcct instead, | ||||
| 	// and have followers of originAcct removed. | ||||
| 	// | ||||
| 	// Select local followers with barebones, since | ||||
| 	// we only need follow.Account and we can get | ||||
| 	// that ourselves. | ||||
| 	followers, err := p.state.DB.GetAccountLocalFollowers( | ||||
| 		gtscontext.SetBarebones(ctx), | ||||
| 		originAcct.ID, | ||||
| 	) | ||||
| 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||
| 		log.Errorf(ctx, | ||||
| 			"db error getting follows targeting originAcct: %v", | ||||
| 			err, | ||||
| 		) | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	for _, follow := range followers { | ||||
| 		// Fetch the local account that | ||||
| 		// owns the follow targeting originAcct. | ||||
| 		if follow.Account, err = p.state.DB.GetAccountByID( | ||||
| 			gtscontext.SetBarebones(ctx), | ||||
| 			follow.AccountID, | ||||
| 		); err != nil { | ||||
| 			log.Errorf(ctx, | ||||
| 				"db error getting follow account %s: %v", | ||||
| 				follow.AccountID, err, | ||||
| 			) | ||||
| 			return false | ||||
| 		} | ||||
|  | ||||
| 		// Use the account processor FollowCreate | ||||
| 		// function to send off the new follow, | ||||
| 		// carrying over the Reblogs and Notify | ||||
| 		// values from the old follow to the new. | ||||
| 		// | ||||
| 		// This will also handle cases where our | ||||
| 		// account has already followed the target | ||||
| 		// account, by just updating the existing | ||||
| 		// follow of target account. | ||||
| 		if _, err := p.account.FollowCreate( | ||||
| 			ctx, | ||||
| 			follow.Account, | ||||
| 			&apimodel.AccountFollowRequest{ | ||||
| 				ID:      targetAcct.ID, | ||||
| 				Reblogs: follow.ShowReblogs, | ||||
| 				Notify:  follow.Notify, | ||||
| 			}, | ||||
| 		); err != nil { | ||||
| 			log.Errorf(ctx, | ||||
| 				"error creating new follow for account %s: %v", | ||||
| 				follow.AccountID, err, | ||||
| 			) | ||||
| 			return false | ||||
| 		} | ||||
|  | ||||
| 		// New follow is in the process of | ||||
| 		// sending, remove the existing follow. | ||||
| 		// This will send out an Undo Activity for each Follow. | ||||
| 		if _, err := p.account.FollowRemove( | ||||
| 			ctx, | ||||
| 			follow.Account, | ||||
| 			follow.TargetAccountID, | ||||
| 		); err != nil { | ||||
| 			log.Errorf(ctx, | ||||
| 				"error removing old follow for account %s: %v", | ||||
| 				follow.AccountID, err, | ||||
| 			) | ||||
| 			return false | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return true | ||||
| } | ||||
|  | ||||
| // RemoveAccountFollowing removes all | ||||
| // follows owned by the move originAcct. | ||||
| // | ||||
| // originAcct must be fully dereferenced | ||||
| // already, and the Move must be valid. | ||||
| // | ||||
| // Callers to this function MUST have obtained | ||||
| // a lock already by calling FedLocks.Lock. | ||||
| // | ||||
| // Return bool will be true if all goes OK. | ||||
| func (p *fediAPI) RemoveAccountFollowing( | ||||
| 	ctx context.Context, | ||||
| 	originAcct *gtsmodel.Account, | ||||
| ) bool { | ||||
| 	// Any follows owned by originAcct which target | ||||
| 	// accounts on our instance should be removed. | ||||
| 	// | ||||
| 	// We should rely on the target instance | ||||
| 	// to send out new follows from targetAcct. | ||||
| 	following, err := p.state.DB.GetAccountLocalFollows( | ||||
| 		gtscontext.SetBarebones(ctx), | ||||
| 		originAcct.ID, | ||||
| 	) | ||||
| 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||
| 		log.Errorf(ctx, | ||||
| 			"db error getting follows owned by originAcct: %v", | ||||
| 			err, | ||||
| 		) | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	for _, follow := range following { | ||||
| 		// Ditch it. This is a one-way action | ||||
| 		// from our side so we don't need to | ||||
| 		// send any messages this time. | ||||
| 		if err := p.state.DB.DeleteFollowByID(ctx, follow.ID); err != nil { | ||||
| 			log.Errorf(ctx, | ||||
| 				"error removing old follow owned by account %s: %v", | ||||
| 				follow.AccountID, err, | ||||
| 			) | ||||
| 			return false | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Finally delete any follow requests | ||||
| 	// owned by or targeting the originAcct. | ||||
| 	if err := p.state.DB.DeleteAccountFollowRequests( | ||||
| 		ctx, originAcct.ID, | ||||
| 	); err != nil { | ||||
| 		log.Errorf(ctx, | ||||
| 			"db error deleting follow requests involving originAcct %s: %v", | ||||
| 			originAcct.URI, err, | ||||
| 		) | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	return true | ||||
| } | ||||
| @@ -536,6 +536,75 @@ func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() { | ||||
| 	suite.Equal(statusCreator.URI, s.AccountURI) | ||||
| } | ||||
|  | ||||
| func (suite *FromFediAPITestSuite) TestMoveAccount() { | ||||
| 	// We're gonna migrate foss_satan to our local admin account. | ||||
| 	ctx := context.Background() | ||||
| 	receivingAcct := suite.testAccounts["local_account_1"] | ||||
|  | ||||
| 	// Copy requesting and target accounts | ||||
| 	// since we'll be changing these. | ||||
| 	requestingAcct := >smodel.Account{} | ||||
| 	*requestingAcct = *suite.testAccounts["remote_account_1"] | ||||
| 	targetAcct := >smodel.Account{} | ||||
| 	*targetAcct = *suite.testAccounts["admin_account"] | ||||
|  | ||||
| 	// Set alsoKnownAs on the admin account. | ||||
| 	targetAcct.AlsoKnownAsURIs = []string{requestingAcct.URI} | ||||
| 	if err := suite.state.DB.UpdateAccount(ctx, targetAcct, "also_known_as_uris"); err != nil { | ||||
| 		suite.FailNow(err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	// Remove existing follow from zork to admin account. | ||||
| 	if err := suite.state.DB.DeleteFollowByID( | ||||
| 		ctx, | ||||
| 		suite.testFollows["local_account_1_admin_account"].ID, | ||||
| 	); err != nil { | ||||
| 		suite.FailNow(err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	// Have Zork follow foss_satan instead. | ||||
| 	if err := suite.state.DB.PutFollow(ctx, >smodel.Follow{ | ||||
| 		ID:              "01HRA0XZYFZC5MNWTKEBR58SSE", | ||||
| 		URI:             "http://localhost:8080/users/the_mighty_zork/follows/01HRA0XZYFZC5MNWTKEBR58SSE", | ||||
| 		AccountID:       receivingAcct.ID, | ||||
| 		TargetAccountID: requestingAcct.ID, | ||||
| 	}); err != nil { | ||||
| 		suite.FailNow(err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	// Process the Move. | ||||
| 	err := suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{ | ||||
| 		APObjectType:   ap.ObjectProfile, | ||||
| 		APActivityType: ap.ActivityMove, | ||||
| 		GTSModel: >smodel.Move{ | ||||
| 			OriginURI: requestingAcct.URI, | ||||
| 			Origin:    testrig.URLMustParse(requestingAcct.URI), | ||||
| 			TargetURI: targetAcct.URI, | ||||
| 			Target:    testrig.URLMustParse(targetAcct.URI), | ||||
| 			URI:       "https://fossbros-anonymous.io/users/foss_satan/moves/01HRA064871MR8HGVSAFJ333GM", | ||||
| 		}, | ||||
| 		ReceivingAccount:  receivingAcct, | ||||
| 		RequestingAccount: requestingAcct, | ||||
| 	}) | ||||
| 	suite.NoError(err) | ||||
|  | ||||
| 	// Zork should now be following admin account. | ||||
| 	follows, err := suite.state.DB.IsFollowing(ctx, receivingAcct.ID, targetAcct.ID) | ||||
| 	if err != nil { | ||||
| 		suite.FailNow(err.Error()) | ||||
| 	} | ||||
| 	suite.True(follows) | ||||
|  | ||||
| 	// Move should be in the DB. | ||||
| 	move, err := suite.state.DB.GetMoveByURI(ctx, "https://fossbros-anonymous.io/users/foss_satan/moves/01HRA064871MR8HGVSAFJ333GM") | ||||
| 	if err != nil { | ||||
| 		suite.FailNow(err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	// Move should be marked as completed. | ||||
| 	suite.WithinDuration(time.Now(), move.SucceededAt, 1*time.Minute) | ||||
| } | ||||
|  | ||||
| func TestFromFederatorTestSuite(t *testing.T) { | ||||
| 	suite.Run(t, &FromFediAPITestSuite{}) | ||||
| } | ||||
|   | ||||
| @@ -42,9 +42,12 @@ type State struct { | ||||
| 	// DB provides access to the database. | ||||
| 	DB db.DB | ||||
|  | ||||
| 	// FedLocks provides access to this state's mutex map | ||||
| 	// of per URI federation locks. Used during dereferencing | ||||
| 	// and by the go-fed/activity library. | ||||
| 	// FedLocks provides access to this state's | ||||
| 	// mutex map of per URI federation locks. | ||||
| 	// | ||||
| 	// Used during account and status dereferencing, | ||||
| 	// message processing in the FromFediAPI worker | ||||
| 	// functions, and by the go-fed/activity library. | ||||
| 	FedLocks mutexes.MutexMap | ||||
|  | ||||
| 	// Storage provides access to the storage driver. | ||||
|   | ||||
		Reference in New Issue
	
	Block a user