[feature] Account alias / move API + db models (#2518)

* [feature] Account alias / move API + db models

* go fmt

* fix little cherry-pick issues

* update error checking, formatting

* add and use new util functions to simplify alias logic
This commit is contained in:
tobi 2024-01-16 17:22:44 +01:00 committed by GitHub
parent ebf550b7c1
commit c36f9ac37b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1243 additions and 39 deletions

View file

@ -111,6 +111,16 @@ definitions:
Source: Source:
description: Returned as an additional entity when verifying and updated credentials, as an attribute of Account. description: Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
properties: properties:
also_known_as_uris:
description: |-
This account is aliased to / also known as accounts at the
given ActivityPub URIs. To set this, use `/api/v1/accounts/alias`.
Omitted from json if empty / not set.
items:
type: string
type: array
x-go-name: AlsoKnownAsURIs
fields: fields:
description: Metadata about the account. description: Metadata about the account.
items: items:
@ -246,6 +256,8 @@ definitions:
description: Account manually approves follow requests. description: Account manually approves follow requests.
type: boolean type: boolean
x-go-name: Locked x-go-name: Locked
moved:
$ref: '#/definitions/account'
mute_expires_at: mute_expires_at:
description: If this account has been muted, when will the mute expire (ISO 8601 Datetime). description: If this account has been muted, when will the mute expire (ISO 8601 Datetime).
example: "2021-07-30T09:20:25+00:00" example: "2021-07-30T09:20:25+00:00"
@ -1419,6 +1431,10 @@ definitions:
This should be displayed on the 'about' page for an instance. This should be displayed on the 'about' page for an instance.
type: string type: string
x-go-name: Description x-go-name: Description
description_text:
description: Raw (unparsed) version of description.
type: string
x-go-name: DescriptionText
email: email:
description: An email address that may be used for inquiries. description: An email address that may be used for inquiries.
example: admin@example.org example: admin@example.org
@ -1463,6 +1479,10 @@ definitions:
This should be displayed on the instance splash/landing page. This should be displayed on the instance splash/landing page.
type: string type: string
x-go-name: ShortDescription x-go-name: ShortDescription
short_description_text:
description: Raw (unparsed) version of short description.
type: string
x-go-name: ShortDescriptionText
stats: stats:
additionalProperties: additionalProperties:
format: int64 format: int64
@ -1474,6 +1494,10 @@ definitions:
description: Terms and conditions for accounts on this instance. description: Terms and conditions for accounts on this instance.
type: string type: string
x-go-name: Terms x-go-name: Terms
terms_text:
description: Raw (unparsed) version of terms.
type: string
x-go-name: TermsRaw
thumbnail: thumbnail:
description: URL of the instance avatar/banner image. description: URL of the instance avatar/banner image.
example: https://example.org/files/instance/thumbnail.jpeg example: https://example.org/files/instance/thumbnail.jpeg
@ -1565,6 +1589,10 @@ definitions:
This should be displayed on the 'about' page for an instance. This should be displayed on the 'about' page for an instance.
type: string type: string
x-go-name: Description x-go-name: Description
description_text:
description: Raw (unparsed) version of description.
type: string
x-go-name: DescriptionText
domain: domain:
description: The domain of the instance. description: The domain of the instance.
example: gts.example.org example: gts.example.org
@ -1595,6 +1623,10 @@ definitions:
description: Terms and conditions for accounts on this instance. description: Terms and conditions for accounts on this instance.
type: string type: string
x-go-name: Terms x-go-name: Terms
terms_text:
description: Raw (unparsed) version of terms.
type: string
x-go-name: TermsText
thumbnail: thumbnail:
$ref: '#/definitions/instanceV2Thumbnail' $ref: '#/definitions/instanceV2Thumbnail'
title: title:
@ -3509,6 +3541,47 @@ paths:
summary: Unfollow account with id. summary: Unfollow account with id.
tags: tags:
- accounts - accounts
/api/v1/accounts/alias:
post:
consumes:
- multipart/form-data
description: |-
This is useful when you want to move from another account this this account.
In such cases, you should set the alsoKnownAs of this account to the URI of
the account you want to move from.
operationId: accountAlias
parameters:
- description: |-
ActivityPub URI/IDs of target accounts to which this account is being aliased. Eg., `["https://example.org/users/some_account"]`.
Use an empty array to unset alsoKnownAs, clearing the aliases.
in: formData
name: also_known_as_uris
required: true
type: string
responses:
"200":
description: The newly updated account.
schema:
$ref: '#/definitions/account'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"422":
description: Unprocessable. Check the response body for more details.
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:accounts
summary: Alias your account to another account by setting alsoKnownAs to the given URI.
tags:
- accounts
/api/v1/accounts/delete: /api/v1/accounts/delete:
post: post:
consumes: consumes:
@ -3571,6 +3644,43 @@ paths:
summary: Quickly lookup a username to see if it is available, skipping WebFinger resolution. summary: Quickly lookup a username to see if it is available, skipping WebFinger resolution.
tags: tags:
- accounts - accounts
/api/v1/accounts/move:
post:
consumes:
- multipart/form-data
operationId: accountMove
parameters:
- description: Password of the account user, for confirmation.
in: formData
name: password
required: true
type: string
- description: ActivityPub URI/ID of the target account. Eg., `https://example.org/users/some_account`. The target account must be alsoKnownAs the requesting account in order for the move to be successful.
in: formData
name: moved_to_uri
required: true
type: string
responses:
"202":
description: The account move has been accepted and the account will be moved.
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"422":
description: Unprocessable. Check the response body for more details.
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:accounts
summary: Move your account to another account.
tags:
- accounts
/api/v1/accounts/relationships: /api/v1/accounts/relationships:
get: get:
operationId: accountRelationships operationId: accountRelationships

View file

@ -392,8 +392,8 @@ func (suite *InboxPostTestSuite) TestPostUpdate() {
suite.EqualValues(requestingAccount.HeaderRemoteURL, dbUpdatedAccount.HeaderRemoteURL) suite.EqualValues(requestingAccount.HeaderRemoteURL, dbUpdatedAccount.HeaderRemoteURL)
suite.EqualValues(requestingAccount.Note, dbUpdatedAccount.Note) suite.EqualValues(requestingAccount.Note, dbUpdatedAccount.Note)
suite.EqualValues(requestingAccount.Memorial, dbUpdatedAccount.Memorial) suite.EqualValues(requestingAccount.Memorial, dbUpdatedAccount.Memorial)
suite.EqualValues(requestingAccount.AlsoKnownAs, dbUpdatedAccount.AlsoKnownAs) suite.EqualValues(requestingAccount.AlsoKnownAsURIs, dbUpdatedAccount.AlsoKnownAsURIs)
suite.EqualValues(requestingAccount.MovedToAccountID, dbUpdatedAccount.MovedToAccountID) suite.EqualValues(requestingAccount.MovedToURI, dbUpdatedAccount.MovedToURI)
suite.EqualValues(requestingAccount.Bot, dbUpdatedAccount.Bot) suite.EqualValues(requestingAccount.Bot, dbUpdatedAccount.Bot)
suite.EqualValues(requestingAccount.Reason, dbUpdatedAccount.Reason) suite.EqualValues(requestingAccount.Reason, dbUpdatedAccount.Reason)
suite.EqualValues(requestingAccount.Locked, dbUpdatedAccount.Locked) suite.EqualValues(requestingAccount.Locked, dbUpdatedAccount.Locked)

View file

@ -0,0 +1,99 @@
// 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 accounts
import (
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// AccountAliasPOSTHandler swagger:operation POST /api/v1/accounts/alias accountAlias
//
// Alias your account to another account by setting alsoKnownAs to the given URI.
//
// This is useful when you want to move from another account this this account.
//
// In such cases, you should set the alsoKnownAs of this account to the URI of
// the account you want to move from.
//
// ---
// tags:
// - accounts
//
// consumes:
// - multipart/form-data
//
// parameters:
// -
// name: also_known_as_uris
// in: formData
// description: >-
// ActivityPub URI/IDs of target accounts to which this account
// is being aliased. Eg., `["https://example.org/users/some_account"]`.
//
// Use an empty array to unset alsoKnownAs, clearing the aliases.
// type: string
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:accounts
//
// responses:
// '200':
// description: "The newly updated account."
// schema:
// "$ref": "#/definitions/account"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '422':
// description: Unprocessable. Check the response body for more details.
// '500':
// description: internal server error
func (m *Module) AccountAliasPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := &apimodel.AccountAliasRequest{}
if err := c.ShouldBind(&form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Account().Alias(c.Request.Context(), authed.Account, form.AlsoKnownAsURIs)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, resp)
}

View file

@ -0,0 +1,97 @@
// 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 accounts
import (
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// AccountMovePOSTHandler swagger:operation POST /api/v1/accounts/move accountMove
//
// Move your account to another account.
//
// ---
// tags:
// - accounts
//
// consumes:
// - multipart/form-data
//
// parameters:
// -
// name: password
// in: formData
// description: Password of the account user, for confirmation.
// type: string
// required: true
// -
// name: moved_to_uri
// in: formData
// description: >-
// ActivityPub URI/ID of the target account. Eg., `https://example.org/users/some_account`.
// The target account must be alsoKnownAs the requesting account in order for the move to be successful.
// type: string
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:accounts
//
// responses:
// '202':
// description: The account move has been accepted and the account will be moved.
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '422':
// description: Unprocessable. Check the response body for more details.
// '500':
// description: internal server error
func (m *Module) AccountMovePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := &apimodel.AccountMoveRequest{}
if err := c.ShouldBind(&form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if errWithCode := m.processor.Account().MoveSelf(c.Request.Context(), authed, form); errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusAccepted, map[string]string{
"message": "accepted",
})
}

View file

@ -53,6 +53,8 @@
UnfollowPath = BasePathWithID + "/unfollow" UnfollowPath = BasePathWithID + "/unfollow"
UpdatePath = BasePath + "/update_credentials" UpdatePath = BasePath + "/update_credentials"
VerifyPath = BasePath + "/verify_credentials" VerifyPath = BasePath + "/verify_credentials"
MovePath = BasePath + "/move"
AliasPath = BasePath + "/alias"
) )
type Module struct { type Module struct {
@ -108,4 +110,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
// search for accounts // search for accounts
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler) attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler) attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
// migration handlers
attachHandler(http.MethodPost, AliasPath, m.AccountAliasPOSTHandler)
attachHandler(http.MethodPost, MovePath, m.AccountMovePOSTHandler)
} }

View file

@ -96,6 +96,9 @@ type Account struct {
// Role of the account on this instance. // Role of the account on this instance.
// Omitted for remote accounts. // Omitted for remote accounts.
Role *AccountRole `json:"role,omitempty"` Role *AccountRole `json:"role,omitempty"`
// If set, indicates that this account is currently inactive, and has migrated to the given account.
// Omitted for accounts that haven't moved, and for suspended accounts.
Moved *Account `json:"moved,omitempty"`
} }
// AccountCreateRequest models account creation parameters. // AccountCreateRequest models account creation parameters.
@ -213,6 +216,23 @@ type AccountDeleteRequest struct {
Password string `form:"password" json:"password" xml:"password"` Password string `form:"password" json:"password" xml:"password"`
} }
// AccountMoveRequest models a request to Move an account.
//
// swagger:ignore
type AccountMoveRequest struct {
// Password of the account's user, for confirmation.
Password string `form:"password" json:"password" xml:"password"`
// ActivityPub URI of the account that's being moved to.
MovedToURI string `form:"moved_to_uri" json:"moved_to_uri" xml:"moved_to_uri"`
}
// AccountAliasRequest models a request
// to set an account's alsoKnownAs URIs.
type AccountAliasRequest struct {
// ActivityPub URIs of any accounts that this one is being aliased to.
AlsoKnownAsURIs []string `form:"also_known_as_uris" json:"also_known_as_uris" xml:"also_known_as_uris"`
}
// AccountRole models the role of an account. // AccountRole models the role of an account.
// //
// swagger:model accountRole // swagger:model accountRole

View file

@ -38,4 +38,9 @@ type Source struct {
Fields []Field `json:"fields"` Fields []Field `json:"fields"`
// The number of pending follow requests. // The number of pending follow requests.
FollowRequestsCount int `json:"follow_requests_count"` FollowRequestsCount int `json:"follow_requests_count"`
// This account is aliased to / also known as accounts at the
// given ActivityPub URIs. To set this, use `/api/v1/accounts/alias`.
//
// Omitted from json if empty / not set.
AlsoKnownAsURIs []string `json:"also_known_as_uris,omitempty"`
} }

View file

@ -254,7 +254,7 @@ func (a *accountDB) getAccount(ctx context.Context, lookup string, dbQuery func(
func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Account) error { func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Account) error {
var ( var (
err error err error
errs = gtserror.NewMultiError(3) errs = gtserror.NewMultiError(5)
) )
if account.AvatarMediaAttachment == nil && account.AvatarMediaAttachmentID != "" { if account.AvatarMediaAttachment == nil && account.AvatarMediaAttachmentID != "" {
@ -279,6 +279,37 @@ func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Accou
} }
} }
if !account.AlsoKnownAsPopulated() {
// Account alsoKnownAs accounts are
// out-of-date with URIs, repopulate.
alsoKnownAs := make([]*gtsmodel.Account, 0)
for _, uri := range account.AlsoKnownAsURIs {
akaAcct, err := a.state.DB.GetAccountByURI(
gtscontext.SetBarebones(ctx),
uri,
)
if err != nil {
errs.Appendf("error populating also known as account %s: %w", uri, err)
continue
}
alsoKnownAs = append(alsoKnownAs, akaAcct)
}
account.AlsoKnownAs = alsoKnownAs
}
if account.MovedTo == nil && account.MovedToURI != "" {
// Account movedTo is not set, fetch from database.
account.MovedTo, err = a.state.DB.GetAccountByURI(
gtscontext.SetBarebones(ctx),
account.MovedToURI,
)
if err != nil {
errs.Appendf("error populating moved to account: %w", err)
}
}
if !account.EmojisPopulated() { if !account.EmojisPopulated() {
// Account emojis are out-of-date with IDs, repopulate. // Account emojis are out-of-date with IDs, repopulate.
account.Emojis, err = a.state.DB.GetEmojisByIDs( account.Emojis, err = a.state.DB.GetEmojisByIDs(

View file

@ -86,8 +86,8 @@ func (suite *BasicTestSuite) TestPutAccountWithBunDefaultFields() {
suite.Empty(a.Note) suite.Empty(a.Note)
suite.Empty(a.NoteRaw) suite.Empty(a.NoteRaw)
suite.False(*a.Memorial) suite.False(*a.Memorial)
suite.Empty(a.AlsoKnownAs) suite.Empty(a.AlsoKnownAsURIs)
suite.Empty(a.MovedToAccountID) suite.Empty(a.MovedToURI)
suite.False(*a.Bot) suite.False(*a.Bot)
suite.Empty(a.Reason) suite.Empty(a.Reason)
// Locked is especially important, since it's a bool that defaults // Locked is especially important, since it's a bool that defaults

View file

@ -20,7 +20,7 @@
import ( import (
"context" "context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20230328203024_migration_fix"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )

View file

@ -0,0 +1,79 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"crypto/rsa"
"time"
)
// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc).
type Account struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item was last updated.
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
Username string `bun:",nullzero,notnull,unique:usernamedomain"` // Username of the account, should just be a string of [a-zA-Z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org``. Username and domain should be unique *with* each other
Domain string `bun:",nullzero,unique:usernamedomain"` // Domain of the account, will be null if this is a local account, otherwise something like ``example.org``. Should be unique with username.
AvatarMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` // Database ID of the media attachment, if present
AvatarRemoteURL string `bun:",nullzero"` // For a non-local account, where can the header be fetched?
HeaderMediaAttachmentID string `bun:"type:CHAR(26),nullzero"` // Database ID of the media attachment, if present
HeaderRemoteURL string `bun:",nullzero"` // For a non-local account, where can the header be fetched?
DisplayName string `bun:""` // DisplayName for this account. Can be empty, then just the Username will be used for display purposes.
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this account's bio, display name, etc
Fields []*Field // A slice of of fields that this account has added to their profile.
Note string `bun:""` // A note that this account has on their profile (ie., the account's bio/description of themselves)
NoteRaw string `bun:""` // The raw contents of .Note without conversion to HTML, only available when requester = target
Memorial *bool `bun:",default:false"` // Is this a memorial account, ie., has the user passed away?
AlsoKnownAs string `bun:",nullzero"` // This account is associated with x account URI.
MovedToAccountID string `bun:",nullzero"` // This account has moved to this account URI.
Bot *bool `bun:",default:false"` // Does this account identify itself as a bot?
Reason string `bun:""` // What reason was given for signing up when this account was created?
Locked *bool `bun:",default:true"` // Does this account need an approval for new followers?
Discoverable *bool `bun:",default:false"` // Should this account be shown in the instance's profile directory?
Privacy string `bun:",nullzero"` // Default post privacy for this account
Sensitive *bool `bun:",default:false"` // Set posts from this account to sensitive by default?
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
URI string `bun:",nullzero,notnull,unique"` // ActivityPub URI for this account.
URL string `bun:",nullzero,unique"` // Web URL for this account's profile
InboxURI string `bun:",nullzero,unique"` // Address of this account's ActivityPub inbox, for sending activity to
SharedInboxURI *string `bun:""` // Address of this account's ActivityPub sharedInbox. Gotcha warning: this is a string pointer because it has three possible states: 1. We don't know yet if the account has a shared inbox -- null. 2. We know it doesn't have a shared inbox -- empty string. 3. We know it does have a shared inbox -- url string.
OutboxURI string `bun:",nullzero,unique"` // Address of this account's activitypub outbox
FollowingURI string `bun:",nullzero,unique"` // URI for getting the following list of this account
FollowersURI string `bun:",nullzero,unique"` // URI for getting the followers list of this account
FeaturedCollectionURI string `bun:",nullzero,unique"` // URL for getting the featured collection list of this account
ActorType string `bun:",nullzero,notnull"` // What type of activitypub actor is this account?
PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for signing activitypub requests, will only be defined for local accounts
PublicKey *rsa.PublicKey `bun:",notnull"` // Publickey for authorizing signed activitypub requests, will be defined for both local and remote accounts
PublicKeyURI string `bun:",nullzero,notnull,unique"` // Web-reachable location of this account's public key
PublicKeyExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // PublicKey will expire/has expired at given time, and should be fetched again as appropriate. Only ever set for remote accounts.
SensitizedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account set to have all its media shown as sensitive?
SilencedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account silenced (eg., statuses only visible to followers, not public)?
SuspendedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account)
HideCollections *bool `bun:",default:false"` // Hide this account's collections
SuspensionOrigin string `bun:"type:CHAR(26),nullzero"` // id of the database entry that caused this account to become suspended -- can be an account ID or a domain block ID
EnableRSS *bool `bun:",default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
}
type Field struct {
Name string `validate:"required"` // Name of this field.
Value string `validate:"required"` // Value of this field.
VerifiedAt time.Time `validate:"-" bun:",nullzero"` // This field was verified at (optional).
}

View file

@ -0,0 +1,88 @@
// 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 migrations
import (
"context"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Drop now-unused columns
// from accounts table.
for _, column := range []string{
"also_known_as",
"moved_to_account_id",
} {
if _, err := tx.
NewDropColumn().
Table("accounts").
Column(column).
Exec(ctx); err != nil {
return err
}
}
// Create new columns.
if _, err := tx.
NewAddColumn().
Table("accounts").
ColumnExpr("? VARCHAR", bun.Ident("moved_to_uri")).
Exec(ctx); err != nil {
return err
}
switch tx.Dialect().Name() {
case dialect.SQLite:
if _, err := tx.
NewAddColumn().
Table("accounts").
ColumnExpr("? VARCHAR", bun.Ident("also_known_as_uris")).
Exec(ctx); err != nil {
return err
}
case dialect.PG:
if _, err := tx.
NewAddColumn().
Table("accounts").
ColumnExpr("? VARCHAR ARRAY", bun.Ident("also_known_as_uris")).
Exec(ctx); err != nil {
return err
}
default:
panic("db conn was neither pg not sqlite")
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -52,8 +52,10 @@ type Account struct {
Note string `bun:""` // A note that this account has on their profile (ie., the account's bio/description of themselves) Note string `bun:""` // A note that this account has on their profile (ie., the account's bio/description of themselves)
NoteRaw string `bun:""` // The raw contents of .Note without conversion to HTML, only available when requester = target NoteRaw string `bun:""` // The raw contents of .Note without conversion to HTML, only available when requester = target
Memorial *bool `bun:",default:false"` // Is this a memorial account, ie., has the user passed away? Memorial *bool `bun:",default:false"` // Is this a memorial account, ie., has the user passed away?
AlsoKnownAs string `bun:"type:CHAR(26),nullzero"` // This account is associated with x account id (TODO: migrate to be AlsoKnownAsID) AlsoKnownAsURIs []string `bun:"also_known_as_uris,nullzero"` // This account is associated with these account URIs.
MovedToAccountID string `bun:"type:CHAR(26),nullzero"` // This account has moved this account id in the database AlsoKnownAs []*Account `bun:"-"` // This account is associated with these accounts (field not stored in the db).
MovedToURI string `bun:",nullzero"` // This account has moved to this account URI.
MovedTo *Account `bun:"-"` // This account has moved to this account (field not stored in the db).
Bot *bool `bun:",default:false"` // Does this account identify itself as a bot? Bot *bool `bun:",default:false"` // Does this account identify itself as a bot?
Reason string `bun:""` // What reason was given for signing up when this account was created? Reason string `bun:""` // What reason was given for signing up when this account was created?
Locked *bool `bun:",default:true"` // Does this account need an approval for new followers? Locked *bool `bun:",default:true"` // Does this account need an approval for new followers?
@ -109,7 +111,8 @@ func (a *Account) IsInstance() bool {
a.Username == "instance.actor" // <- misskey a.Username == "instance.actor" // <- misskey
} }
// EmojisPopulated returns whether emojis are populated according to current EmojiIDs. // EmojisPopulated returns whether emojis are
// populated according to current EmojiIDs.
func (a *Account) EmojisPopulated() bool { func (a *Account) EmojisPopulated() bool {
if len(a.EmojiIDs) != len(a.Emojis) { if len(a.EmojiIDs) != len(a.Emojis) {
// this is the quickest indicator. // this is the quickest indicator.
@ -130,6 +133,28 @@ func (a *Account) EmojisPopulated() bool {
return true return true
} }
// AlsoKnownAsPopulated returns whether alsoKnownAs accounts
// are populated according to current AlsoKnownAsURIs.
func (a *Account) AlsoKnownAsPopulated() bool {
if len(a.AlsoKnownAsURIs) != len(a.AlsoKnownAs) {
// this is the quickest indicator.
return false
}
// Accounts must be in same order.
for i, uri := range a.AlsoKnownAsURIs {
if a.AlsoKnownAs[i] == nil {
log.Warnf(nil, "nil account in alsoKnownAs slice for account %s", a.URI)
continue
}
if a.AlsoKnownAs[i].URI != uri {
return false
}
}
return true
}
// PubKeyExpired returns true if the account's public key // PubKeyExpired returns true if the account's public key
// has been marked as expired, and the expiry time has passed. // has been marked as expired, and the expiry time has passed.
func (a *Account) PubKeyExpired() bool { func (a *Account) PubKeyExpired() bool {

View file

@ -0,0 +1,149 @@
// 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
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) Alias(
ctx context.Context,
account *gtsmodel.Account,
newAKAURIStrs []string,
) (*apimodel.Account, gtserror.WithCode) {
if slices.Equal(
newAKAURIStrs,
account.AlsoKnownAsURIs,
) {
// No changes to do
// here. Return early.
return p.c.GetAPIAccountSensitive(ctx, account)
}
newLen := len(newAKAURIStrs)
if newLen == 0 {
// Simply unset existing
// aliases and return early.
account.AlsoKnownAsURIs = nil
account.AlsoKnownAs = nil
err := p.state.DB.UpdateAccount(ctx, account, "also_known_as_uris")
if err != nil {
err := gtserror.Newf("db error updating also_known_as_uri: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.c.GetAPIAccountSensitive(ctx, account)
}
// We need to set new AKA URIs!
//
// First parse them to URI ptrs and
// normalized string representations.
//
// Use this cheeky type to avoid
// repeatedly calling uri.String().
type uri struct {
uri *url.URL // Parsed URI.
str string // uri.String().
}
newAKAs := make([]uri, newLen)
for i, newAKAURIStr := range newAKAURIStrs {
newAKAURI, err := url.Parse(newAKAURIStr)
if err != nil {
err := fmt.Errorf(
"invalid also_known_as_uri (%s) provided in account alias request: %w",
newAKAURIStr, err,
)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// We only deref http or https, so check this.
if newAKAURI.Scheme != "https" && newAKAURI.Scheme != "http" {
err := fmt.Errorf(
"invalid also_known_as_uri (%s) provided in account alias request: %w",
newAKAURIStr, errors.New("uri must not be empty and scheme must be http or https"),
)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
newAKAs[i].uri = newAKAURI
newAKAs[i].str = newAKAURI.String()
}
// Dedupe the URI/string pairs.
newAKAs = util.DeduplicateFunc(
newAKAs,
func(v uri) string {
return v.str
},
)
// For each deduped entry, get and
// check the target account, and set.
for _, newAKA := range newAKAs {
// Don't let account do anything
// daft by aliasing to itself.
if newAKA.str == account.URI {
continue
}
// Ensure we have a valid, up-to-date
// representation of the target account.
targetAccount, _, err := p.federator.GetAccountByURI(ctx, account.Username, newAKA.uri)
if err != nil {
err := fmt.Errorf(
"error dereferencing also_known_as_uri (%s) account: %w",
newAKA.str, err,
)
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Alias target must not be suspended.
if !targetAccount.SuspendedAt.IsZero() {
err := fmt.Errorf(
"target account %s is suspended from this instance; "+
"you will not be able to set alsoKnownAs to that account",
newAKA.str,
)
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Alrighty-roo, looks good, add this one.
account.AlsoKnownAsURIs = append(account.AlsoKnownAsURIs, newAKA.str)
account.AlsoKnownAs = append(account.AlsoKnownAs, targetAccount)
}
err := p.state.DB.UpdateAccount(ctx, account, "also_known_as_uris")
if err != nil {
err := gtserror.Newf("db error updating also_known_as_uri: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.c.GetAPIAccountSensitive(ctx, account)
}

View file

@ -0,0 +1,161 @@
// 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"
"slices"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type AliasTestSuite struct {
AccountStandardTestSuite
}
func (suite *AliasTestSuite) TestAliasAccount() {
for _, test := range []struct {
newAliases []string
expectedAliases []string
expectedErr string
}{
// Alias zork to turtle.
{
newAliases: []string{
"http://localhost:8080/users/1happyturtle",
},
expectedAliases: []string{
"http://localhost:8080/users/1happyturtle",
},
},
// Alias zork to admin.
{
newAliases: []string{
"http://localhost:8080/users/admin",
},
expectedAliases: []string{
"http://localhost:8080/users/admin",
},
},
// Alias zork to turtle AND admin.
{
newAliases: []string{
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
},
expectedAliases: []string{
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
},
},
// Same again (noop).
{
newAliases: []string{
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
},
expectedAliases: []string{
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
},
},
// Remove admin alias.
{
newAliases: []string{
"http://localhost:8080/users/1happyturtle",
},
expectedAliases: []string{
"http://localhost:8080/users/1happyturtle",
},
},
// Clear aliases.
{
newAliases: []string{},
expectedAliases: []string{},
},
// Set bad alias.
{
newAliases: []string{"oh no"},
expectedErr: "invalid also_known_as_uri (oh no) provided in account alias request: uri must not be empty and scheme must be http or https",
},
// Try to alias to self (won't do anything).
{
newAliases: []string{
"http://localhost:8080/users/the_mighty_zork",
},
expectedAliases: []string{},
},
// Try to alias to self and admin
// (only non-self alias will work).
{
newAliases: []string{
"http://localhost:8080/users/the_mighty_zork",
"http://localhost:8080/users/admin",
},
expectedAliases: []string{
"http://localhost:8080/users/admin",
},
},
// Alias zork to turtle AND admin,
// duplicates should be removed.
{
newAliases: []string{
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
"http://localhost:8080/users/admin",
},
expectedAliases: []string{
"http://localhost:8080/users/1happyturtle",
"http://localhost:8080/users/admin",
},
},
} {
var (
ctx = context.Background()
testAcct = new(gtsmodel.Account)
)
// Copy zork test account.
*testAcct = *suite.testAccounts["local_account_1"]
apiAcct, err := suite.accountProcessor.Alias(ctx, testAcct, test.newAliases)
if err != nil {
if err.Error() != test.expectedErr {
suite.FailNow("", "unexpected error: %s", err)
} else {
continue
}
}
if !slices.Equal(apiAcct.Source.AlsoKnownAsURIs, test.expectedAliases) {
suite.FailNow("", "unexpected aliases: %+v", apiAcct.Source.AlsoKnownAsURIs)
}
}
}
func TestAliasTestSuite(t *testing.T) {
suite.Run(t, new(AliasTestSuite))
}

View file

@ -516,8 +516,8 @@ func stubbifyAccount(account *gtsmodel.Account, origin string) []string {
account.Note = "" account.Note = ""
account.NoteRaw = "" account.NoteRaw = ""
account.Memorial = util.Ptr(false) account.Memorial = util.Ptr(false)
account.AlsoKnownAs = "" account.AlsoKnownAsURIs = nil
account.MovedToAccountID = "" account.MovedToURI = ""
account.Reason = "" account.Reason = ""
account.Discoverable = util.Ptr(false) account.Discoverable = util.Ptr(false)
account.StatusContentType = "" account.StatusContentType = ""
@ -539,8 +539,8 @@ func stubbifyAccount(account *gtsmodel.Account, origin string) []string {
"note", "note",
"note_raw", "note_raw",
"memorial", "memorial",
"also_known_as", "also_known_as_uris",
"moved_to_account_id", "moved_to_uri",
"reason", "reason",
"discoverable", "discoverable",
"status_content_type", "status_content_type",

View file

@ -65,7 +65,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeleteLocal() {
suite.Zero(updatedAccount.Note) suite.Zero(updatedAccount.Note)
suite.Zero(updatedAccount.NoteRaw) suite.Zero(updatedAccount.NoteRaw)
suite.False(*updatedAccount.Memorial) suite.False(*updatedAccount.Memorial)
suite.Zero(updatedAccount.AlsoKnownAs) suite.Empty(updatedAccount.AlsoKnownAsURIs)
suite.Zero(updatedAccount.Reason) suite.Zero(updatedAccount.Reason)
suite.False(*updatedAccount.Discoverable) suite.False(*updatedAccount.Discoverable)
suite.Zero(updatedAccount.StatusContentType) suite.Zero(updatedAccount.StatusContentType)

View file

@ -0,0 +1,153 @@
// 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
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"golang.org/x/crypto/bcrypt"
)
func (p *Processor) MoveSelf(
ctx context.Context,
authed *oauth.Auth,
form *apimodel.AccountMoveRequest,
) gtserror.WithCode {
// Ensure valid MovedToURI.
if form.MovedToURI == "" {
err := errors.New("no moved_to_uri provided in account Move request")
return gtserror.NewErrorBadRequest(err, err.Error())
}
movedToURI, 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" {
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())
}
// Self account Move requires password to ensure it's for real.
if form.Password == "" {
err := errors.New("no password provided in account Move request")
return gtserror.NewErrorBadRequest(err, err.Error())
}
if err := bcrypt.CompareHashAndPassword(
[]byte(authed.User.EncryptedPassword),
[]byte(form.Password),
); err != nil {
err := errors.New("invalid password provided in account Move request")
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.
err := fmt.Errorf(
"account %s is already Moved to %s, cannot also Move to %s",
account.URI, account.MovedToURI, form.MovedToURI,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Ensure we have a valid, up-to-date representation of the target account.
targetAccount, _, err = p.federator.GetAccountByURI(ctx, account.Username, movedToURI)
if err != nil {
err := fmt.Errorf("error dereferencing moved_to_uri account: %w", err)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
if !targetAccount.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,
)
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) {
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,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Target account cannot itself have
// already Moved somewhere else.
if targetAccount.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,
)
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Everything seems OK, so process the Move.
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ActorPerson,
APActivityType: ap.ActivityMove,
OriginAccount: account,
TargetAccount: targetAccount,
})
return nil
}

View file

@ -162,6 +162,23 @@ func (p *Processor) GetAPIAccountBlocked(
return apiAccount, nil return apiAccount, nil
} }
// GetAPIAccountSensitive fetches the "sensitive" account model for the given target.
// *BE CAREFUL!* Only return a sensitive account if targetAcc == account making the request.
func (p *Processor) GetAPIAccountSensitive(
ctx context.Context,
targetAcc *gtsmodel.Account,
) (
apiAcc *apimodel.Account,
errWithCode gtserror.WithCode,
) {
apiAccount, err := p.converter.AccountToAPIAccountSensitive(ctx, targetAcc)
if err != nil {
err = gtserror.Newf("error converting account: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiAccount, nil
}
// GetVisibleAPIAccounts converts an array of gtsmodel.Accounts (inputted by next function) into // GetVisibleAPIAccounts converts an array of gtsmodel.Accounts (inputted by next function) into
// public API model accounts, checking first for visibility. Please note that all errors will be // public API model accounts, checking first for visibility. Please note that all errors will be
// logged at ERROR level, but will not be returned. Callers are likely to run into show-stopping // logged at ERROR level, but will not be returned. Callers are likely to run into show-stopping

View file

@ -90,6 +90,7 @@ func (c *Converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode
Note: a.NoteRaw, Note: a.NoteRaw,
Fields: c.fieldsToAPIFields(a.FieldsRaw), Fields: c.fieldsToAPIFields(a.FieldsRaw),
FollowRequestsCount: frc, FollowRequestsCount: frc,
AlsoKnownAsURIs: a.AlsoKnownAsURIs,
} }
return apiAccount, nil return apiAccount, nil
@ -111,27 +112,27 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
followersCount, err := c.state.DB.CountAccountFollowers(ctx, a.ID) followersCount, err := c.state.DB.CountAccountFollowers(ctx, a.ID)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting followers: %w", err) return nil, gtserror.Newf("error counting followers: %w", err)
} }
followingCount, err := c.state.DB.CountAccountFollows(ctx, a.ID) followingCount, err := c.state.DB.CountAccountFollows(ctx, a.ID)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting following: %w", err) return nil, gtserror.Newf("error counting following: %w", err)
} }
statusesCount, err := c.state.DB.CountAccountStatuses(ctx, a.ID) statusesCount, err := c.state.DB.CountAccountStatuses(ctx, a.ID)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting statuses: %w", err) return nil, gtserror.Newf("error counting statuses: %w", err)
} }
var lastStatusAt *string var lastStatusAt *string
lastPosted, err := c.state.DB.GetAccountLastPosted(ctx, a.ID, false) lastPosted, err := c.state.DB.GetAccountLastPosted(ctx, a.ID, false)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error counting statuses: %w", err) return nil, gtserror.Newf("error getting last posted: %w", err)
} }
if !lastPosted.IsZero() { if !lastPosted.IsZero() {
lastStatusAt = func() *string { t := util.FormatISO8601(lastPosted); return &t }() lastStatusAt = util.Ptr(util.FormatISO8601(lastPosted))
} }
// Profile media + nice extras: // Profile media + nice extras:
@ -180,7 +181,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
// de-punify it just in case. // de-punify it just in case.
d, err := util.DePunify(a.Domain) d, err := util.DePunify(a.Domain)
if err != nil { if err != nil {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error de-punifying domain %s for account id %s: %w", a.Domain, a.ID, err) return nil, gtserror.Newf("error de-punifying domain %s for account id %s: %w", a.Domain, a.ID, err)
} }
acct = a.Username + "@" + d acct = a.Username + "@" + d
@ -191,7 +192,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
if !a.IsInstance() { if !a.IsInstance() {
user, err := c.state.DB.GetUserByAccountID(ctx, a.ID) user, err := c.state.DB.GetUserByAccountID(ctx, a.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("AccountToAPIAccountPublic: error getting user from database for account id %s: %w", a.ID, err) return nil, gtserror.Newf("error getting user from database for account id %s: %w", a.ID, err)
} }
switch { switch {
@ -207,6 +208,15 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
acct = a.Username // omit domain acct = a.Username // omit domain
} }
// Populate moved.
var moved *apimodel.Account
if a.MovedTo != nil {
moved, err = c.AccountToAPIAccountPublic(ctx, a.MovedTo)
if err != nil {
log.Errorf(ctx, "error converting account movedTo: %v", err)
}
}
// Remaining properties are simple and // Remaining properties are simple and
// can be populated directly below. // can be populated directly below.
@ -235,6 +245,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
CustomCSS: a.CustomCSS, CustomCSS: a.CustomCSS,
EnableRSS: *a.EnableRSS, EnableRSS: *a.EnableRSS,
Role: role, Role: role,
Moved: moved,
} }
// Bodge default avatar + header in, // Bodge default avatar + header in,

View file

@ -69,6 +69,105 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() {
}`, string(b)) }`, string(b))
} }
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved() {
// Take zork for this test.
var testAccount = new(gtsmodel.Account)
*testAccount = *suite.testAccounts["local_account_1"]
// Update zork to indicate that he's moved to turtle.
// This is a bit weird but it's just for this test.
movedTo := suite.testAccounts["local_account_2"]
testAccount.MovedToURI = movedTo.URI
testAccount.AlsoKnownAsURIs = []string{movedTo.URI}
if err := suite.state.DB.UpdateAccount(context.Background(), testAccount, "moved_to_uri"); err != nil {
suite.FailNow(err.Error())
}
apiAccount, err := suite.typeconverter.AccountToAPIAccountSensitive(context.Background(), testAccount)
suite.NoError(err)
suite.NotNil(apiAccount)
// moved and also_known_as_uris
// should both be set now.
b, err := json.MarshalIndent(apiAccount, "", " ")
suite.NoError(err)
suite.Equal(`{
"id": "01F8MH1H7YV1Z7D2C8K2730QBF",
"username": "the_mighty_zork",
"acct": "the_mighty_zork",
"display_name": "original zork (he/they)",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-20T11:09:18.000Z",
"note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e",
"url": "http://localhost:8080/@the_mighty_zork",
"avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg",
"header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
"followers_count": 2,
"following_count": 2,
"statuses_count": 7,
"last_status_at": "2023-12-10T09:24:00.000Z",
"emojis": [],
"fields": [],
"source": {
"privacy": "public",
"sensitive": false,
"language": "en",
"status_content_type": "text/plain",
"note": "hey yo this is my profile!",
"fields": [],
"follow_requests_count": 0,
"also_known_as_uris": [
"http://localhost:8080/users/1happyturtle"
]
},
"enable_rss": true,
"role": {
"name": "user"
},
"moved": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"discoverable": false,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 8,
"last_status_at": "2021-07-28T08:40:37.000Z",
"emojis": [],
"fields": [
{
"name": "should you follow me?",
"value": "maybe!",
"verified_at": null
},
{
"name": "age",
"value": "120",
"verified_at": null
}
],
"role": {
"name": "user"
}
}
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() { func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() {
testAccount := &gtsmodel.Account{} testAccount := &gtsmodel.Account{}
*testAccount = *suite.testAccounts["local_account_1"] // take zork for this test *testAccount = *suite.testAccounts["local_account_1"] // take zork for this test

View file

@ -0,0 +1,63 @@
// 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 util
// Deduplicate deduplicates entries in the given slice.
func Deduplicate[T comparable](in []T) []T {
var (
inL = len(in)
unique = make(map[T]struct{}, inL)
deduped = make([]T, 0, inL)
)
for _, v := range in {
if _, ok := unique[v]; ok {
// Already have this.
continue
}
unique[v] = struct{}{}
deduped = append(deduped, v)
}
return deduped
}
// DeduplicateFunc deduplicates entries in the given
// slice, using the result of key() to gauge uniqueness.
func DeduplicateFunc[T any, C comparable](in []T, key func(v T) C) []T {
var (
inL = len(in)
unique = make(map[C]struct{}, inL)
deduped = make([]T, 0, inL)
)
for _, v := range in {
k := key(v)
if _, ok := unique[k]; ok {
// Already have this.
continue
}
unique[k] = struct{}{}
deduped = append(deduped, v)
}
return deduped
}

View file

@ -297,7 +297,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Note: "", Note: "",
NoteRaw: "", NoteRaw: "",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2020-05-17T13:10:59Z"), CreatedAt: TimeMustParse("2020-05-17T13:10:59Z"),
UpdatedAt: TimeMustParse("2020-05-17T13:10:59Z"), UpdatedAt: TimeMustParse("2020-05-17T13:10:59Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -317,7 +317,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://localhost:8080/users/localhost:8080/following", FollowingURI: "http://localhost:8080/users/localhost:8080/following",
FeaturedCollectionURI: "http://localhost:8080/users/localhost:8080/collections/featured", FeaturedCollectionURI: "http://localhost:8080/users/localhost:8080/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
SensitizedAt: time.Time{}, SensitizedAt: time.Time{},
@ -336,7 +335,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Fields: []*gtsmodel.Field{}, Fields: []*gtsmodel.Field{},
Note: "", Note: "",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -355,7 +354,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://localhost:8080/users/weed_lord420/following", FollowingURI: "http://localhost:8080/users/weed_lord420/following",
FeaturedCollectionURI: "http://localhost:8080/users/weed_lord420/collections/featured", FeaturedCollectionURI: "http://localhost:8080/users/weed_lord420/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://localhost:8080/users/weed_lord420#main-key", PublicKeyURI: "http://localhost:8080/users/weed_lord420#main-key",
@ -376,7 +374,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Note: "", Note: "",
NoteRaw: "", NoteRaw: "",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2022-05-17T13:10:59Z"), CreatedAt: TimeMustParse("2022-05-17T13:10:59Z"),
UpdatedAt: TimeMustParse("2022-05-17T13:10:59Z"), UpdatedAt: TimeMustParse("2022-05-17T13:10:59Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -396,7 +394,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://localhost:8080/users/admin/following", FollowingURI: "http://localhost:8080/users/admin/following",
FeaturedCollectionURI: "http://localhost:8080/users/admin/collections/featured", FeaturedCollectionURI: "http://localhost:8080/users/admin/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
SensitizedAt: time.Time{}, SensitizedAt: time.Time{},
@ -416,7 +413,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Note: "<p>hey yo this is my profile!</p>", Note: "<p>hey yo this is my profile!</p>",
NoteRaw: "hey yo this is my profile!", NoteRaw: "hey yo this is my profile!",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2022-05-20T11:09:18Z"), CreatedAt: TimeMustParse("2022-05-20T11:09:18Z"),
UpdatedAt: TimeMustParse("2022-05-20T11:09:18Z"), UpdatedAt: TimeMustParse("2022-05-20T11:09:18Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -435,7 +432,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://localhost:8080/users/the_mighty_zork/following", FollowingURI: "http://localhost:8080/users/the_mighty_zork/following",
FeaturedCollectionURI: "http://localhost:8080/users/the_mighty_zork/collections/featured", FeaturedCollectionURI: "http://localhost:8080/users/the_mighty_zork/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://localhost:8080/users/the_mighty_zork/main-key", PublicKeyURI: "http://localhost:8080/users/the_mighty_zork/main-key",
@ -475,7 +471,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Note: "<p>i post about things that concern me</p>", Note: "<p>i post about things that concern me</p>",
NoteRaw: "i post about things that concern me", NoteRaw: "i post about things that concern me",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -494,7 +490,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://localhost:8080/users/1happyturtle/following", FollowingURI: "http://localhost:8080/users/1happyturtle/following",
FeaturedCollectionURI: "http://localhost:8080/users/1happyturtle/collections/featured", FeaturedCollectionURI: "http://localhost:8080/users/1happyturtle/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://localhost:8080/users/1happyturtle#main-key", PublicKeyURI: "http://localhost:8080/users/1happyturtle#main-key",
@ -513,7 +508,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Fields: []*gtsmodel.Field{}, Fields: []*gtsmodel.Field{},
Note: "i post about like, i dunno, stuff, or whatever!!!!", Note: "i post about like, i dunno, stuff, or whatever!!!!",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2021-09-26T12:52:36+02:00"), CreatedAt: TimeMustParse("2021-09-26T12:52:36+02:00"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -531,7 +526,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://fossbros-anonymous.io/users/foss_satan/following", FollowingURI: "http://fossbros-anonymous.io/users/foss_satan/following",
FeaturedCollectionURI: "http://fossbros-anonymous.io/users/foss_satan/collections/featured", FeaturedCollectionURI: "http://fossbros-anonymous.io/users/foss_satan/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://fossbros-anonymous.io/users/foss_satan/main-key", PublicKeyURI: "http://fossbros-anonymous.io/users/foss_satan/main-key",
@ -550,7 +544,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Fields: []*gtsmodel.Field{}, Fields: []*gtsmodel.Field{},
Note: "i'm a real son of a gun", Note: "i'm a real son of a gun",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"), CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -568,7 +562,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://example.org/users/Some_User/following", FollowingURI: "http://example.org/users/Some_User/following",
FeaturedCollectionURI: "http://example.org/users/Some_User/collections/featured", FeaturedCollectionURI: "http://example.org/users/Some_User/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://example.org/users/Some_User#main-key", PublicKeyURI: "http://example.org/users/Some_User#main-key",
@ -587,7 +580,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
Fields: []*gtsmodel.Field{}, Fields: []*gtsmodel.Field{},
Note: "if i die blame charles don't let that fuck become king", Note: "if i die blame charles don't let that fuck become king",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"), CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -605,7 +598,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/following", FollowingURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/following",
FeaturedCollectionURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/collections/featured", FeaturedCollectionURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
PublicKeyURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj#main-key", PublicKeyURI: "http://thequeenisstillalive.technology/users/her_fuckin_maj#main-key",
@ -624,7 +616,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
DisplayName: "", DisplayName: "",
Note: "", Note: "",
Memorial: util.Ptr(false), Memorial: util.Ptr(false),
MovedToAccountID: "", MovedToURI: "",
CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"), CreatedAt: TimeMustParse("2020-08-10T14:13:28+02:00"),
UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"),
Bot: util.Ptr(false), Bot: util.Ptr(false),
@ -642,7 +634,6 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
FollowingURI: "https://xn--xample-ova.org/users/%C3%BCser/following", FollowingURI: "https://xn--xample-ova.org/users/%C3%BCser/following",
FeaturedCollectionURI: "https://xn--xample-ova.org/users/%C3%BCser/collections/featured", FeaturedCollectionURI: "https://xn--xample-ova.org/users/%C3%BCser/collections/featured",
ActorType: ap.ActorPerson, ActorType: ap.ActorPerson,
AlsoKnownAs: "",
PrivateKey: &rsa.PrivateKey{}, PrivateKey: &rsa.PrivateKey{},
PublicKey: &rsa.PublicKey{}, PublicKey: &rsa.PublicKey{},
PublicKeyURI: "https://xn--xample-ova.org/users/%C3%BCser#main-key", PublicKeyURI: "https://xn--xample-ova.org/users/%C3%BCser#main-key",