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 @@ var (
// This is tuned to be quite fresh without
// causing loads of dereferencing calls.
Fresh = util.Ptr(FreshnessWindow(5 * time.Minute))
+
+ // 10 seconds.
+ //
+ // Freshest is useful when you want an
+ // immediately up to date model of something
+ // that's even fresher than Fresh.
+ //
+ // Be careful using this one; it can cause
+ // lots of unnecessary traffic if used unwisely.
+ Freshest = util.Ptr(FreshnessWindow(10 * time.Second))
)
// Dereferencer wraps logic and functionality for doing dereferencing
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 @@ import (
// DB wraps the pub.Database interface with
// a couple of custom functions for GoToSocial.
type DB interface {
+ // Default functionality.
pub.Database
+
+ /*
+ Overridden functionality for calling from federatingProtocol.
+ */
+
Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error
Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error
Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error
Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error
+ Move(ctx context.Context, move vocab.ActivityStreamsMove) error
}
// FederatingDB uses the given state interface
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 (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
}
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.