From ab2d063fcb04f241a3147c843a021491f5fc0a55 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 13 Mar 2024 13:53:29 +0100 Subject: [PATCH] [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 * 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 --- docs/user_guide/settings.md | 43 +++ internal/ap/properties.go | 8 +- internal/api/client/accounts/follow.go | 5 + internal/api/client/accounts/lookup.go | 7 + internal/api/client/accounts/note.go | 5 + internal/api/client/accounts/search.go | 7 + internal/api/client/accounts/statuses.go | 7 + internal/api/client/admin/accountaction.go | 5 + internal/api/client/admin/domainkeysexpire.go | 5 + internal/api/client/admin/domainpermission.go | 10 + internal/api/client/admin/emailtest.go | 5 + internal/api/client/admin/emojicreate.go | 5 + internal/api/client/admin/emojidelete.go | 5 + internal/api/client/admin/emojiupdate.go | 5 + internal/api/client/admin/headerfilter.go | 10 + internal/api/client/admin/mediacleanup.go | 5 + internal/api/client/admin/mediarefetch.go | 5 + internal/api/client/admin/reportresolve.go | 5 + internal/api/client/admin/rulecreate.go | 5 + internal/api/client/admin/ruledelete.go | 5 + internal/api/client/admin/ruleupdate.go | 5 + internal/api/client/filters/v1/filterpost.go | 5 + internal/api/client/filters/v1/filterput.go | 5 + .../api/client/followrequests/authorize.go | 5 + internal/api/client/instance/instancepatch.go | 5 + internal/api/client/lists/listaccountsadd.go | 5 + internal/api/client/lists/listcreate.go | 5 + internal/api/client/lists/listupdate.go | 5 + internal/api/client/media/mediacreate.go | 5 + internal/api/client/media/mediaupdate.go | 5 + internal/api/client/polls/polls_vote.go | 5 + internal/api/client/reports/reportcreate.go | 5 + internal/api/client/search/searchget.go | 12 + .../api/client/statuses/statusbookmark.go | 5 + internal/api/client/statuses/statusboost.go | 5 + internal/api/client/statuses/statuscreate.go | 5 + internal/api/client/statuses/statusfave.go | 5 + internal/api/client/statuses/statusmute.go | 5 + internal/api/client/statuses/statuspin.go | 5 + internal/api/client/streaming/stream.go | 7 + internal/api/client/timelines/home.go | 7 + internal/api/client/timelines/list.go | 7 + internal/api/client/timelines/public.go | 7 + internal/api/client/timelines/tag.go | 7 + internal/api/util/errorhandling.go | 18 ++ internal/db/bundb/move.go | 16 +- internal/db/move.go | 3 + internal/processing/account/move.go | 249 +++++++++++++++--- internal/processing/account/move_test.go | 175 ++++++++++++ internal/processing/workers/federate.go | 66 +++++ internal/processing/workers/fromclientapi.go | 53 +++- internal/processing/workers/fromfediapi.go | 12 +- .../processing/workers/fromfediapi_move.go | 95 +------ internal/processing/workers/util.go | 240 +++++++++++++++++ internal/processing/workers/wipestatus.go | 135 ---------- internal/processing/workers/workers.go | 36 +-- internal/state/state.go | 6 + internal/uris/uri.go | 9 + web/source/css/profile.css | 5 + web/template/profile.tmpl | 21 ++ 60 files changed, 1124 insertions(+), 309 deletions(-) create mode 100644 internal/processing/account/move_test.go create mode 100644 internal/processing/workers/util.go delete mode 100644 internal/processing/workers/wipestatus.go diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md index 89bc0a8bc..d82b2c084 100644 --- a/docs/user_guide/settings.md +++ b/docs/user_guide/settings.md @@ -137,6 +137,49 @@ You can use the Password Change section of the User Settings Panel to set a new For more information on the way GoToSocial manages passwords, please see the [Password management document](./password_management.md). +## Migration + +In the migration section you can manage settings related to aliasing and/or migrating your account to another account. + +!!! tip + Depending on the software that a target account is hosted on, target account URIs for both aliasing and moves should look something like `https://mastodon.example.org/users/account_you_are_moving_to`. If you are unsure what format to use, check with the admin of the instance you are moving or aliasing to. + +### Alias Account + +You can use this section to create an alias from your GoToSocial account to other accounts elsewhere, indicating that you are also known as those accounts. + +**Not implemented yet**: Alias information for accounts you enter here will be shown on the web view of your profile, but only if the target accounts are also aliased back to your account first. This is to prevent accounts from claiming to be aliased to other accounts that they don't actually control. + +### Move Account + +Using the move account settings, you can trigger the migration of your current account to the given target account URI. + +In order for the move to be successful, the target account (the account you are moving to) must be aliased back to your current account (the account you are moving from). The target account must also be reachable from your current account, ie., not blocked by you, not suspended by your current instance, and not on a domain that is blocked by your current instance. The target account does not have to be on a GoToSocial instance. + +GoToSocial uses an account move cooldown of 7 days. If either your current account or the target account have recently been involved in a move, you will not be able to trigger a move to the target account until seven days have passed. + +Moving your account will send a message out from your current account, to your current followers, indicating that they should follow the target account instead. Depending on the server software used by your followers, they may then automatically send a follow (request) to the target account, and unfollow your current account. + +Currently, **only your followers will be carried over to the new account**. Other things like your following list, statuses, media, bookmarks, faves, blocks, etc, will not be carried over. + +Once your account has moved, the web view of your current (now old) account will show a notice that you have moved, and to where. + +Your old statuses and media will still be visible on the web view of the account you've moved from, unless you delete them manually. If you prefer, you can ask the admin of the instance you've moved from to suspend/delete your account after the move has gone through. + +If necessary, you can retry an account move using the same target account URI. This will send the move message out again. + +!!! danger "Moving your account is an irreversible, permanent action!" + + From the moment you trigger an account move, you will have only basic read- and delete-level permissions on the account you've moved from. + + You will still be able to log in to your old account and see your own posts, faves, bookmarks, blocks, and lists. + + You will also be able to edit your profile, delete and/or unpin your own posts, and unboost, unfave, and unbookmark posts. + + However, you will not be able to take any action that involves creating something, such as writing, boosting, bookmarking, or faving a post, following someone, uploading media, creating a list, etc. + + Additionally, you will not be able to view any timelines (home, tag, public, list), or use the search functionality. + ## Admins If your account has been promoted to admin, this interface will also show sections related to admin actions, see [Admin Settings](../admin/settings.md). diff --git a/internal/ap/properties.go b/internal/ap/properties.go index b77d20a02..1bd8c303e 100644 --- a/internal/ap/properties.go +++ b/internal/ap/properties.go @@ -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'. diff --git a/internal/api/client/accounts/follow.go b/internal/api/client/accounts/follow.go index 2e6e79964..8a6e99744 100644 --- a/internal/api/client/accounts/follow.go +++ b/internal/api/client/accounts/follow.go @@ -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 diff --git a/internal/api/client/accounts/lookup.go b/internal/api/client/accounts/lookup.go index f6bd97657..d2a8e76be 100644 --- a/internal/api/client/accounts/lookup.go +++ b/internal/api/client/accounts/lookup.go @@ -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 diff --git a/internal/api/client/accounts/note.go b/internal/api/client/accounts/note.go index 29ea01c9a..bcfd232ae 100644 --- a/internal/api/client/accounts/note.go +++ b/internal/api/client/accounts/note.go @@ -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 diff --git a/internal/api/client/accounts/search.go b/internal/api/client/accounts/search.go index 183fc1347..13c135601 100644 --- a/internal/api/client/accounts/search.go +++ b/internal/api/client/accounts/search.go @@ -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 diff --git a/internal/api/client/accounts/statuses.go b/internal/api/client/accounts/statuses.go index cd93cb74e..7dd4cbe37 100644 --- a/internal/api/client/accounts/statuses.go +++ b/internal/api/client/accounts/statuses.go @@ -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 != "" { diff --git a/internal/api/client/admin/accountaction.go b/internal/api/client/admin/accountaction.go index 89bcf644e..7d74e8530 100644 --- a/internal/api/client/admin/accountaction.go +++ b/internal/api/client/admin/accountaction.go @@ -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) diff --git a/internal/api/client/admin/domainkeysexpire.go b/internal/api/client/admin/domainkeysexpire.go index 4990d879f..0926519f5 100644 --- a/internal/api/client/admin/domainkeysexpire.go +++ b/internal/api/client/admin/domainkeysexpire.go @@ -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 diff --git a/internal/api/client/admin/domainpermission.go b/internal/api/client/admin/domainpermission.go index 05319086f..90c0eb4c0 100644 --- a/internal/api/client/admin/domainpermission.go +++ b/internal/api/client/admin/domainpermission.go @@ -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 diff --git a/internal/api/client/admin/emailtest.go b/internal/api/client/admin/emailtest.go index 8f274e226..42b405ce7 100644 --- a/internal/api/client/admin/emailtest.go +++ b/internal/api/client/admin/emailtest.go @@ -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 diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go index 9086b27e0..75661f1c3 100644 --- a/internal/api/client/admin/emojicreate.go +++ b/internal/api/client/admin/emojicreate.go @@ -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 diff --git a/internal/api/client/admin/emojidelete.go b/internal/api/client/admin/emojidelete.go index b5cf72daf..47248a1b9 100644 --- a/internal/api/client/admin/emojidelete.go +++ b/internal/api/client/admin/emojidelete.go @@ -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 diff --git a/internal/api/client/admin/emojiupdate.go b/internal/api/client/admin/emojiupdate.go index ffde2d597..1d41dd545 100644 --- a/internal/api/client/admin/emojiupdate.go +++ b/internal/api/client/admin/emojiupdate.go @@ -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 diff --git a/internal/api/client/admin/headerfilter.go b/internal/api/client/admin/headerfilter.go index 7b1a85c86..01bcaca16 100644 --- a/internal/api/client/admin/headerfilter.go +++ b/internal/api/client/admin/headerfilter.go @@ -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) diff --git a/internal/api/client/admin/mediacleanup.go b/internal/api/client/admin/mediacleanup.go index 7a0ee4bd6..661a8ff15 100644 --- a/internal/api/client/admin/mediacleanup.go +++ b/internal/api/client/admin/mediacleanup.go @@ -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) diff --git a/internal/api/client/admin/mediarefetch.go b/internal/api/client/admin/mediarefetch.go index 1c0da6dea..b2b0516ba 100644 --- a/internal/api/client/admin/mediarefetch.go +++ b/internal/api/client/admin/mediarefetch.go @@ -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 diff --git a/internal/api/client/admin/reportresolve.go b/internal/api/client/admin/reportresolve.go index 2ad979b0b..51c268a2d 100644 --- a/internal/api/client/admin/reportresolve.go +++ b/internal/api/client/admin/reportresolve.go @@ -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 diff --git a/internal/api/client/admin/rulecreate.go b/internal/api/client/admin/rulecreate.go index 155c69db0..8728940c5 100644 --- a/internal/api/client/admin/rulecreate.go +++ b/internal/api/client/admin/rulecreate.go @@ -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 diff --git a/internal/api/client/admin/ruledelete.go b/internal/api/client/admin/ruledelete.go index 834149978..ead219e34 100644 --- a/internal/api/client/admin/ruledelete.go +++ b/internal/api/client/admin/ruledelete.go @@ -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 diff --git a/internal/api/client/admin/ruleupdate.go b/internal/api/client/admin/ruleupdate.go index 2ba31485e..bf838f7ae 100644 --- a/internal/api/client/admin/ruleupdate.go +++ b/internal/api/client/admin/ruleupdate.go @@ -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 diff --git a/internal/api/client/filters/v1/filterpost.go b/internal/api/client/filters/v1/filterpost.go index 4c71eeddb..2d19f69cf 100644 --- a/internal/api/client/filters/v1/filterpost.go +++ b/internal/api/client/filters/v1/filterpost.go @@ -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 diff --git a/internal/api/client/filters/v1/filterput.go b/internal/api/client/filters/v1/filterput.go index b7164936b..bb9fa809f 100644 --- a/internal/api/client/filters/v1/filterput.go +++ b/internal/api/client/filters/v1/filterput.go @@ -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 diff --git a/internal/api/client/followrequests/authorize.go b/internal/api/client/followrequests/authorize.go index 406b54179..6a6f0dc81 100644 --- a/internal/api/client/followrequests/authorize.go +++ b/internal/api/client/followrequests/authorize.go @@ -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 diff --git a/internal/api/client/instance/instancepatch.go b/internal/api/client/instance/instancepatch.go index 58549a866..afddc5a50 100644 --- a/internal/api/client/instance/instancepatch.go +++ b/internal/api/client/instance/instancepatch.go @@ -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) diff --git a/internal/api/client/lists/listaccountsadd.go b/internal/api/client/lists/listaccountsadd.go index 6fb5eab3c..e20056502 100644 --- a/internal/api/client/lists/listaccountsadd.go +++ b/internal/api/client/lists/listaccountsadd.go @@ -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 diff --git a/internal/api/client/lists/listcreate.go b/internal/api/client/lists/listcreate.go index 4228e5fff..9046ce34d 100644 --- a/internal/api/client/lists/listcreate.go +++ b/internal/api/client/lists/listcreate.go @@ -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 diff --git a/internal/api/client/lists/listupdate.go b/internal/api/client/lists/listupdate.go index 966de4098..312aa9ec7 100644 --- a/internal/api/client/lists/listupdate.go +++ b/internal/api/client/lists/listupdate.go @@ -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 diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go index daa2e5bb7..eef945d21 100644 --- a/internal/api/client/media/mediacreate.go +++ b/internal/api/client/media/mediacreate.go @@ -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 diff --git a/internal/api/client/media/mediaupdate.go b/internal/api/client/media/mediaupdate.go index 8378502e8..0a9ce4eb8 100644 --- a/internal/api/client/media/mediaupdate.go +++ b/internal/api/client/media/mediaupdate.go @@ -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 diff --git a/internal/api/client/polls/polls_vote.go b/internal/api/client/polls/polls_vote.go index 0ab5ac20c..c5344326f 100644 --- a/internal/api/client/polls/polls_vote.go +++ b/internal/api/client/polls/polls_vote.go @@ -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) diff --git a/internal/api/client/reports/reportcreate.go b/internal/api/client/reports/reportcreate.go index a34b8d52e..a303cf20a 100644 --- a/internal/api/client/reports/reportcreate.go +++ b/internal/api/client/reports/reportcreate.go @@ -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 diff --git a/internal/api/client/search/searchget.go b/internal/api/client/search/searchget.go index 909c14f24..76cb929bf 100644 --- a/internal/api/client/search/searchget.go +++ b/internal/api/client/search/searchget.go @@ -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 diff --git a/internal/api/client/statuses/statusbookmark.go b/internal/api/client/statuses/statusbookmark.go index cd1dd1c72..9dbc0f56e 100644 --- a/internal/api/client/statuses/statusbookmark.go +++ b/internal/api/client/statuses/statusbookmark.go @@ -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 diff --git a/internal/api/client/statuses/statusboost.go b/internal/api/client/statuses/statusboost.go index 1a3ca0eb2..035ee8747 100644 --- a/internal/api/client/statuses/statusboost.go +++ b/internal/api/client/statuses/statusboost.go @@ -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 diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go index efbe79223..5a9654195 100644 --- a/internal/api/client/statuses/statuscreate.go +++ b/internal/api/client/statuses/statuscreate.go @@ -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 diff --git a/internal/api/client/statuses/statusfave.go b/internal/api/client/statuses/statusfave.go index 947760af3..41d45c6b8 100644 --- a/internal/api/client/statuses/statusfave.go +++ b/internal/api/client/statuses/statusfave.go @@ -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 diff --git a/internal/api/client/statuses/statusmute.go b/internal/api/client/statuses/statusmute.go index 95ada8939..58d14a8bf 100644 --- a/internal/api/client/statuses/statusmute.go +++ b/internal/api/client/statuses/statusmute.go @@ -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 diff --git a/internal/api/client/statuses/statuspin.go b/internal/api/client/statuses/statuspin.go index 4c58eb1a5..e5879f715 100644 --- a/internal/api/client/statuses/statuspin.go +++ b/internal/api/client/statuses/statuspin.go @@ -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 diff --git a/internal/api/client/streaming/stream.go b/internal/api/client/streaming/stream.go index 8df4e9e76..e39c780b6 100644 --- a/internal/api/client/streaming/stream.go +++ b/internal/api/client/streaming/stream.go @@ -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) diff --git a/internal/api/client/timelines/home.go b/internal/api/client/timelines/home.go index a7e7717da..55928dd3a 100644 --- a/internal/api/client/timelines/home.go +++ b/internal/api/client/timelines/home.go @@ -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 diff --git a/internal/api/client/timelines/list.go b/internal/api/client/timelines/list.go index dc5f21424..25695bf0e 100644 --- a/internal/api/client/timelines/list.go +++ b/internal/api/client/timelines/list.go @@ -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 diff --git a/internal/api/client/timelines/public.go b/internal/api/client/timelines/public.go index 8eb34edc7..c4ffbc6c8 100644 --- a/internal/api/client/timelines/public.go +++ b/internal/api/client/timelines/public.go @@ -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 diff --git a/internal/api/client/timelines/tag.go b/internal/api/client/timelines/tag.go index e66955a73..258184355 100644 --- a/internal/api/client/timelines/tag.go +++ b/internal/api/client/timelines/tag.go @@ -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 diff --git a/internal/api/util/errorhandling.go b/internal/api/util/errorhandling.go index 848beff5b..d2b9171c8 100644 --- a/internal/api/util/errorhandling.go +++ b/internal/api/util/errorhandling.go @@ -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, + }) +} diff --git a/internal/db/bundb/move.go b/internal/db/bundb/move.go index a66b9dea5..220874630 100644 --- a/internal/db/bundb/move.go +++ b/internal/db/bundb/move.go @@ -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 { diff --git a/internal/db/move.go b/internal/db/move.go index 5bce781a3..42357627b 100644 --- a/internal/db/move.go +++ b/internal/db/move.go @@ -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. diff --git a/internal/processing/account/move.go b/internal/processing/account/move.go index cd5c577c6..ca8dd4dea 100644 --- a/internal/processing/account/move.go +++ b/internal/processing/account/move.go @@ -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 = >smodel.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 diff --git a/internal/processing/account/move_test.go b/internal/processing/account/move_test.go new file mode 100644 index 000000000..dfa0ea4e4 --- /dev/null +++ b/internal/processing/account/move_test.go @@ -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 . + +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)) +} diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go index aacb8dcc8..9fdb8f662 100644 --- a/internal/processing/workers/federate.go +++ b/internal/processing/workers/federate.go @@ -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 +} diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index 05b9acc1f..c7e78fee2 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -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 +} diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 62cb58c83..2fc3b4b26 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -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) } diff --git a/internal/processing/workers/fromfediapi_move.go b/internal/processing/workers/fromfediapi_move.go index 2223a21f5..0188a5d14 100644 --- a/internal/processing/workers/fromfediapi_move.go +++ b/internal/processing/workers/fromfediapi_move.go @@ -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. // diff --git a/internal/processing/workers/util.go b/internal/processing/workers/util.go new file mode 100644 index 000000000..a38ecd336 --- /dev/null +++ b/internal/processing/workers/util.go @@ -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 . + +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 +} diff --git a/internal/processing/workers/wipestatus.go b/internal/processing/workers/wipestatus.go deleted file mode 100644 index 90a037928..000000000 --- a/internal/processing/workers/wipestatus.go +++ /dev/null @@ -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 . - -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() - } -} diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go index c0612de27..8488e501c 100644 --- a/internal/processing/workers/workers.go +++ b/internal/processing/workers/workers.go @@ -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, }, } } diff --git a/internal/state/state.go b/internal/state/state.go index 5dfe83271..6120515b9 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -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 diff --git a/internal/uris/uri.go b/internal/uris/uri.go index d12e24fea..335461d84 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -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 // diff --git a/web/source/css/profile.css b/web/source/css/profile.css index 97dbdfe88..a966d768a 100644 --- a/web/source/css/profile.css +++ b/web/source/css/profile.css @@ -38,6 +38,11 @@ overflow: hidden; margin-bottom: 1rem; + .moved-to { + padding: 1rem; + text-align: center; + } + .header-image-wrapper { position: relative; padding-top: 33.33%; /* aspect-ratio 1/3 */ diff --git a/web/template/profile.tmpl b/web/template/profile.tmpl index 0b079db10..b6ef056f0 100644 --- a/web/template/profile.tmpl +++ b/web/template/profile.tmpl @@ -17,10 +17,31 @@ // along with this program. If not, see . */ -}} +{{- define "profileMovedTo" -}} +{{- with .account.Moved }} +
+ + ℹ️ This account has permanently moved to + + @{{ .Username }} + + +
+{{- end }} +{{- end -}} + {{- with . }}

Profile for {{ .account.Username -}}

+ {{- if .account.Moved }} + {{- include "profileMovedTo" . | indent 2 }} + {{- end }}