[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
parent 13b9fd5f92
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

@ -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).

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
//

View File

@ -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 */

View File

@ -17,10 +17,31 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
{{- define "profileMovedTo" -}}
{{- with .account.Moved }}
<div class="moved-to">
<b>
This account has permanently moved to
<a
href="{{ .URL }}"
class="nounderline"
rel="nofollow noreferrer noopener"
target="_blank"
>
@{{ .Username }}
</a>
</b>
</div>
{{- end }}
{{- end -}}
{{- with . }}
<main class="profile">
<h2 class="sr-only">Profile for {{ .account.Username -}}</h2>
<section class="profile-header" role="region" aria-label="Basic info">
{{- if .account.Moved }}
{{- include "profileMovedTo" . | indent 2 }}
{{- end }}
<div class="header-image-wrapper">
<img
src="{{- .account.Header -}}"