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 | ### `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 | 		return true | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !account.SuspendedAt.IsZero() { | 	if account.IsSuspended() { | ||||||
| 		// Can't refresh | 		// Can't/won't refresh | ||||||
| 		// suspended accounts. | 		// suspended accounts. | ||||||
| 		return true | 		return true | ||||||
| 	} | 	} | ||||||
| @@ -388,8 +388,9 @@ func (d *Dereferencer) enrichAccountSafely( | |||||||
| 	account *gtsmodel.Account, | 	account *gtsmodel.Account, | ||||||
| 	accountable ap.Accountable, | 	accountable ap.Accountable, | ||||||
| ) (*gtsmodel.Account, ap.Accountable, error) { | ) (*gtsmodel.Account, ap.Accountable, error) { | ||||||
| 	// Noop if account has been suspended. | 	// Noop if account suspended; | ||||||
| 	if !account.SuspendedAt.IsZero() { | 	// we don't want to deref it. | ||||||
|  | 	if account.IsSuspended() { | ||||||
| 		return account, nil, nil | 		return account, nil, nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -64,6 +64,16 @@ var ( | |||||||
| 	// This is tuned to be quite fresh without | 	// This is tuned to be quite fresh without | ||||||
| 	// causing loads of dereferencing calls. | 	// causing loads of dereferencing calls. | ||||||
| 	Fresh = util.Ptr(FreshnessWindow(5 * time.Minute)) | 	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 | // 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 | 	requestingAcct := activityContext.requestingAcct | ||||||
| 	receivingAcct := activityContext.receivingAcct | 	receivingAcct := activityContext.receivingAcct | ||||||
|  |  | ||||||
|  | 	if requestingAcct.IsMoving() { | ||||||
|  | 		// A Moving account | ||||||
|  | 		// can't do this. | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	// Iterate all provided objects in the activity. | 	// Iterate all provided objects in the activity. | ||||||
| 	for _, object := range ap.ExtractObjects(accept) { | 	for _, object := range ap.ExtractObjects(accept) { | ||||||
|  |  | ||||||
|   | |||||||
| @@ -49,6 +49,12 @@ func (f *federatingDB) Announce(ctx context.Context, announce vocab.ActivityStre | |||||||
| 	requestingAcct := activityContext.requestingAcct | 	requestingAcct := activityContext.requestingAcct | ||||||
| 	receivingAcct := activityContext.receivingAcct | 	receivingAcct := activityContext.receivingAcct | ||||||
|  |  | ||||||
|  | 	if requestingAcct.IsMoving() { | ||||||
|  | 		// A Moving account | ||||||
|  | 		// can't do this. | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	// Ensure requestingAccount is among | 	// Ensure requestingAccount is among | ||||||
| 	// the Actors doing the Announce. | 	// the Actors doing the Announce. | ||||||
| 	// | 	// | ||||||
|   | |||||||
| @@ -68,6 +68,12 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { | |||||||
| 	requestingAcct := activityContext.requestingAcct | 	requestingAcct := activityContext.requestingAcct | ||||||
| 	receivingAcct := activityContext.receivingAcct | 	receivingAcct := activityContext.receivingAcct | ||||||
|  |  | ||||||
|  | 	if requestingAcct.IsMoving() { | ||||||
|  | 		// A Moving account | ||||||
|  | 		// can't do this. | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	switch asType.GetTypeName() { | 	switch asType.GetTypeName() { | ||||||
| 	case ap.ActivityBlock: | 	case ap.ActivityBlock: | ||||||
| 		// BLOCK SOMETHING | 		// BLOCK SOMETHING | ||||||
|   | |||||||
| @@ -31,11 +31,18 @@ import ( | |||||||
| // DB wraps the pub.Database interface with | // DB wraps the pub.Database interface with | ||||||
| // a couple of custom functions for GoToSocial. | // a couple of custom functions for GoToSocial. | ||||||
| type DB interface { | type DB interface { | ||||||
|  | 	// Default functionality. | ||||||
| 	pub.Database | 	pub.Database | ||||||
|  |  | ||||||
|  | 	/* | ||||||
|  | 		Overridden functionality for calling from federatingProtocol. | ||||||
|  | 	*/ | ||||||
|  |  | ||||||
| 	Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error | 	Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error | ||||||
| 	Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error | 	Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error | ||||||
| 	Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error | 	Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error | ||||||
| 	Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error | 	Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error | ||||||
|  | 	Move(ctx context.Context, move vocab.ActivityStreamsMove) error | ||||||
| } | } | ||||||
|  |  | ||||||
| // FederatingDB uses the given state interface | // 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 | // Applications are not expected to handle every single ActivityStreams | ||||||
| // type and extension. The unhandled ones are passed to DefaultCallback. | // 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{ | 	wrapped = pub.FederatingWrappedCallbacks{ | ||||||
| 		// OnFollow determines what action to take for this | 		// OnFollow determines what action to take for this | ||||||
| 		// particular callback if a Follow Activity is handled. | 		// 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. | 	// Override some default behaviors to trigger our own side effects. | ||||||
| 	other = []interface{}{ | 	other = []any{ | ||||||
| 		func(ctx context.Context, undo vocab.ActivityStreamsUndo) error { | 		func(ctx context.Context, undo vocab.ActivityStreamsUndo) error { | ||||||
| 			return f.FederatingDB().Undo(ctx, undo) | 			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 | 	return | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -187,6 +187,12 @@ func (a *Account) IsSuspended() bool { | |||||||
| 	return !a.SuspendedAt.IsZero() | 	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. | // AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis. | ||||||
| type AccountToEmoji struct { | type AccountToEmoji struct { | ||||||
| 	AccountID string   `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` | 	AccountID string   `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` | ||||||
|   | |||||||
| @@ -34,10 +34,11 @@ type FromClientAPI struct { | |||||||
|  |  | ||||||
| // FromFediAPI wraps a message that travels from the federating API into the processor. | // FromFediAPI wraps a message that travels from the federating API into the processor. | ||||||
| type FromFediAPI struct { | type FromFediAPI struct { | ||||||
| 	APObjectType     string | 	APObjectType      string | ||||||
| 	APActivityType   string | 	APActivityType    string | ||||||
| 	APIri            *url.URL | 	APIri             *url.URL | ||||||
| 	APObjectModel    interface{}       // Optional AP model of the Object of the Activity. Should be Accountable or Statusable. | 	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. | 	GTSModel          interface{}       // Optional GTS model of the Activity or Object. | ||||||
| 	ReceivingAccount *gtsmodel.Account // Local account which owns the inbox that this Activity was posted to. | 	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: | 		case ap.ObjectProfile: | ||||||
| 			return p.fediAPI.DeleteAccount(ctx, fMsg) | 			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) | 	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) | 	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) { | func TestFromFederatorTestSuite(t *testing.T) { | ||||||
| 	suite.Run(t, &FromFediAPITestSuite{}) | 	suite.Run(t, &FromFediAPITestSuite{}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -42,9 +42,12 @@ type State struct { | |||||||
| 	// DB provides access to the database. | 	// DB provides access to the database. | ||||||
| 	DB db.DB | 	DB db.DB | ||||||
|  |  | ||||||
| 	// FedLocks provides access to this state's mutex map | 	// FedLocks provides access to this state's | ||||||
| 	// of per URI federation locks. Used during dereferencing | 	// mutex map of per URI federation locks. | ||||||
| 	// and by the go-fed/activity library. | 	// | ||||||
|  | 	// Used during account and status dereferencing, | ||||||
|  | 	// message processing in the FromFediAPI worker | ||||||
|  | 	// functions, and by the go-fed/activity library. | ||||||
| 	FedLocks mutexes.MutexMap | 	FedLocks mutexes.MutexMap | ||||||
|  |  | ||||||
| 	// Storage provides access to the storage driver. | 	// Storage provides access to the storage driver. | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user