diff --git a/docs/federation/federating_with_gotosocial.md b/docs/federation/federating_with_gotosocial.md index 0825c3fcc..dad673484 100644 --- a/docs/federation/federating_with_gotosocial.md +++ b/docs/federation/federating_with_gotosocial.md @@ -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`. diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 10d15bca6..5e81fb445 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -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 } diff --git a/internal/federation/dereferencing/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index 24e579408..3fa199345 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -64,6 +64,16 @@ // 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 diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go index e1d754f2e..7ec9346e0 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -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) { diff --git a/internal/federation/federatingdb/announce.go b/internal/federation/federatingdb/announce.go index 2ce6d1c59..e13e212da 100644 --- a/internal/federation/federatingdb/announce.go +++ b/internal/federation/federatingdb/announce.go @@ -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. // diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go index cfb0f319b..cacaf07cf 100644 --- a/internal/federation/federatingdb/create.go +++ b/internal/federation/federatingdb/create.go @@ -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 diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go index 2174a8003..12bd5a376 100644 --- a/internal/federation/federatingdb/db.go +++ b/internal/federation/federatingdb/db.go @@ -31,11 +31,18 @@ // 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 diff --git a/internal/federation/federatingdb/move.go b/internal/federation/federatingdb/move.go new file mode 100644 index 000000000..2e8049e08 --- /dev/null +++ b/internal/federation/federatingdb/move.go @@ -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 . + +// 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 +} diff --git a/internal/federation/federatingdb/move_test.go b/internal/federation/federatingdb/move_test.go new file mode 100644 index 000000000..006dcf0dc --- /dev/null +++ b/internal/federation/federatingdb/move_test.go @@ -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 . + +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{}) +} diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index f3ab1ae3c..2c2da7b7b 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -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(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error { }, } + // 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 } diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 82be86955..643dd62b8 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -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"` diff --git a/internal/messages/messages.go b/internal/messages/messages.go index 236aea722..32cb5fbba 100644 --- a/internal/messages/messages.go +++ b/internal/messages/messages.go @@ -34,10 +34,11 @@ type FromClientAPI struct { // FromFediAPI wraps a message that travels from the federating API into the processor. type FromFediAPI struct { - APObjectType string - APActivityType string - 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. - ReceivingAccount *gtsmodel.Account // Local account which owns the inbox that this Activity was posted to. + APObjectType string + APActivityType string + 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. } diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 74ec0db25..62cb58c83 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -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) diff --git a/internal/processing/workers/fromfediapi_move.go b/internal/processing/workers/fromfediapi_move.go new file mode 100644 index 000000000..2223a21f5 --- /dev/null +++ b/internal/processing/workers/fromfediapi_move.go @@ -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 . + +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 +} diff --git a/internal/processing/workers/fromfediapi_test.go b/internal/processing/workers/fromfediapi_test.go index 60a9e785e..b7466ec73 100644 --- a/internal/processing/workers/fromfediapi_test.go +++ b/internal/processing/workers/fromfediapi_test.go @@ -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{}) } diff --git a/internal/state/state.go b/internal/state/state.go index 7cd0406b0..5dfe83271 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -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.