gotosocial/internal/processing/workers/fromfediapi_move.go
tobi ab2d063fcb
[feature] Process outgoing Move from clientAPI (#2750)
* prevent moved accounts from taking create-type actions

* update move logic

* federate move out

* indicate on web profile when an account has moved

* [docs] Add migration docs section

* lock while checking + setting move state

* use redirectFollowers func for clientAPI as well

* comment typo

* linter? i barely know 'er!

* Update internal/uris/uri.go

Co-authored-by: Daenney <daenney@users.noreply.github.com>

* add a couple tests for move

* fix little mistake exposed by tests (thanks tests)

* ensure Move marked as successful

* attach shared util funcs to struct

* lock whole account when doing move

* move moving check to after error check

* replace repeated text with error func

* linterrrrrr!!!!

* catch self follow case

---------

Co-authored-by: Daenney <daenney@users.noreply.github.com>
2024-03-13 13:53:29 +01:00

481 lines
13 KiB
Go

// 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"
"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 = &gtsmodel.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.utilF.redirectFollowers(
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
}
// 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
}