[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:
parent
5e871e81a8
commit
1bcdf1da3b
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in New Issue