[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>
This commit is contained in:
tobi 2024-03-13 13:53:29 +01:00 committed by GitHub
commit ab2d063fcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 1124 additions and 309 deletions

View file

@ -192,7 +192,7 @@ func GetObjectIRIs(with WithObject) []*url.URL {
}
// AppendObjectIRIs appends the given IRIs to the Object property of 'with'.
func AppendObjectIRIs(with WithObject) {
func AppendObjectIRIs(with WithObject, object ...*url.URL) {
appendIRIs(func() Property[vocab.ActivityStreamsObjectPropertyIterator] {
objectProp := with.GetActivityStreamsObject()
if objectProp == nil {
@ -200,7 +200,7 @@ func AppendObjectIRIs(with WithObject) {
with.SetActivityStreamsObject(objectProp)
}
return objectProp
})
}, object...)
}
// GetTargetIRIs returns the IRIs contained in the Target property of 'with'.
@ -210,7 +210,7 @@ func GetTargetIRIs(with WithTarget) []*url.URL {
}
// AppendTargetIRIs appends the given IRIs to the Target property of 'with'.
func AppendTargetIRIs(with WithTarget) {
func AppendTargetIRIs(with WithTarget, target ...*url.URL) {
appendIRIs(func() Property[vocab.ActivityStreamsTargetPropertyIterator] {
targetProp := with.GetActivityStreamsTarget()
if targetProp == nil {
@ -218,7 +218,7 @@ func AppendTargetIRIs(with WithTarget) {
with.SetActivityStreamsTarget(targetProp)
}
return targetProp
})
}, target...)
}
// GetAttributedTo returns the IRIs contained in the AttributedTo property of 'with'.

View file

@ -97,6 +97,11 @@ func (m *Module) AccountFollowPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -72,6 +72,13 @@ func (m *Module) AccountLookupGETHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
// For moving/moved accounts, just return
// empty to avoid breaking client apps.
apiutil.NotFoundAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -81,6 +81,11 @@ func (m *Module) AccountNotePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -113,6 +113,13 @@ func (m *Module) AccountSearchGETHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
// For moving/moved accounts, just return
// empty to avoid breaking client apps.
apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -152,6 +152,13 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() && targetAcctID != authed.Account.ID {
// For moving/moved accounts, allow the
// account to view its own statuses only.
apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
return
}
limit := 30
limitString := c.Query(LimitKey)
if limitString != "" {

View file

@ -99,6 +99,11 @@ func (m *Module) AccountActionPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
form := &apimodel.AdminActionRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)

View file

@ -107,6 +107,11 @@ func (m *Module) DomainKeysExpirePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -75,6 +75,11 @@ func (m *Module) createDomainPermissions(
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
@ -178,6 +183,11 @@ func (m *Module) deleteDomainPermission(
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -93,6 +93,11 @@ func (m *Module) EmailTestPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -110,6 +110,11 @@ func (m *Module) EmojiCreatePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -87,6 +87,11 @@ func (m *Module) EmojiDELETEHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -137,6 +137,11 @@ func (m *Module) EmojiPATCHHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -114,6 +114,11 @@ func (m *Module) createHeaderFilter(c *gin.Context, create func(context.Context,
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
errWithCode := gtserror.NewErrorNotAcceptable(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
@ -157,6 +162,11 @@ func (m *Module) deleteHeaderFilter(c *gin.Context, delete func(context.Context,
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
filterID, errWithCode := apiutil.ParseID(c.Param("ID"))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)

View file

@ -81,6 +81,11 @@ func (m *Module) MediaCleanupPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
form := &apimodel.MediaCleanupRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)

View file

@ -83,6 +83,11 @@ func (m *Module) MediaRefetchPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if errWithCode := m.processor.Admin().MediaRefetch(c.Request.Context(), authed.Account, c.Query(DomainQueryKey)); errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -97,6 +97,11 @@ func (m *Module) ReportResolvePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -77,6 +77,11 @@ func (m *Module) RulePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -85,6 +85,11 @@ func (m *Module) RuleDELETEHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -77,6 +77,11 @@ func (m *Module) RulePATCHHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -131,6 +131,11 @@ func (m *Module) FilterPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -137,6 +137,11 @@ func (m *Module) FilterPUTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -75,6 +75,11 @@ func (m *Module) FollowRequestAuthorizePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -144,6 +144,11 @@ func (m *Module) InstanceUpdatePATCHHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
form := &apimodel.InstanceSettingsUpdateRequest{}
if err := c.ShouldBind(&form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)

View file

@ -87,6 +87,11 @@ func (m *Module) ListAccountsPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -74,6 +74,11 @@ func (m *Module) ListCreatePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -104,6 +104,11 @@ func (m *Module) ListUpdatePUTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -108,6 +108,11 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -112,6 +112,11 @@ func (m *Module) MediaPUTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -87,6 +87,11 @@ func (m *Module) PollVotePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
errWithCode := gtserror.NewErrorNotAcceptable(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)

View file

@ -72,6 +72,11 @@ func (m *Module) ReportPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -175,6 +175,18 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
// For moving/moved accounts, just return
// empty to avoid breaking client apps.
results := &apimodel.SearchResult{
Accounts: make([]*apimodel.Account, 0),
Statuses: make([]*apimodel.Status, 0),
Hashtags: make([]any, 0),
}
apiutil.JSON(c, http.StatusOK, results)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -75,6 +75,11 @@ func (m *Module) StatusBookmarkPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -78,6 +78,11 @@ func (m *Module) StatusBoostPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -218,6 +218,11 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -74,6 +74,11 @@ func (m *Module) StatusFavePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -78,6 +78,11 @@ func (m *Module) StatusMutePOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -80,6 +80,11 @@ func (m *Module) StatusPinPOSTHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -185,6 +185,13 @@ func (m *Module) StreamGETHandler(c *gin.Context) {
account = authed.Account
}
if account.IsMoving() {
// Moving accounts can't
// use streaming endpoints.
apiutil.NotFoundAfterMove(c)
return
}
// Get the initial requested stream type, if there is one.
streamType := c.Query(StreamQueryKey)

View file

@ -113,6 +113,13 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
// For moving/moved accounts, just return
// empty to avoid breaking client apps.
apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -112,6 +112,13 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
// For moving/moved accounts, just return
// empty to avoid breaking client apps.
apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -124,6 +124,13 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) {
return
}
if authed.Account != nil && authed.Account.IsMoving() {
// For moving/moved accounts, just return
// empty to avoid breaking client apps.
apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -114,6 +114,13 @@ func (m *Module) TagTimelineGETHandler(c *gin.Context) {
return
}
if authed.Account.IsMoving() {
// For moving/moved accounts, just return
// empty to avoid breaking client apps.
apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -184,3 +184,21 @@ func OAuthErrorHandler(c *gin.Context, errWithCode gtserror.WithCode) {
"error_description": errWithCode.Safe(),
})
}
// NotFoundAfterMove returns code 404 to the caller and writes a helpful error message.
// Specifically used for accounts trying to access endpoints they cannot use while moving.
func NotFoundAfterMove(c *gin.Context) {
const errMsg = "your account has Moved or is currently Moving; you cannot use this endpoint"
JSON(c, http.StatusForbidden, map[string]string{
"error": errMsg,
})
}
// ForbiddenAfterMove returns code 403 to the caller and writes a helpful error message.
// Specifically used for accounts trying to take actions on endpoints they cannot do while moving.
func ForbiddenAfterMove(c *gin.Context) {
const errMsg = "your account has Moved or is currently Moving; you cannot take create or update type actions"
JSON(c, http.StatusForbidden, map[string]string{
"error": errMsg,
})
}

View file

@ -177,21 +177,31 @@ func (m *moveDB) getMove(
}
// Populate the Move by parsing out the URIs.
if err := m.PopulateMove(ctx, move); err != nil {
return nil, err
}
return move, nil
}
func (m *moveDB) PopulateMove(ctx context.Context, move *gtsmodel.Move) error {
if move.Origin == nil {
var err error
move.Origin, err = url.Parse(move.OriginURI)
if err != nil {
return nil, fmt.Errorf("error parsing Move originURI: %w", err)
return fmt.Errorf("error parsing Move originURI: %w", err)
}
}
if move.Target == nil {
var err error
move.Target, err = url.Parse(move.TargetURI)
if err != nil {
return nil, fmt.Errorf("error parsing Move originURI: %w", err)
return fmt.Errorf("error parsing Move targetURI: %w", err)
}
}
return move, nil
return nil
}
func (m *moveDB) PutMove(ctx context.Context, move *gtsmodel.Move) error {

View file

@ -34,6 +34,9 @@ type Move interface {
// GetMoveByOriginTarget gets one move with the given originURI and targetURI.
GetMoveByOriginTarget(ctx context.Context, originURI string, targetURI string) (*gtsmodel.Move, error)
// PopulateMove parses out the origin and target URIs on the move.
PopulateMove(ctx context.Context, move *gtsmodel.Move) error
// GetLatestMoveSuccessInvolvingURIs gets the time of
// the latest successfully-processed Move that includes
// either uri1 or uri2 in target or origin positions.

View file

@ -23,14 +23,17 @@ import (
"fmt"
"net/url"
"slices"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"golang.org/x/crypto/bcrypt"
)
@ -45,13 +48,14 @@ func (p *Processor) MoveSelf(
return gtserror.NewErrorBadRequest(err, err.Error())
}
movedToURI, err := url.Parse(form.MovedToURI)
targetAcctURIStr := form.MovedToURI
targetAcctURI, err := url.Parse(form.MovedToURI)
if err != nil {
err := fmt.Errorf("invalid moved_to_uri provided in account Move request: %w", err)
return gtserror.NewErrorBadRequest(err, err.Error())
}
if movedToURI.Scheme != "https" && movedToURI.Scheme != "http" {
if targetAcctURI.Scheme != "https" && targetAcctURI.Scheme != "http" {
err := errors.New("invalid moved_to_uri provided in account Move request: uri scheme must be http or https")
return gtserror.NewErrorBadRequest(err, err.Error())
}
@ -70,83 +74,244 @@ func (p *Processor) MoveSelf(
return gtserror.NewErrorBadRequest(err, err.Error())
}
var (
// Current account from which
// the move is taking place.
account = authed.Account
// Target account to which
// the move is taking place.
targetAccount *gtsmodel.Account
)
switch {
case account.MovedToURI == "":
// No problemo.
case account.MovedToURI == form.MovedToURI:
// Trying to move again to the same
// destination, perhaps to reprocess
// side effects. This is OK.
log.Info(ctx,
"reprocessing Move side effects from %s to %s",
account.URI, form.MovedToURI,
)
default:
// Account already moved, and now
// trying to move somewhere else.
// 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 {
err := fmt.Errorf(
"account %s is already Moved to %s, cannot also Move to %s",
account.URI, account.MovedToURI, form.MovedToURI,
"db error checking if target domain %s blocked: %w",
targetAcctURI.Host, err,
)
return gtserror.NewErrorInternalError(err)
}
if targetDomainBlocked {
err := fmt.Errorf(
"domain of %s is blocked from this instance; "+
"you will not be able to Move to that account",
targetAcctURIStr,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
var (
// Current account from which
// the move is taking place.
originAcct = authed.Account
// Target account to which
// the move is taking place.
targetAcct *gtsmodel.Account
// AP representation of target.
targetAcctable ap.Accountable
)
// Next steps involve checking + setting
// state that might get messed up if a
// client triggers this function twice
// in quick succession, so get a lock on
// this account.
lockKey := originAcct.URI
unlock := p.state.ClientLocks.Lock(lockKey)
defer unlock()
// Ensure we have a valid, up-to-date representation of the target account.
targetAccount, _, err = p.federator.GetAccountByURI(ctx, account.Username, movedToURI)
targetAcct, targetAcctable, err = p.federator.GetAccountByURI(
ctx,
originAcct.Username,
targetAcctURI,
)
if err != nil {
err := fmt.Errorf("error dereferencing moved_to_uri account: %w", err)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
if !targetAccount.SuspendedAt.IsZero() {
if !targetAcct.SuspendedAt.IsZero() {
err := fmt.Errorf(
"target account %s is suspended from this instance; "+
"you will not be able to Move to that account",
targetAccount.URI,
targetAcct.URI,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
if targetAcct.IsRemote() {
// Force refresh Move target account
// to ensure we have up-to-date version.
targetAcct, _, err = p.federator.RefreshAccount(ctx,
originAcct.Username,
targetAcct,
targetAcctable,
dereferencing.Freshest,
)
if err != nil {
err := fmt.Errorf(
"error refreshing target account %s: %w",
targetAcctURIStr, err,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
}
// Target account MUST be aliased to this
// account for this to be a valid Move.
if !slices.Contains(targetAccount.AlsoKnownAsURIs, account.URI) {
if !slices.Contains(targetAcct.AlsoKnownAsURIs, originAcct.URI) {
err := fmt.Errorf(
"target account %s is not aliased to this account via alsoKnownAs; "+
"if you just changed it, wait five minutes and try the Move again",
targetAccount.URI,
"if you just changed it, please wait a few minutes and try the Move again",
targetAcct.URI,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Target account cannot itself have
// already Moved somewhere else.
if targetAccount.MovedToURI != "" {
if targetAcct.MovedToURI != "" {
err := fmt.Errorf(
"target account %s has already Moved somewhere else (%s); "+
"you will not be able to Move to that account",
targetAccount.URI, targetAccount.MovedToURI,
targetAcct.URI, targetAcct.MovedToURI,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Everything seems OK, so process the Move.
// 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.
latestMoveAttempt, err := p.state.DB.GetLatestMoveAttemptInvolvingURIs(
ctx, originAcct.URI, targetAcct.URI,
)
if err != nil {
err := fmt.Errorf(
"error checking latest Move attempt involving origin %s and target %s: %w",
originAcct.URI, targetAcct.URI, err,
)
return gtserror.NewErrorInternalError(err)
}
if !latestMoveAttempt.IsZero() &&
time.Since(latestMoveAttempt) < 5*time.Minute {
err := fmt.Errorf(
"your account or target account have been involved in a Move attempt within "+
"the last 5 minutes, will not process Move; please try again after %s",
latestMoveAttempt.Add(5*time.Minute),
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// 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, originAcct.URI, targetAcct.URI,
)
if err != nil {
err := fmt.Errorf(
"error checking latest Move success involving origin %s and target %s: %w",
originAcct.URI, targetAcct.URI, err,
)
return gtserror.NewErrorInternalError(err)
}
if !latestMoveSuccess.IsZero() &&
time.Since(latestMoveSuccess) < 168*time.Hour {
err := fmt.Errorf(
"your account or target account have been involved in a successful Move within "+
"the last 7 days, will not process Move; please try again after %s",
latestMoveSuccess.Add(168*time.Hour),
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// See if we have a Move stored already
// or if we need to create a new one.
var move *gtsmodel.Move
if originAcct.MoveID != "" {
// Move already stored, ensure it's
// to the target and nothing weird is
// happening with race conditions etc.
move = originAcct.Move
if move == nil {
// This shouldn't happen...
err := fmt.Errorf("nil move for id %s", originAcct.MoveID)
return gtserror.NewErrorInternalError(err)
}
if move.OriginURI != originAcct.URI ||
move.TargetURI != targetAcct.URI {
// This is also weird...
err := errors.New("a Move is already stored for your account but contains invalid fields")
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
if originAcct.MovedToURI != move.TargetURI {
// Huh... I'll be damned.
err := errors.New("stored Move target URI does not equal your moved_to_uri value")
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
} else {
// Move not stored yet, create it.
moveID := id.NewULID()
moveURIStr := uris.GenerateURIForMove(originAcct.Username, moveID)
// We might have selected the target
// using the URL and not the URI.
// Ensure we continue with the URI!
if targetAcctURIStr != targetAcct.URI {
targetAcctURIStr = targetAcct.URI
targetAcctURI, err = url.Parse(targetAcctURIStr)
if err != nil {
return gtserror.NewErrorInternalError(err)
}
}
// Parse origin URI.
originAcctURI, err := url.Parse(originAcct.URI)
if err != nil {
return gtserror.NewErrorInternalError(err)
}
// Store the Move.
move = &gtsmodel.Move{
ID: moveID,
AttemptedAt: time.Now(),
OriginURI: originAcct.URI,
Origin: originAcctURI,
TargetURI: targetAcctURIStr,
Target: targetAcctURI,
URI: moveURIStr,
}
if err := p.state.DB.PutMove(ctx, move); err != nil {
err := fmt.Errorf("db error storing move %s: %w", moveURIStr, err)
return gtserror.NewErrorInternalError(err)
}
// Update account with the new
// Move, and set moved_to_uri.
originAcct.MoveID = move.ID
originAcct.Move = move
originAcct.MovedToURI = targetAcct.URI
originAcct.MovedTo = targetAcct
if err := p.state.DB.UpdateAccount(
ctx,
originAcct,
"move_id",
"moved_to_uri",
); err != nil {
err := fmt.Errorf("db error updating account: %w", err)
return gtserror.NewErrorInternalError(err)
}
}
// Everything seems OK, process Move side effects async.
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ActorPerson,
APActivityType: ap.ActivityMove,
OriginAccount: account,
TargetAccount: targetAccount,
GTSModel: move,
OriginAccount: originAcct,
TargetAccount: targetAcct,
})
return nil

View file

@ -0,0 +1,175 @@
// 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 account_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
type MoveTestSuite struct {
AccountStandardTestSuite
}
func (suite *MoveTestSuite) TestMoveAccountOK() {
ctx := context.Background()
// Copy zork.
requestingAcct := new(gtsmodel.Account)
*requestingAcct = *suite.testAccounts["local_account_1"]
// Copy admin.
targetAcct := new(gtsmodel.Account)
*targetAcct = *suite.testAccounts["admin_account"]
// Update admin to alias back to zork.
targetAcct.AlsoKnownAsURIs = []string{requestingAcct.URI}
if err := suite.state.DB.UpdateAccount(
ctx,
targetAcct,
"also_known_as_uris",
); err != nil {
suite.FailNow(err.Error())
}
// Trigger move from zork to admin.
if err := suite.accountProcessor.MoveSelf(
ctx,
&oauth.Auth{
Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]),
Application: suite.testApplications["local_account_1"],
User: suite.testUsers["local_account_1"],
Account: requestingAcct,
},
&apimodel.AccountMoveRequest{
Password: "password",
MovedToURI: targetAcct.URI,
},
); err != nil {
suite.FailNow(err.Error())
}
// There should be a msg heading back to fromClientAPI.
select {
case msg := <-suite.fromClientAPIChan:
move, ok := msg.GTSModel.(*gtsmodel.Move)
if !ok {
suite.FailNow("", "could not cast %T to *gtsmodel.Move", move)
}
now := time.Now()
suite.WithinDuration(now, move.CreatedAt, 5*time.Second)
suite.WithinDuration(now, move.UpdatedAt, 5*time.Second)
suite.WithinDuration(now, move.AttemptedAt, 5*time.Second)
suite.Zero(move.SucceededAt)
suite.NotZero(move.ID)
suite.Equal(requestingAcct.URI, move.OriginURI)
suite.NotNil(move.Origin)
suite.Equal(targetAcct.URI, move.TargetURI)
suite.NotNil(move.Target)
suite.NotZero(move.URI)
case <-time.After(5 * time.Second):
suite.FailNow("time out waiting for message")
}
// Move should be in the database now.
move, err := suite.state.DB.GetMoveByOriginTarget(
ctx,
requestingAcct.URI,
targetAcct.URI,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.NotNil(move)
// Origin account should have move ID and move to URI set.
suite.Equal(move.ID, requestingAcct.MoveID)
suite.Equal(targetAcct.URI, requestingAcct.MovedToURI)
}
func (suite *MoveTestSuite) TestMoveAccountNotAliased() {
ctx := context.Background()
// Copy zork.
requestingAcct := new(gtsmodel.Account)
*requestingAcct = *suite.testAccounts["local_account_1"]
// Don't copy admin.
targetAcct := suite.testAccounts["admin_account"]
// Trigger move from zork to admin.
//
// Move should fail since admin is
// not aliased back to zork.
err := suite.accountProcessor.MoveSelf(
ctx,
&oauth.Auth{
Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]),
Application: suite.testApplications["local_account_1"],
User: suite.testUsers["local_account_1"],
Account: requestingAcct,
},
&apimodel.AccountMoveRequest{
Password: "password",
MovedToURI: targetAcct.URI,
},
)
suite.EqualError(err, "target account http://localhost:8080/users/admin is not aliased to this account via alsoKnownAs; if you just changed it, please wait a few minutes and try the Move again")
}
func (suite *MoveTestSuite) TestMoveAccountBadPassword() {
ctx := context.Background()
// Copy zork.
requestingAcct := new(gtsmodel.Account)
*requestingAcct = *suite.testAccounts["local_account_1"]
// Don't copy admin.
targetAcct := suite.testAccounts["admin_account"]
// Trigger move from zork to admin.
//
// Move should fail since admin is
// not aliased back to zork.
err := suite.accountProcessor.MoveSelf(
ctx,
&oauth.Auth{
Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]),
Application: suite.testApplications["local_account_1"],
User: suite.testUsers["local_account_1"],
Account: requestingAcct,
},
&apimodel.AccountMoveRequest{
Password: "boobies",
MovedToURI: targetAcct.URI,
},
)
suite.EqualError(err, "invalid password provided in account Move request")
}
func TestMoveTestSuite(t *testing.T) {
suite.Run(t, new(MoveTestSuite))
}

View file

@ -23,6 +23,7 @@ import (
"github.com/superseriousbusiness/activity/pub"
"github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -954,3 +955,68 @@ func (f *federate) Flag(ctx context.Context, report *gtsmodel.Report) error {
return nil
}
func (f *federate) MoveAccount(ctx context.Context, account *gtsmodel.Account) error {
// Do nothing if it's not our
// account that's been moved.
if !account.IsLocal() {
return nil
}
// Parse relevant URI(s).
outboxIRI, err := parseURI(account.OutboxURI)
if err != nil {
return err
}
// Actor doing the Move.
actorIRI := account.Move.Origin
// Destination Actor of the Move.
targetIRI := account.Move.Target
followersIRI, err := parseURI(account.FollowersURI)
if err != nil {
return err
}
publicIRI, err := parseURI(pub.PublicActivityPubIRI)
if err != nil {
return err
}
// Create a new move.
move := streams.NewActivityStreamsMove()
// Set the Move ID.
if err := ap.SetJSONLDIdStr(move, account.Move.URI); err != nil {
return err
}
// Set the Actor for the Move.
ap.AppendActorIRIs(move, actorIRI)
// Set the account's IRI as the 'object' property.
ap.AppendObjectIRIs(move, actorIRI)
// Set the target's IRI as the 'target' property.
ap.AppendTargetIRIs(move, targetIRI)
// Address the move To followers.
ap.AppendTo(move, followersIRI)
// Address the move CC public.
ap.AppendCc(move, publicIRI)
// Send the Move via the Actor's outbox.
if _, err := f.FederatingActor().Send(
ctx, outboxIRI, move,
); err != nil {
return gtserror.Newf(
"error sending activity %T via outbox %s: %w",
move, outboxIRI, err,
)
}
return nil
}

View file

@ -39,12 +39,12 @@ import (
// specifically for messages originating
// from the client/REST API.
type clientAPI struct {
state *state.State
converter *typeutils.Converter
surface *surface
federate *federate
wipeStatus wipeStatus
account *account.Processor
state *state.State
converter *typeutils.Converter
surface *surface
federate *federate
account *account.Processor
utilF *utilF
}
func (p *Processor) EnqueueClientAPI(cctx context.Context, msgs ...messages.FromClientAPI) {
@ -194,6 +194,15 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From
case ap.ObjectProfile:
return p.clientAPI.ReportAccount(ctx, cMsg)
}
// MOVE SOMETHING
case ap.ActivityMove:
switch cMsg.APObjectType { //nolint:gocritic
// MOVE PROFILE/ACCOUNT
case ap.ObjectProfile, ap.ActorPerson:
return p.clientAPI.MoveAccount(ctx, cMsg)
}
}
return gtserror.Newf("unhandled: %s %s", cMsg.APActivityType, cMsg.APObjectType)
@ -576,7 +585,7 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg messages.FromClientAP
return gtserror.Newf("db error populating status: %w", err)
}
if err := p.wipeStatus(ctx, status, deleteAttachments); err != nil {
if err := p.utilF.wipeStatus(ctx, status, deleteAttachments); err != nil {
log.Errorf(ctx, "error wiping status: %v", err)
}
@ -641,3 +650,33 @@ func (p *clientAPI) ReportAccount(ctx context.Context, cMsg messages.FromClientA
return nil
}
func (p *clientAPI) MoveAccount(ctx context.Context, cMsg messages.FromClientAPI) error {
// Redirect each local follower of
// OriginAccount to follow move target.
p.utilF.redirectFollowers(ctx, cMsg.OriginAccount, cMsg.TargetAccount)
// At this point, we know OriginAccount has the
// Move set on it. Just make sure it's populated.
if err := p.state.DB.PopulateMove(ctx, cMsg.OriginAccount.Move); err != nil {
return gtserror.Newf("error populating Move: %w", err)
}
// Now send the Move message out to
// OriginAccount's (remote) followers.
if err := p.federate.MoveAccount(ctx, cMsg.OriginAccount); err != nil {
return gtserror.Newf("error federating account move: %w", err)
}
// Mark the move attempt as successful.
cMsg.OriginAccount.Move.SucceededAt = cMsg.OriginAccount.Move.AttemptedAt
if err := p.state.DB.UpdateMove(
ctx,
cMsg.OriginAccount.Move,
"succeeded_at",
); err != nil {
return gtserror.Newf("error marking move as successful: %w", err)
}
return nil
}

View file

@ -39,11 +39,11 @@ import (
// specifically for messages originating
// from the federation/ActivityPub API.
type fediAPI struct {
state *state.State
surface *surface
federate *federate
wipeStatus wipeStatus
account *account.Processor
state *state.State
surface *surface
federate *federate
account *account.Processor
utilF *utilF
}
func (p *Processor) EnqueueFediAPI(cctx context.Context, msgs ...messages.FromFediAPI) {
@ -563,7 +563,7 @@ func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg messages.FromFediAPI) e
return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel)
}
if err := p.wipeStatus(ctx, status, deleteAttachments); err != nil {
if err := p.utilF.wipeStatus(ctx, status, deleteAttachments); err != nil {
log.Errorf(ctx, "error wiping status: %v", err)
}

View file

@ -22,7 +22,6 @@ import (
"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"
@ -380,7 +379,7 @@ func (p *fediAPI) MoveAccount(ctx context.Context, fMsg messages.FromFediAPI) er
// Transfer originAcct's followers
// on this instance to targetAcct.
redirectOK := p.RedirectAccountFollowers(
redirectOK := p.utilF.redirectFollowers(
ctx,
originAcct,
targetAcct,
@ -422,98 +421,6 @@ func (p *fediAPI) MoveAccount(ctx context.Context, fMsg messages.FromFediAPI) er
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.
//

View file

@ -0,0 +1,240 @@
// 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"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
"github.com/superseriousbusiness/gotosocial/internal/state"
)
// utilF wraps util functions used by both
// the fromClientAPI and fromFediAPI functions.
type utilF struct {
state *state.State
media *media.Processor
account *account.Processor
surface *surface
}
// wipeStatus encapsulates common logic
// used to totally delete a status + all
// its attachments, notifications, boosts,
// and timeline entries.
func (u *utilF) wipeStatus(
ctx context.Context,
statusToDelete *gtsmodel.Status,
deleteAttachments bool,
) error {
var errs gtserror.MultiError
// Either delete all attachments for this status,
// or simply unattach + clean them separately later.
//
// Reason to unattach rather than delete is that
// the poster might want to reattach them to another
// status immediately (in case of delete + redraft)
if deleteAttachments {
// todo:u.state.DB.DeleteAttachmentsForStatus
for _, id := range statusToDelete.AttachmentIDs {
if err := u.media.Delete(ctx, id); err != nil {
errs.Appendf("error deleting media: %w", err)
}
}
} else {
// todo:u.state.DB.UnattachAttachmentsForStatus
for _, id := range statusToDelete.AttachmentIDs {
if _, err := u.media.Unattach(ctx, statusToDelete.Account, id); err != nil {
errs.Appendf("error unattaching media: %w", err)
}
}
}
// delete all mention entries generated by this status
// todo:u.state.DB.DeleteMentionsForStatus
for _, id := range statusToDelete.MentionIDs {
if err := u.state.DB.DeleteMentionByID(ctx, id); err != nil {
errs.Appendf("error deleting status mention: %w", err)
}
}
// delete all notification entries generated by this status
if err := u.state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status notifications: %w", err)
}
// delete all bookmarks that point to this status
if err := u.state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status bookmarks: %w", err)
}
// delete all faves of this status
if err := u.state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status faves: %w", err)
}
if pollID := statusToDelete.PollID; pollID != "" {
// Delete this poll by ID from the database.
if err := u.state.DB.DeletePollByID(ctx, pollID); err != nil {
errs.Appendf("error deleting status poll: %w", err)
}
// Delete any poll votes pointing to this poll ID.
if err := u.state.DB.DeletePollVotes(ctx, pollID); err != nil {
errs.Appendf("error deleting status poll votes: %w", err)
}
// Cancel any scheduled expiry task for poll.
_ = u.state.Workers.Scheduler.Cancel(pollID)
}
// delete all boosts for this status + remove them from timelines
boosts, err := u.state.DB.GetStatusBoosts(
// we MUST set a barebones context here,
// as depending on where it came from the
// original BoostOf may already be gone.
gtscontext.SetBarebones(ctx),
statusToDelete.ID)
if err != nil {
errs.Appendf("error fetching status boosts: %w", err)
}
for _, boost := range boosts {
if err := u.surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil {
errs.Appendf("error deleting boost from timelines: %w", err)
}
if err := u.state.DB.DeleteStatusByID(ctx, boost.ID); err != nil {
errs.Appendf("error deleting boost: %w", err)
}
}
// delete this status from any and all timelines
if err := u.surface.deleteStatusFromTimelines(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status from timelines: %w", err)
}
// finally, delete the status itself
if err := u.state.DB.DeleteStatusByID(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status: %w", err)
}
return errs.Combine()
}
// redirectFollowers redirects all local
// followers of originAcct to targetAcct.
//
// Both accounts must be fully dereferenced
// already, and the Move must be valid.
//
// Return bool will be true if all goes OK.
func (u *utilF) redirectFollowers(
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 := u.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 = u.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.
//
// Also, ensure new follow wouldn't be a
// self follow, since that will error.
if follow.AccountID != targetAcct.ID {
if _, err := u.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 := u.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
}

View file

@ -1,135 +0,0 @@
// 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"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
"github.com/superseriousbusiness/gotosocial/internal/state"
)
// wipeStatus encapsulates common logic used to totally delete a status
// + all its attachments, notifications, boosts, and timeline entries.
type wipeStatus func(context.Context, *gtsmodel.Status, bool) error
// wipeStatusF returns a wipeStatus util function.
func wipeStatusF(state *state.State, media *media.Processor, surface *surface) wipeStatus {
return func(
ctx context.Context,
statusToDelete *gtsmodel.Status,
deleteAttachments bool,
) error {
var errs gtserror.MultiError
// Either delete all attachments for this status,
// or simply unattach + clean them separately later.
//
// Reason to unattach rather than delete is that
// the poster might want to reattach them to another
// status immediately (in case of delete + redraft)
if deleteAttachments {
// todo:state.DB.DeleteAttachmentsForStatus
for _, id := range statusToDelete.AttachmentIDs {
if err := media.Delete(ctx, id); err != nil {
errs.Appendf("error deleting media: %w", err)
}
}
} else {
// todo:state.DB.UnattachAttachmentsForStatus
for _, id := range statusToDelete.AttachmentIDs {
if _, err := media.Unattach(ctx, statusToDelete.Account, id); err != nil {
errs.Appendf("error unattaching media: %w", err)
}
}
}
// delete all mention entries generated by this status
// todo:state.DB.DeleteMentionsForStatus
for _, id := range statusToDelete.MentionIDs {
if err := state.DB.DeleteMentionByID(ctx, id); err != nil {
errs.Appendf("error deleting status mention: %w", err)
}
}
// delete all notification entries generated by this status
if err := state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status notifications: %w", err)
}
// delete all bookmarks that point to this status
if err := state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status bookmarks: %w", err)
}
// delete all faves of this status
if err := state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status faves: %w", err)
}
if pollID := statusToDelete.PollID; pollID != "" {
// Delete this poll by ID from the database.
if err := state.DB.DeletePollByID(ctx, pollID); err != nil {
errs.Appendf("error deleting status poll: %w", err)
}
// Delete any poll votes pointing to this poll ID.
if err := state.DB.DeletePollVotes(ctx, pollID); err != nil {
errs.Appendf("error deleting status poll votes: %w", err)
}
// Cancel any scheduled expiry task for poll.
_ = state.Workers.Scheduler.Cancel(pollID)
}
// delete all boosts for this status + remove them from timelines
boosts, err := state.DB.GetStatusBoosts(
// we MUST set a barebones context here,
// as depending on where it came from the
// original BoostOf may already be gone.
gtscontext.SetBarebones(ctx),
statusToDelete.ID)
if err != nil {
errs.Appendf("error fetching status boosts: %w", err)
}
for _, boost := range boosts {
if err := surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil {
errs.Appendf("error deleting boost from timelines: %w", err)
}
if err := state.DB.DeleteStatusByID(ctx, boost.ID); err != nil {
errs.Appendf("error deleting boost: %w", err)
}
}
// delete this status from any and all timelines
if err := surface.deleteStatusFromTimelines(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status from timelines: %w", err)
}
// finally, delete the status itself
if err := state.DB.DeleteStatusByID(ctx, statusToDelete.ID); err != nil {
errs.Appendf("error deleting status: %w", err)
}
return errs.Combine()
}
}

View file

@ -63,30 +63,30 @@ func New(
converter: converter,
}
// Init shared logic wipe
// status util func.
wipeStatus := wipeStatusF(
state,
media,
surface,
)
// Init shared util funcs.
utilF := &utilF{
state: state,
media: media,
account: account,
surface: surface,
}
return Processor{
workers: &state.Workers,
clientAPI: &clientAPI{
state: state,
converter: converter,
surface: surface,
federate: federate,
wipeStatus: wipeStatus,
account: account,
state: state,
converter: converter,
surface: surface,
federate: federate,
account: account,
utilF: utilF,
},
fediAPI: &fediAPI{
state: state,
surface: surface,
federate: federate,
wipeStatus: wipeStatus,
account: account,
state: state,
surface: surface,
federate: federate,
account: account,
utilF: utilF,
},
}
}

View file

@ -50,6 +50,12 @@ type State struct {
// functions, and by the go-fed/activity library.
FedLocks mutexes.MutexMap
// ClientLocks provides access to this state's
// mutex map of per URI client locks.
//
// Used during account migration actions.
ClientLocks mutexes.MutexMap
// Storage provides access to the storage driver.
Storage *storage.Driver

View file

@ -40,6 +40,7 @@ const (
FollowPath = "follow" // FollowPath used to generate the URI for an individual follow or follow request
UpdatePath = "updates" // UpdatePath is used to generate the URI for an account update
BlocksPath = "blocks" // BlocksPath is used to generate the URI for a block
MovesPath = "moves" // MovesPath is used to generate the URI for a move
ReportsPath = "reports" // ReportsPath is used to generate the URI for a report/flag
ConfirmEmailPath = "confirm_email" // ConfirmEmailPath is used to generate the URI for an email confirmation link
FileserverPath = "fileserver" // FileserverPath is a path component for serving attachments + media
@ -108,6 +109,14 @@ func GenerateURIForBlock(username string, thisBlockID string) string {
return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, BlocksPath, thisBlockID)
}
// GenerateURIForMove returns the AP URI for a new Move activity -- something like:
// https://example.org/users/whatever_user/moves/01F7XTH1QGBAPMGF49WJZ91XGC
func GenerateURIForMove(username string, thisMoveID string) string {
protocol := config.GetProtocol()
host := config.GetHost()
return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, MovesPath, thisMoveID)
}
// GenerateURIForReport returns the API URI for a new Flag activity -- something like:
// https://example.org/reports/01GP3AWY4CRDVRNZKW0TEAMB5R
//