diff options
Diffstat (limited to 'internal/processing/account')
29 files changed, 1341 insertions, 660 deletions
diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index d65d7360c..e94b7e844 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -18,14 +18,15 @@ package account import ( - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/text" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/status" + "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Processor wraps functionality for updating, creating, and deleting accounts in response to API requests. @@ -39,6 +40,7 @@ type Processor struct { converter *typeutils.Converter mediaManager *media.Manager visFilter *visibility.Filter + statusFilter *status.Filter formatter *text.Formatter federator *federation.Federator parseMention gtsmodel.ParseMentionFunc @@ -53,6 +55,7 @@ func New( mediaManager *media.Manager, federator *federation.Federator, visFilter *visibility.Filter, + statusFilter *status.Filter, parseMention gtsmodel.ParseMentionFunc, ) Processor { return Processor{ @@ -61,6 +64,7 @@ func New( converter: converter, mediaManager: mediaManager, visFilter: visFilter, + statusFilter: statusFilter, formatter: text.NewFormatter(state.DB), federator: federator, parseMention: parseMention, diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 7bd9658dc..b322ee771 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -21,23 +21,25 @@ import ( "context" "time" + "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" + "code.superseriousbusiness.org/gotosocial/internal/filter/status" + "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/processing" + "code.superseriousbusiness.org/gotosocial/internal/processing/account" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/storage" + "code.superseriousbusiness.org/gotosocial/internal/transport" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/admin" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/processing/account" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/transport" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" ) type AccountStandardTestSuite struct { @@ -55,7 +57,6 @@ type AccountStandardTestSuite struct { // standard suite models testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client testApplications map[string]*gtsmodel.Application testUsers map[string]*gtsmodel.User testAccounts map[string]*gtsmodel.Account @@ -68,7 +69,7 @@ type AccountStandardTestSuite struct { } func (suite *AccountStandardTestSuite) getClientMsg(timeout time.Duration) (*messages.FromClientAPI, bool) { - ctx := context.Background() + ctx := suite.T().Context() ctx, cncl := context.WithTimeout(ctx, timeout) defer cncl() return suite.state.Workers.Client.Queue.PopCtx(ctx) @@ -76,7 +77,6 @@ func (suite *AccountStandardTestSuite) getClientMsg(timeout time.Duration) (*mes func (suite *AccountStandardTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() @@ -97,12 +97,6 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers) suite.tc = typeutils.NewConverter(&suite.state) - testrig.StartTimelines( - &suite.state, - visibility.NewFilter(&suite.state), - suite.tc, - ) - suite.storage = testrig.NewInMemoryStorage() suite.state.Storage = suite.storage suite.mediaManager = testrig.NewTestMediaManager(&suite.state) @@ -112,9 +106,11 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) - filter := visibility.NewFilter(&suite.state) - common := common.New(&suite.state, suite.mediaManager, suite.tc, suite.federator, filter) - suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) + visFilter := visibility.NewFilter(&suite.state) + mutesFilter := mutes.NewFilter(&suite.state) + statusFilter := status.NewFilter(&suite.state) + common := common.New(&suite.state, suite.mediaManager, suite.tc, suite.federator, visFilter, mutesFilter, statusFilter) + suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, visFilter, statusFilter, processing.GetParseMentionFunc(&suite.state, suite.federator)) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") } diff --git a/internal/processing/account/alias.go b/internal/processing/account/alias.go index d7d4cf547..01d4e0999 100644 --- a/internal/processing/account/alias.go +++ b/internal/processing/account/alias.go @@ -24,10 +24,10 @@ import ( "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/xslices" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/util/xslices" ) func (p *Processor) Alias( @@ -107,9 +107,14 @@ func (p *Processor) Alias( } // Ensure we have account dereferenced. + // + // As this comes from user input, allow checking + // by URL to make things easier, not just to an + // exact AP URI (which a user might not even know). targetAccount, _, err := p.federator.GetAccountByURI(ctx, account.Username, newAKA.uri, + true, ) if err != nil { err := fmt.Errorf( diff --git a/internal/processing/account/alias_test.go b/internal/processing/account/alias_test.go index 80fdb81c1..fef2335ec 100644 --- a/internal/processing/account/alias_test.go +++ b/internal/processing/account/alias_test.go @@ -18,12 +18,11 @@ package account_test import ( - "context" "slices" "testing" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) type AliasTestSuite struct { @@ -145,7 +144,7 @@ func (suite *AliasTestSuite) TestAliasAccount() { }, } { var ( - ctx = context.Background() + ctx = suite.T().Context() testAcct = new(gtsmodel.Account) ) diff --git a/internal/processing/account/block.go b/internal/processing/account/block.go index d3904bffa..3c143e53b 100644 --- a/internal/processing/account/block.go +++ b/internal/processing/account/block.go @@ -22,17 +22,17 @@ import ( "errors" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/paging" - "github.com/superseriousbusiness/gotosocial/internal/uris" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/ap" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // BlockCreate handles the creation of a block from requestingAccount to targetAccountID, either remote or local. @@ -137,7 +137,7 @@ func (p *Processor) BlocksGet( requestingAccount *gtsmodel.Account, page *paging.Page, ) (*apimodel.PageableResponse, gtserror.WithCode) { - blocks, err := p.state.DB.GetAccountBlocks(ctx, + blocks, err := p.state.DB.GetAccountBlocking(ctx, requestingAccount.ID, page, ) diff --git a/internal/processing/account/bookmarks.go b/internal/processing/account/bookmarks.go index d64108d3a..468c6ad62 100644 --- a/internal/processing/account/bookmarks.go +++ b/internal/processing/account/bookmarks.go @@ -21,13 +21,12 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/util" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // BookmarksGet returns a pageable response of statuses that are bookmarked by requestingAccount. @@ -75,11 +74,12 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode } // Convert the status. - item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil) + item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount) if err != nil { log.Errorf(ctx, "error converting bookmarked status to api: %s", err) continue } + items = append(items, item) } diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index 2618fdfc5..a45afe754 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -23,313 +23,313 @@ import ( "net" "time" - "codeberg.org/gruf/go-kv" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/util" + "codeberg.org/gruf/go-kv/v2" "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "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/messages" - "github.com/superseriousbusiness/gotosocial/internal/util" "golang.org/x/crypto/bcrypt" ) -const deleteSelectLimit = 50 +const deleteSelectLimit = 100 // Delete deletes an account, and all of that account's statuses, media, follows, notifications, etc etc etc. // The origin passed here should be either the ID of the account doing the delete (can be itself), or the ID of a domain block. -func (p *Processor) Delete( - ctx context.Context, - account *gtsmodel.Account, - origin string, -) gtserror.WithCode { - l := log.WithContext(ctx).WithFields(kv.Fields{ - {"username", account.Username}, - {"domain", account.Domain}, +// +// This delete function handles the case of both local and remote accounts, and processes side +// effects synchronously to not clog worker queues with potentially tens-of-thousands of requests. +func (p *Processor) Delete(ctx context.Context, account *gtsmodel.Account, origin string) error { + + // Prepare a new log entry for account delete. + log := log.WithContext(ctx).WithFields(kv.Fields{ + {"uri", account.URI}, + {"origin", origin}, }...) - l.Trace("beginning account delete process") - // Delete statuses *before* follows to ensure correct addressing - // of any outgoing fedi messages generated by deleting statuses. - if err := p.deleteAccountStatuses(ctx, account); err != nil { - l.Errorf("continuing after error during account delete: %v", err) - } + var err error - if err := p.deleteAccountFollows(ctx, account); err != nil { - l.Errorf("continuing after error during account delete: %v", err) - } + // Log operation start / stop. + log.Info("start account delete") + defer func() { + if err != nil { + log.Errorf("fatal error during account delete: %v", err) + } else { + log.Info("finished account delete") + } + }() - if err := p.deleteAccountBlocks(ctx, account); err != nil { - l.Errorf("continuing after error during account delete: %v", err) - } + // Delete statuses *before* anything else as for local + // accounts we need to federate out deletes, which relies + // on follows for addressing the appropriate accounts. + p.deleteAccountStatuses(ctx, &log, account) - if err := p.deleteAccountNotifications(ctx, account); err != nil { - l.Errorf("continuing after error during account delete: %v", err) - } + // Now delete relationships to / from account. + p.deleteAccountRelations(ctx, &log, account) - if err := p.deleteAccountPeripheral(ctx, account); err != nil { - l.Errorf("continuing after error during account delete: %v", err) - } + // Now delete any notifications to / from account. + p.deleteAccountNotifications(ctx, &log, account) + + // Delete other peripheral objects ownable / + // manageable by any local / remote account. + p.deleteAccountPeripheral(ctx, &log, account) if account.IsLocal() { // We delete tokens, applications and clients for // account as one of the last stages during deletion, // as other database models rely on these. - if err := p.deleteUserAndTokensForAccount(ctx, account); err != nil { - l.Errorf("continuing after error during account delete: %v", err) + if err = p.deleteUserAndTokensForAccount(ctx, &log, account); err != nil { + return err } } // To prevent the account being created again, - // stubbify it and update it in the db. - // The account will not be deleted, but it - // will become completely unusable. + // (which would cause horrible federation shenanigans), + // the account will be stubbed out to an unusable state + // with no identifying info remaining, but NOT deleted. columns := stubbifyAccount(account, origin) - if err := p.state.DB.UpdateAccount(ctx, account, columns...); err != nil { - return gtserror.NewErrorInternalError(err) + if err = p.state.DB.UpdateAccount(ctx, account, columns...); err != nil { + return gtserror.Newf("error stubbing out account: %v", err) } - l.Info("account delete process complete") return nil } -// deleteUserAndTokensForAccount deletes the gtsmodel.User and -// any OAuth tokens, applications, and Web Push subscriptions for the given account. -// -// Callers to this function should already have checked that -// this is a local account, or else it won't have a user associated -// with it, and this will fail. -func (p *Processor) deleteUserAndTokensForAccount(ctx context.Context, account *gtsmodel.Account) error { +func (p *Processor) deleteUserAndTokensForAccount( + ctx context.Context, + log *log.Entry, + account *gtsmodel.Account, +) error { + + // Fetch the associated user for account, on fail return + // early as all other parts of this func rely on this user. user, err := p.state.DB.GetUserByAccountID(ctx, account.ID) if err != nil { - return gtserror.Newf("db error getting user: %w", err) + return gtserror.Newf("error getting account user: %v", err) } - tokens := []*gtsmodel.Token{} - if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "user_id", Value: user.ID}}, &tokens); err != nil { - return gtserror.Newf("db error getting tokens: %w", err) + // Get list of applications managed by deleting user. + apps, err := p.state.DB.GetApplicationsManagedByUserID(ctx, + user.ID, + nil, // i.e. all + ) + if err != nil { + log.Errorf("error getting user applications: %v", err) } - for _, t := range tokens { - // Delete any OAuth clients associated with this token. - if err := p.state.DB.DeleteByID(ctx, t.ClientID, &[]*gtsmodel.Client{}); err != nil { - return gtserror.Newf("db error deleting client: %w", err) + // Delete each app and any tokens it had created + // (not necessarily owned by deleted account). + for _, app := range apps { + if err := p.state.DB.DeleteTokensByClientID(ctx, app.ClientID); err != nil { + log.Errorf("error deleting application token: %v", err) } - - // Delete any OAuth applications associated with this token. - if err := p.state.DB.DeleteApplicationByClientID(ctx, t.ClientID); err != nil { - return gtserror.Newf("db error deleting application: %w", err) + if err := p.state.DB.DeleteApplicationByID(ctx, app.ID); err != nil { + log.Errorf("error deleting user application: %v", err) } + } - // Delete the token itself. - if err := p.state.DB.DeleteByID(ctx, t.ID, t); err != nil { - return gtserror.Newf("db error deleting token: %w", err) - } + // Get any remaining access tokens owned by user. + tokens, err := p.state.DB.GetAccessTokens(ctx, + user.ID, + nil, // i.e. all + ) + if err != nil { + log.Errorf("error getting user access tokens: %v", err) } - if err := p.state.DB.DeleteWebPushSubscriptionsByAccountID(ctx, account.ID); err != nil { - return gtserror.Newf("db error deleting Web Push subscriptions: %w", err) + // Delete user access tokens. + for _, token := range tokens { + if err := p.state.DB.DeleteTokenByID(ctx, token.ID); err != nil { + log.Errorf("error deleting user access token: %v", err) + } } - columns, err := stubbifyUser(user) - if err != nil { - return gtserror.Newf("error stubbifying user: %w", err) + // Delete any web push subscriptions created by this local user account. + if err := p.state.DB.DeleteWebPushSubscriptionsByAccountID(ctx, account.ID); err != nil { + log.Errorf("error deleting account web push subscriptions: %v", err) } + // To prevent the user being created again, + // the user will be stubbed out to an unusable state + // with no identifying info remaining, but NOT deleted. + columns := stubbifyUser(user) if err := p.state.DB.UpdateUser(ctx, user, columns...); err != nil { - return gtserror.Newf("db error updating user: %w", err) + return gtserror.Newf("error stubbing out user: %w", err) } return nil } -// deleteAccountFollows deletes: -// - Follows targeting account. -// - Follow requests targeting account. -// - Follows created by account. -// - Follow requests created by account. -func (p *Processor) deleteAccountFollows(ctx context.Context, account *gtsmodel.Account) error { - // Delete follows targeting this account. - followedBy, err := p.state.DB.GetAccountFollowers(ctx, account.ID, nil) +func (p *Processor) deleteAccountRelations( + ctx context.Context, + log *log.Entry, + account *gtsmodel.Account, +) { + // Get a list of the follows targeting this account. + followedBy, err := p.state.DB.GetAccountFollowers(ctx, + account.ID, + nil, // i.e. all + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("db error getting follows targeting account %s: %w", account.ID, err) + log.Errorf("error getting account followed-bys: %v", err) } + // Delete these follows from database. for _, follow := range followedBy { if err := p.state.DB.DeleteFollowByID(ctx, follow.ID); err != nil { - return gtserror.Newf("db error unfollowing account followedBy: %w", err) + log.Errorf("error deleting account followed-by %s: %v", follow.URI, err) } } - // Delete follow requests targeting this account. - followRequestedBy, err := p.state.DB.GetAccountFollowRequests(ctx, account.ID, nil) + // Get a list of the follow requests targeting this account. + followRequestedBy, err := p.state.DB.GetAccountFollowRequests(ctx, + account.ID, + nil, // i.e. all + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("db error getting follow requests targeting account %s: %w", account.ID, err) + log.Errorf("error getting account follow-requested-bys: %v", err) } - for _, followRequest := range followRequestedBy { - if err := p.state.DB.DeleteFollowRequestByID(ctx, followRequest.ID); err != nil { - return gtserror.Newf("db error unfollowing account followRequestedBy: %w", err) + // Delete these follow requests from database. + for _, followReq := range followRequestedBy { + if err := p.state.DB.DeleteFollowRequestByID(ctx, followReq.ID); err != nil { + log.Errorf("error deleting account follow-requested-by %s: %v", followReq.URI, err) } } - var ( - // Use this slice to batch unfollow messages. - msgs = []*messages.FromClientAPI{} - - // To avoid checking if account is local over + over - // inside the subsequent loops, just generate static - // side effects function once now. - unfollowSideEffects = p.unfollowSideEffectsFunc(account.IsLocal()) + // Get a list of the blocks targeting this account. + blockedBy, err := p.state.DB.GetAccountBlockedBy(ctx, + account.ID, + nil, // i.e. all ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error getting account blocked-bys: %v", err) + } - // Delete follows originating from this account. - following, err := p.state.DB.GetAccountFollows(ctx, account.ID, nil) + // Delete these blocks from database. + for _, block := range blockedBy { + if err := p.state.DB.DeleteBlockByID(ctx, block.ID); err != nil { + log.Errorf("error deleting account blocked-by %s: %v", block.URI, err) + } + } + + // Get the follows originating from this account. + following, err := p.state.DB.GetAccountFollows(ctx, + account.ID, + nil, // i.e. all + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("db error getting follows owned by account %s: %w", account.ID, err) + log.Errorf("error getting account follows: %v", err) } - // For each follow owned by this account, unfollow - // and process side effects (noop if remote account). + // Delete these follows from database. for _, follow := range following { if err := p.state.DB.DeleteFollowByID(ctx, follow.ID); err != nil { - return gtserror.Newf("db error unfollowing account: %w", err) - } - if msg := unfollowSideEffects(ctx, account, follow); msg != nil { - // There was a side effect to process. - msgs = append(msgs, msg) + log.Errorf("error deleting account followed %s: %v", follow.URI, err) } } - // Delete follow requests originating from this account. - followRequesting, err := p.state.DB.GetAccountFollowRequesting(ctx, account.ID, nil) + // Get a list of the follow requests originating from this account. + followRequesting, err := p.state.DB.GetAccountFollowRequesting(ctx, + account.ID, + nil, // i.e. all + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("db error getting follow requests owned by account %s: %w", account.ID, err) + log.Errorf("error getting account follow-requests: %v", err) } - // For each follow owned by this account, unfollow - // and process side effects (noop if remote account). - for _, followRequest := range followRequesting { - if err := p.state.DB.DeleteFollowRequestByID(ctx, followRequest.ID); err != nil { - return gtserror.Newf("db error unfollowingRequesting account: %w", err) - } - - // Dummy out a follow so our side effects func - // has something to work with. This follow will - // never enter the db, it's just for convenience. - follow := >smodel.Follow{ - URI: followRequest.URI, - AccountID: followRequest.AccountID, - Account: followRequest.Account, - TargetAccountID: followRequest.TargetAccountID, - TargetAccount: followRequest.TargetAccount, + // Delete these follow requests from database. + for _, followReq := range followRequesting { + if err := p.state.DB.DeleteFollowRequestByID(ctx, followReq.ID); err != nil { + log.Errorf("error deleting account follow-request %s: %v", followReq.URI, err) } + } - if msg := unfollowSideEffects(ctx, account, follow); msg != nil { - // There was a side effect to process. - msgs = append(msgs, msg) - } + // Get the blocks originating from this account. + blocking, err := p.state.DB.GetAccountBlocking(ctx, + account.ID, + nil, // i.e. all + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error getting account blocks: %v", err) } - // Process accreted messages in serial. - for _, msg := range msgs { - if err := p.state.Workers.Client.Process(ctx, msg); err != nil { - log.Errorf( - ctx, - "error processing %s of %s during Delete of account %s: %v", - msg.APActivityType, msg.APObjectType, account.ID, err, - ) + // Delete these blocks from database. + for _, block := range blocking { + if err := p.state.DB.DeleteBlockByID(ctx, block.ID); err != nil { + log.Errorf("error deleting account block %s: %v", block.URI, err) } } - return nil -} - -func (p *Processor) unfollowSideEffectsFunc(local bool) func( - ctx context.Context, - account *gtsmodel.Account, - follow *gtsmodel.Follow, -) *messages.FromClientAPI { - if !local { - // Don't try to process side effects - // for accounts that aren't local. - return func( - _ context.Context, - _ *gtsmodel.Account, - _ *gtsmodel.Follow, - ) *messages.FromClientAPI { - // noop - return nil - } + // Delete all mutes targetting / originating from account. + if err := p.state.DB.DeleteAccountMutes(ctx, account.ID); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error deleting mutes to / from account: %v", err) } - return func( - ctx context.Context, - account *gtsmodel.Account, - follow *gtsmodel.Follow, - ) *messages.FromClientAPI { - if follow.TargetAccount == nil { - // TargetAccount seems to have gone; - // race condition? db corruption? - log. - WithContext(ctx). - WithField("follow", follow). - Warn("follow had no TargetAccount, likely race condition") - return nil + if account.IsLocal() { + // Process side-effects for deleting + // of account follows from local user. + for _, follow := range following { + p.processSideEffect(ctx, log, + ap.ActivityUndo, + ap.ActivityFollow, + follow, + account, + follow.TargetAccount, + ) } - if follow.TargetAccount.IsLocal() { - // No side effects - // for local unfollows. - return nil + // Process side-effects for deleting of account follow requests + // from local user. Though handled as though UNDO of a follow. + for _, followReq := range followRequesting { + p.processSideEffect(ctx, log, + ap.ActivityUndo, + ap.ActivityFollow, + >smodel.Follow{ + ID: followReq.ID, + URI: followReq.URI, + AccountID: followReq.AccountID, + Account: followReq.Account, + TargetAccountID: followReq.TargetAccountID, + TargetAccount: followReq.TargetAccount, + ShowReblogs: new(bool), + Notify: new(bool), + }, + account, + followReq.TargetAccount, + ) } - // There was a follow, process side effects. - return &messages.FromClientAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityUndo, - GTSModel: follow, - Origin: account, - Target: follow.TargetAccount, + // Process side-effects for deleting + // of account blocks from local user. + for _, block := range blocking { + p.processSideEffect(ctx, log, + ap.ActivityUndo, + ap.ActivityBlock, + block, + account, + block.TargetAccount, + ) } } } -func (p *Processor) deleteAccountBlocks(ctx context.Context, account *gtsmodel.Account) error { - if err := p.state.DB.DeleteAccountBlocks(ctx, account.ID); err != nil { - return gtserror.Newf("db error deleting account blocks for %s: %w", account.ID, err) - } - return nil -} - -// deleteAccountStatuses iterates through all statuses owned by -// the given account, passing each discovered status (and boosts -// thereof) to the processor workers for further processing. func (p *Processor) deleteAccountStatuses( ctx context.Context, + log *log.Entry, account *gtsmodel.Account, -) error { - // We'll select statuses 50 at a time so we don't wreck the db, - // and pass them through to the client api worker to handle. - // - // Deleting the statuses in this way also handles deleting the - // account's media attachments, mentions, and polls, since these - // are all attached to statuses. +) { + var maxID string - var ( - statuses []*gtsmodel.Status - err error - maxID string - msgs = []*messages.FromClientAPI{} - ) - -statusLoop: for { - // Page through account's statuses. - statuses, err = p.state.DB.GetAccountStatuses( - ctx, + // Page through deleting account's statuses. + statuses, err := p.state.DB.GetAccountStatuses( + gtscontext.SetBarebones(ctx), account.ID, deleteSelectLimit, false, @@ -340,12 +340,14 @@ statusLoop: false, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - // Make sure we don't have a real error. - return err + log.Errorf("error getting account statuses: %v", err) + return } if len(statuses) == 0 { - break statusLoop + // reached + // the end. + break } // Update next maxID from last status. @@ -356,140 +358,173 @@ statusLoop: status.Account = account // Look for any boosts of this status in DB. - // - // We put these in the msgs slice first so - // that they're handled first, before the - // parent status that's being boosted. - // - // Use a barebones context and just select the - // origin account separately. The rest will be - // populated later anyway, and we don't want to - // stop now because we couldn't get something. boosts, err := p.state.DB.GetStatusBoosts( gtscontext.SetBarebones(ctx), status.ID, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error fetching status boosts for %s: %w", status.ID, err) + log.Errorf("error getting status boosts for %s: %v", status.URI, err) + continue } // Prepare to Undo each boost. for _, boost := range boosts { + + // Fetch the owning account of this boost. boost.Account, err = p.state.DB.GetAccountByID( gtscontext.SetBarebones(ctx), boost.AccountID, ) - if err != nil { - log.Warnf( - ctx, - "db error getting owner %s of status boost %s: %v", - boost.AccountID, boost.ID, err, - ) + log.Errorf("error getting owner %s of status boost %s: %v", + boost.AccountURI, boost.URI, err) continue } - msgs = append(msgs, &messages.FromClientAPI{ - APObjectType: ap.ActivityAnnounce, - APActivityType: ap.ActivityUndo, - GTSModel: status, - Origin: boost.Account, - Target: account, - }) + // Process boost undo event. + p.processSideEffect(ctx, log, + ap.ActivityUndo, + ap.ActivityAnnounce, + boost, + account, + account, + ) } - // Now prepare to Delete status. - msgs = append(msgs, &messages.FromClientAPI{ - APObjectType: ap.ObjectNote, - APActivityType: ap.ActivityDelete, - GTSModel: status, - Origin: account, - Target: account, - }) + // Process status delete event. + p.processSideEffect(ctx, log, + ap.ActivityDelete, + ap.ObjectNote, + status, + account, + account, + ) } } +} - // Process accreted messages in serial. - for _, msg := range msgs { - if err := p.state.Workers.Client.Process(ctx, msg); err != nil { - log.Errorf( - ctx, - "error processing %s of %s during Delete of account %s: %v", - msg.APActivityType, msg.APObjectType, account.ID, err, - ) +func (p *Processor) deleteAccountNotifications( + ctx context.Context, + log *log.Entry, + account *gtsmodel.Account, +) { + if account.IsLocal() { + // Delete all types of notifications targeting this local account. + if err := p.state.DB.DeleteNotifications(ctx, nil, account.ID, ""); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error deleting notifications targeting account: %v", err) } } - return nil + // Delete all types of notifications originating from this account. + if err := p.state.DB.DeleteNotifications(ctx, nil, "", account.ID); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error deleting notifications originating from account: %v", err) + } } -func (p *Processor) deleteAccountNotifications(ctx context.Context, account *gtsmodel.Account) error { - // Delete all notifications of all types targeting given account. - if err := p.state.DB.DeleteNotifications(ctx, nil, account.ID, ""); err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting notifications targeting account: %w", err) - } +func (p *Processor) deleteAccountPeripheral( + ctx context.Context, + log *log.Entry, + account *gtsmodel.Account, +) { + if account.IsLocal() { + // Delete all bookmarks owned by given account, only for local. + if err := p.state.DB.DeleteStatusBookmarks(ctx, account.ID, ""); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error deleting bookmarks by account: %v", err) + } - // Delete all notifications of all types originating from given account. - if err := p.state.DB.DeleteNotifications(ctx, nil, "", account.ID); err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting notifications by account: %w", err) - } + // Delete all faves owned by given account, only for local. + if err := p.state.DB.DeleteStatusFaves(ctx, account.ID, ""); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error deleting faves by account: %v", err) + } - return nil -} + // Delete all conversations owned by given account, only for local. + // + // *Participated* conversations will be retained, leaving up to *their* owners. + if err := p.state.DB.DeleteConversationsByOwnerAccountID(ctx, account.ID); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error deleting conversations by account: %v", err) + } -func (p *Processor) deleteAccountPeripheral(ctx context.Context, account *gtsmodel.Account) error { - // Delete all bookmarks owned by given account. - if err := p.state.DB.DeleteStatusBookmarks(ctx, account.ID, ""); // nocollapse - err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting bookmarks by account: %w", err) - } + // Delete all followed tags owned by given account, only for local. + if err := p.state.DB.DeleteFollowedTagsByAccountID(ctx, account.ID); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + log.Errorf("error deleting followed tags by account: %v", err) + } - // Delete all bookmarks targeting given account. - if err := p.state.DB.DeleteStatusBookmarks(ctx, "", account.ID); // nocollapse - err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting bookmarks targeting account: %w", err) - } + // Delete stats model stored for given account, only for local. + if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil { + log.Errorf("error deleting stats for account: %v", err) + } - // Delete all faves owned by given account. - if err := p.state.DB.DeleteStatusFaves(ctx, account.ID, ""); // nocollapse - err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting faves by account: %w", err) + // Delete statuses scheduled by given account, only for local. + if err := p.state.DB.DeleteScheduledStatusesByAccountID(ctx, account.ID); err != nil { + log.Errorf("error deleting scheduled statuses for account: %v", err) + } } - // Delete all faves targeting given account. - if err := p.state.DB.DeleteStatusFaves(ctx, "", account.ID); // nocollapse + // Delete all bookmarks targeting given account, local and remote. + if err := p.state.DB.DeleteStatusBookmarks(ctx, "", account.ID); // nocollapse err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting faves targeting account: %w", err) + log.Errorf("error deleting bookmarks targeting account: %v", err) } - // TODO: add status mutes here when they're implemented. - - // Delete all conversations owned by given account. - // Conversations in which it has only participated will be retained; - // they can always be deleted by their owners. - if err := p.state.DB.DeleteConversationsByOwnerAccountID(ctx, account.ID); // nocollapse + // Delete all faves targeting given account, local and remote. + if err := p.state.DB.DeleteStatusFaves(ctx, "", account.ID); // nocollapse err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting conversations owned by account: %w", err) + log.Errorf("error deleting faves targeting account: %v", err) } - // Delete all poll votes owned by given account. + // Delete all poll votes owned by given account, local and remote. if err := p.state.DB.DeletePollVotesByAccountID(ctx, account.ID); // nocollapse err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting poll votes by account: %w", err) + log.Errorf("error deleting poll votes by account: %v", err) } - // Delete all followed tags owned by given account. - if err := p.state.DB.DeleteFollowedTagsByAccountID(ctx, account.ID); // nocollapse + // Delete all interaction requests from given account, local and remote. + if err := p.state.DB.DeleteInteractionRequestsByInteractingAccountID(ctx, account.ID); // nocollapse err != nil && !errors.Is(err, db.ErrNoEntries) { - return gtserror.Newf("error deleting followed tags by account: %w", err) + log.Errorf("error deleting interaction requests by account: %v", err) } +} - // Delete account stats model. - if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil { - return gtserror.Newf("error deleting stats for account: %w", err) +// processSideEffect will process the given side effect details, +// with appropriate worker depending on if origin is local / remote. +func (p *Processor) processSideEffect( + ctx context.Context, + log *log.Entry, + activityType string, + objectType string, + gtsModel any, + origin *gtsmodel.Account, + target *gtsmodel.Account, +) { + if origin.IsLocal() { + // Process side-effect through our client API as this is a local account. + if err := p.state.Workers.Client.Process(ctx, &messages.FromClientAPI{ + APActivityType: activityType, + APObjectType: objectType, + GTSModel: gtsModel, + Origin: origin, + Target: target, + }); err != nil { + log.Errorf("error processing %s of %s during local account %s delete: %v", activityType, objectType, origin.ID, err) + } + } else { + // Process side-effect through our fedi API as this is a remote account. + if err := p.state.Workers.Federator.Process(ctx, &messages.FromFediAPI{ + APActivityType: activityType, + APObjectType: objectType, + GTSModel: gtsModel, + Requesting: origin, + Receiving: target, + }); err != nil { + log.Errorf("error processing %s of %s during local account %s delete: %v", activityType, objectType, origin.ID, err) + } } - - return nil } // stubbifyAccount renders the given account as a stub, @@ -519,7 +554,7 @@ func stubbifyAccount(account *gtsmodel.Account, origin string) []string { account.Fields = nil account.Note = "" account.NoteRaw = "" - account.Memorial = util.Ptr(false) + account.MemorializedAt = never account.AlsoKnownAsURIs = nil account.MovedToURI = "" account.Discoverable = util.Ptr(false) @@ -537,7 +572,7 @@ func stubbifyAccount(account *gtsmodel.Account, origin string) []string { "fields", "note", "note_raw", - "memorial", + "memorialized_at", "also_known_as_uris", "moved_to_uri", "discoverable", @@ -558,15 +593,21 @@ func stubbifyAccount(account *gtsmodel.Account, origin string) []string { // // For caller's convenience, this function returns the db // names of all columns that are updated by it. -func stubbifyUser(user *gtsmodel.User) ([]string, error) { +func stubbifyUser(user *gtsmodel.User) []string { uuid, err := uuid.New().MarshalBinary() if err != nil { - return nil, err + // this should never happen, + // it indicates /dev/random + // is misbehaving. + panic(err) } dummyPassword, err := bcrypt.GenerateFromPassword(uuid, bcrypt.DefaultCost) if err != nil { - return nil, err + // this should never happen, + // it indicates /dev/random + // is misbehaving. + panic(err) } never := time.Time{} @@ -591,5 +632,5 @@ func stubbifyUser(user *gtsmodel.User) ([]string, error) { "confirmation_sent_at", "reset_password_token", "reset_password_sent_at", - }, nil + } } diff --git a/internal/processing/account/delete_test.go b/internal/processing/account/delete_test.go index ee6fe1dfc..93bc1f9c4 100644 --- a/internal/processing/account/delete_test.go +++ b/internal/processing/account/delete_test.go @@ -18,13 +18,12 @@ package account_test import ( - "context" "net" "testing" "time" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) type AccountDeleteTestSuite struct { @@ -32,7 +31,7 @@ type AccountDeleteTestSuite struct { } func (suite *AccountDeleteTestSuite) TestAccountDeleteLocal() { - ctx := context.Background() + ctx := suite.T().Context() // Keep a reference around to the original account // and user, before the delete was processed. @@ -64,7 +63,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeleteLocal() { suite.Nil(updatedAccount.Fields) suite.Zero(updatedAccount.Note) suite.Zero(updatedAccount.NoteRaw) - suite.False(*updatedAccount.Memorial) + suite.Zero(updatedAccount.MemorializedAt) suite.Empty(updatedAccount.AlsoKnownAsURIs) suite.False(*updatedAccount.Discoverable) suite.WithinDuration(time.Now(), updatedAccount.SuspendedAt, 1*time.Minute) diff --git a/internal/processing/account/export.go b/internal/processing/account/export.go index 68cc17b6d..4f66a8229 100644 --- a/internal/processing/account/export.go +++ b/internal/processing/account/export.go @@ -21,10 +21,10 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // ExportStats returns the requester's export stats, @@ -120,7 +120,7 @@ func (p *Processor) ExportBlocks( ctx context.Context, requester *gtsmodel.Account, ) ([][]string, gtserror.WithCode) { - blocks, err := p.state.DB.GetAccountBlocks(ctx, requester.ID, nil) + blocks, err := p.state.DB.GetAccountBlocking(ctx, requester.ID, nil) if err != nil && !errors.Is(err, db.ErrNoEntries) { err = gtserror.Newf("db error getting blocks: %w", err) return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/processing/account/follow.go b/internal/processing/account/follow.go index 59de8834b..91955eaa7 100644 --- a/internal/processing/account/follow.go +++ b/internal/processing/account/follow.go @@ -21,16 +21,16 @@ import ( "context" "errors" - "github.com/superseriousbusiness/gotosocial/internal/ap" - 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/id" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/uris" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/ap" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // FollowCreate handles a follow request to an account, either remote or local. @@ -82,10 +82,7 @@ func (p *Processor) FollowCreate(ctx context.Context, requestingAccount *gtsmode // Neither follows nor follow requests, so // create and store a new follow request. - followID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } + followID := id.NewRandomULID() followURI := uris.GenerateURIForFollow(requestingAccount.Username, followID) fr := >smodel.FollowRequest{ @@ -258,13 +255,9 @@ func (p *Processor) unfollow(ctx context.Context, requestingAccount *gtsmodel.Ac msgs = append(msgs, &messages.FromClientAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityUndo, - GTSModel: >smodel.Follow{ - AccountID: requestingAccount.ID, - TargetAccountID: targetAccount.ID, - URI: follow.URI, - }, - Origin: requestingAccount, - Target: targetAccount, + GTSModel: follow, + Origin: requestingAccount, + Target: targetAccount, }) } @@ -294,9 +287,13 @@ func (p *Processor) unfollow(ctx context.Context, requestingAccount *gtsmodel.Ac msgs = append(msgs, &messages.FromClientAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityUndo, + // Dummy out a follow to undo, + // based on the follow request. GTSModel: >smodel.Follow{ AccountID: requestingAccount.ID, + Account: requestingAccount, TargetAccountID: targetAccount.ID, + TargetAccount: targetAccount, URI: followReq.URI, }, Origin: requestingAccount, diff --git a/internal/processing/account/follow_request.go b/internal/processing/account/follow_request.go index 6f6c7ba2d..88cadd7d0 100644 --- a/internal/processing/account/follow_request.go +++ b/internal/processing/account/follow_request.go @@ -21,13 +21,13 @@ import ( "context" "errors" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/ap" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) // FollowRequestAccept handles the accepting of a follow request from the sourceAccountID to the requestingAccount (the currently authorized account). @@ -117,3 +117,42 @@ func (p *Processor) FollowRequestsGet(ctx context.Context, requestingAccount *gt Prev: page.Prev(lo, hi), }), nil } + +// OutgoingFollowRequestsGet fetches a list of the accounts with a pending follow request originating from the given requestingAccount (the currently authorized account). +func (p *Processor) OutgoingFollowRequestsGet(ctx context.Context, requestingAccount *gtsmodel.Account, page *paging.Page) (*apimodel.PageableResponse, gtserror.WithCode) { + // Fetch follow requests originating from the given requesting account model. + followRequests, err := p.state.DB.GetAccountFollowRequesting(ctx, requestingAccount.ID, page) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError(err) + } + + // Check for empty response. + count := len(followRequests) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := followRequests[count-1].ID + hi := followRequests[0].ID + + // Func to fetch follow source at index. + getIdx := func(i int) *gtsmodel.Account { + return followRequests[i].TargetAccount + } + + // Get a filtered slice of public API account models. + items := p.c.GetVisibleAPIAccountsPaged(ctx, + requestingAccount, + getIdx, + count, + ) + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/follow_requests/outgoing", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} diff --git a/internal/processing/account/follow_test.go b/internal/processing/account/follow_test.go index 9ea8ce1b8..d68d8d065 100644 --- a/internal/processing/account/follow_test.go +++ b/internal/processing/account/follow_test.go @@ -18,14 +18,13 @@ package account_test import ( - "context" "testing" "time" + "code.superseriousbusiness.org/gotosocial/internal/ap" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/util" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/util" ) type FollowTestSuite struct { @@ -33,7 +32,7 @@ type FollowTestSuite struct { } func (suite *FollowTestSuite) TestUpdateExistingFollowChangeBoth() { - ctx := context.Background() + ctx := suite.T().Context() requestingAccount := suite.testAccounts["local_account_1"] targetAccount := suite.testAccounts["admin_account"] @@ -54,7 +53,7 @@ func (suite *FollowTestSuite) TestUpdateExistingFollowChangeBoth() { } func (suite *FollowTestSuite) TestUpdateExistingFollowChangeNotifyIgnoreReblogs() { - ctx := context.Background() + ctx := suite.T().Context() requestingAccount := suite.testAccounts["local_account_1"] targetAccount := suite.testAccounts["admin_account"] @@ -74,7 +73,7 @@ func (suite *FollowTestSuite) TestUpdateExistingFollowChangeNotifyIgnoreReblogs( } func (suite *FollowTestSuite) TestUpdateExistingFollowChangeNotifySetReblogs() { - ctx := context.Background() + ctx := suite.T().Context() requestingAccount := suite.testAccounts["local_account_1"] targetAccount := suite.testAccounts["admin_account"] @@ -95,7 +94,7 @@ func (suite *FollowTestSuite) TestUpdateExistingFollowChangeNotifySetReblogs() { } func (suite *FollowTestSuite) TestUpdateExistingFollowChangeNothing() { - ctx := context.Background() + ctx := suite.T().Context() requestingAccount := suite.testAccounts["local_account_1"] targetAccount := suite.testAccounts["admin_account"] @@ -115,7 +114,7 @@ func (suite *FollowTestSuite) TestUpdateExistingFollowChangeNothing() { } func (suite *FollowTestSuite) TestUpdateExistingFollowSetNothing() { - ctx := context.Background() + ctx := suite.T().Context() requestingAccount := suite.testAccounts["local_account_1"] targetAccount := suite.testAccounts["admin_account"] @@ -133,7 +132,7 @@ func (suite *FollowTestSuite) TestUpdateExistingFollowSetNothing() { } func (suite *FollowTestSuite) TestFollowRequestLocal() { - ctx := context.Background() + ctx := suite.T().Context() requestingAccount := suite.testAccounts["admin_account"] targetAccount := suite.testAccounts["local_account_2"] diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go index eac0f0c3f..7f401ed57 100644 --- a/internal/processing/account/get.go +++ b/internal/processing/account/get.go @@ -22,12 +22,12 @@ import ( "errors" "net/url" - 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" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" ) // Get processes the given request for account information. @@ -66,10 +66,13 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account // Perform a last-minute fetch of target account to // ensure remote account header / avatar is cached. + // + // Match by URI only. latest, _, err := p.federator.GetAccountByURI( gtscontext.SetFastFail(ctx), requestingAccount.Username, targetAccountURI, + false, ) if err != nil { log.Errorf(ctx, "error fetching latest target account: %v", err) @@ -109,7 +112,7 @@ func (p *Processor) GetWeb(ctx context.Context, username string) (*apimodel.WebA return nil, gtserror.NewErrorInternalError(err) } - webAccount, err := p.converter.AccountToWebAccount(ctx, targetAccount) + webAccount, err := p.converter.AccountToWebAccount(ctx, targetAccount, nil) if err != nil { err := gtserror.Newf("error converting account: %w", err) return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/processing/account/import.go b/internal/processing/account/import.go index 68e843cfa..55415b4c2 100644 --- a/internal/processing/account/import.go +++ b/internal/processing/account/import.go @@ -24,10 +24,10 @@ import ( "fmt" "mime/multipart" - 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" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" ) func (p *Processor) ImportData( @@ -55,6 +55,14 @@ func (p *Processor) ImportData( overwrite, ) + case "mutes": + return p.importMutes( + ctx, + requester, + data, + overwrite, + ) + default: const text = "import type not yet supported" return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) @@ -290,7 +298,7 @@ func importBlocksAsyncF( err error ) - prevBlocks, err = p.state.DB.GetAccountBlocks(ctx, requester.ID, nil) + prevBlocks, err = p.state.DB.GetAccountBlocking(ctx, requester.ID, nil) if err != nil { log.Errorf(ctx, "db error getting blocks: %v", err) return @@ -377,3 +385,150 @@ func importBlocksAsyncF( } } } + +func (p *Processor) importMutes( + ctx context.Context, + requester *gtsmodel.Account, + mutesData *multipart.FileHeader, + overwrite bool, +) gtserror.WithCode { + file, err := mutesData.Open() + if err != nil { + err := fmt.Errorf("error opening mutes data file: %w", err) + return gtserror.NewErrorBadRequest(err, err.Error()) + } + defer file.Close() + + // Parse records out of the file. + records, err := csv.NewReader(file).ReadAll() + if err != nil { + err := fmt.Errorf("error reading mutes data file: %w", err) + return gtserror.NewErrorBadRequest(err, err.Error()) + } + + // Convert the records into a slice of barebones mutes. + // + // Only TargetAccount.Username, TargetAccount.Domain, + // and Notifications will be set on each mute. + mutes, err := p.converter.CSVToMutes(ctx, records) + if err != nil { + err := fmt.Errorf("error converting records to mutes: %w", err) + return gtserror.NewErrorBadRequest(err, err.Error()) + } + + // Do remaining processing of this import asynchronously. + f := importMutesAsyncF(p, requester, mutes, overwrite) + p.state.Workers.Processing.Queue.Push(f) + + return nil +} + +func importMutesAsyncF( + p *Processor, + requester *gtsmodel.Account, + mutes []*gtsmodel.UserMute, + overwrite bool, +) func(context.Context) { + return func(ctx context.Context) { + // Map used to store wanted + // mute targets (if overwriting). + var wantedMutes map[string]struct{} + + if overwrite { + // If we're overwriting, we need to get current + // mutes owned by requester *before* making any + // changes, so that we can remove unwanted mutes + // after we've created new ones. + var ( + prevMutes []*gtsmodel.UserMute + err error + ) + + prevMutes, err = p.state.DB.GetAccountMutes(ctx, requester.ID, nil) + if err != nil { + log.Errorf(ctx, "db error getting mutes: %v", err) + return + } + + // Initialize new mutes map. + wantedMutes = make(map[string]struct{}, len(mutes)) + + // Once we've created (or tried to create) + // the required mutes, go through previous + // mutes and remove unwanted ones. + defer func() { + for _, prev := range prevMutes { + username := prev.TargetAccount.Username + domain := prev.TargetAccount.Domain + + _, wanted := wantedMutes[username+"@"+domain] + if wanted { + // Leave this + // one alone. + continue + } + + if _, errWithCode := p.MuteRemove( + ctx, + requester, + prev.TargetAccountID, + ); errWithCode != nil { + log.Errorf(ctx, "could not unmute account: %v", errWithCode.Unwrap()) + continue + } + } + }() + } + + // Go through the mutes parsed from CSV + // file, and create / update each one. + for _, mute := range mutes { + var ( + // Username of the target. + username = mute.TargetAccount.Username + + // Domain of the target. + // Empty for our domain. + domain = mute.TargetAccount.Domain + ) + + if overwrite { + // We'll be overwriting, so store + // this new mute in our handy map. + wantedMutes[username+"@"+domain] = struct{}{} + } + + // Get the target account, dereferencing it if necessary. + targetAcct, _, err := p.federator.Dereferencer.GetAccountByUsernameDomain( + ctx, + // Provide empty request user to use the + // instance account to deref the account. + // + // It's pointless to make lots of calls + // to a remote from an account that's about + // to mute that account. + "", + username, + domain, + ) + if err != nil { + log.Errorf(ctx, "could not retrieve account: %v", err) + continue + } + + // Use the processor's MuteCreate function + // to create or update the mute. This takes + // account of existing mutes, and also sends + // the mute to the FromClientAPI processor. + if _, errWithCode := p.MuteCreate( + ctx, + requester, + targetAcct.ID, + &apimodel.UserMuteCreateUpdateRequest{Notifications: mute.Notifications}, + ); errWithCode != nil { + log.Errorf(ctx, "could not mute account: %v", errWithCode.Unwrap()) + continue + } + } + } +} diff --git a/internal/processing/account/interactionpolicies.go b/internal/processing/account/interactionpolicies.go index e02b43e9e..d581112a6 100644 --- a/internal/processing/account/interactionpolicies.go +++ b/internal/processing/account/interactionpolicies.go @@ -21,10 +21,10 @@ import ( "cmp" "context" - 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/typeutils" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) func (p *Processor) DefaultInteractionPoliciesGet( @@ -89,10 +89,10 @@ func (p *Processor) DefaultInteractionPoliciesGet( } return &apimodel.DefaultPolicies{ - Direct: *directAPI, - Private: *privateAPI, - Unlisted: *unlistedAPI, - Public: *publicAPI, + Direct: directAPI, + Private: privateAPI, + Unlisted: unlistedAPI, + Public: publicAPI, }, nil } diff --git a/internal/processing/account/lists.go b/internal/processing/account/lists.go index 04cf4ca73..be6c86d47 100644 --- a/internal/processing/account/lists.go +++ b/internal/processing/account/lists.go @@ -22,12 +22,12 @@ import ( "errors" "fmt" - 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" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" ) // ListsGet returns all lists owned by requestingAccount, which contain a follow for targetAccountID. diff --git a/internal/processing/account/move.go b/internal/processing/account/move.go index 44f8da268..58db5d452 100644 --- a/internal/processing/account/move.go +++ b/internal/processing/account/move.go @@ -25,23 +25,24 @@ import ( "slices" "time" - "github.com/superseriousbusiness/gotosocial/internal/ap" - 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" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/ap" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/federation/dereferencing" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "codeberg.org/gruf/go-byteutil" "golang.org/x/crypto/bcrypt" ) func (p *Processor) MoveSelf( ctx context.Context, - authed *oauth.Auth, + authed *apiutil.Auth, form *apimodel.AccountMoveRequest, ) gtserror.WithCode { // Ensure valid MovedToURI. @@ -70,8 +71,8 @@ func (p *Processor) MoveSelf( } if err := bcrypt.CompareHashAndPassword( - []byte(authed.User.EncryptedPassword), - []byte(form.Password), + byteutil.S2B(authed.User.EncryptedPassword), + byteutil.S2B(form.Password), ); err != nil { const text = "invalid password provided in Move request" return gtserror.NewErrorBadRequest(errors.New(text), text) @@ -119,11 +120,15 @@ func (p *Processor) MoveSelf( unlock := p.state.ProcessingLocks.Lock(lockKey) defer unlock() - // Ensure we have a valid, up-to-date representation of the target account. + // Ensure we have a valid, up-to-date + // representation of the target account. + // + // Match by uri only. targetAcct, targetAcctable, err = p.federator.GetAccountByURI( ctx, originAcct.Username, targetAcctURI, + false, ) if err != nil { const text = "error dereferencing moved_to_uri" diff --git a/internal/processing/account/move_test.go b/internal/processing/account/move_test.go index 9d06829ca..55d6548db 100644 --- a/internal/processing/account/move_test.go +++ b/internal/processing/account/move_test.go @@ -18,14 +18,14 @@ package account_test import ( - "context" "testing" "time" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/oauth" "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 { @@ -33,7 +33,7 @@ type MoveTestSuite struct { } func (suite *MoveTestSuite) TestMoveAccountOK() { - ctx := context.Background() + ctx := suite.T().Context() // Copy zork. requestingAcct := new(gtsmodel.Account) @@ -56,7 +56,7 @@ func (suite *MoveTestSuite) TestMoveAccountOK() { // Trigger move from zork to admin. if err := suite.accountProcessor.MoveSelf( ctx, - &oauth.Auth{ + &apiutil.Auth{ Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]), Application: suite.testApplications["local_account_1"], User: suite.testUsers["local_account_1"], @@ -105,7 +105,7 @@ func (suite *MoveTestSuite) TestMoveAccountOK() { } func (suite *MoveTestSuite) TestMoveAccountNotAliased() { - ctx := context.Background() + ctx := suite.T().Context() // Copy zork. requestingAcct := new(gtsmodel.Account) @@ -120,7 +120,7 @@ func (suite *MoveTestSuite) TestMoveAccountNotAliased() { // not aliased back to zork. err := suite.accountProcessor.MoveSelf( ctx, - &oauth.Auth{ + &apiutil.Auth{ Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]), Application: suite.testApplications["local_account_1"], User: suite.testUsers["local_account_1"], @@ -135,7 +135,7 @@ func (suite *MoveTestSuite) TestMoveAccountNotAliased() { } func (suite *MoveTestSuite) TestMoveAccountBadPassword() { - ctx := context.Background() + ctx := suite.T().Context() // Copy zork. requestingAcct := new(gtsmodel.Account) @@ -150,7 +150,7 @@ func (suite *MoveTestSuite) TestMoveAccountBadPassword() { // not aliased back to zork. err := suite.accountProcessor.MoveSelf( ctx, - &oauth.Auth{ + &apiutil.Auth{ Token: oauth.DBTokenToToken(suite.testTokens["local_account_1"]), Application: suite.testApplications["local_account_1"], User: suite.testUsers["local_account_1"], diff --git a/internal/processing/account/mute.go b/internal/processing/account/mute.go index 00bb9dd22..43dc45497 100644 --- a/internal/processing/account/mute.go +++ b/internal/processing/account/mute.go @@ -22,14 +22,14 @@ import ( "errors" "time" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/paging" - "github.com/superseriousbusiness/gotosocial/internal/util" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // MuteCreate handles the creation or updating of a mute from requestingAccount to targetAccountID. diff --git a/internal/processing/account/note.go b/internal/processing/account/note.go index 7606c1a91..231bb2ed8 100644 --- a/internal/processing/account/note.go +++ b/internal/processing/account/note.go @@ -20,10 +20,10 @@ package account import ( "context" - 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/id" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" ) // PutNote updates the requesting account's private note on the target account. diff --git a/internal/processing/account/relationships.go b/internal/processing/account/relationships.go index 53d2ee3c7..6a82e67a4 100644 --- a/internal/processing/account/relationships.go +++ b/internal/processing/account/relationships.go @@ -21,11 +21,11 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/paging" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) // FollowersGet fetches a list of the target account's followers. diff --git a/internal/processing/account/rss.go b/internal/processing/account/rss.go index 22ba0fe42..d6f367566 100644 --- a/internal/processing/account/rss.go +++ b/internal/processing/account/rss.go @@ -20,21 +20,19 @@ package account import ( "context" "errors" - "fmt" "time" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/paging" "github.com/gorilla/feeds" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -const ( - rssFeedLength = 20 -) +var never time.Time -type GetRSSFeed func() (string, gtserror.WithCode) +type GetRSSFeed func() (*feeds.Feed, gtserror.WithCode) // GetRSSFeedForUsername returns a function to return the RSS feed of a local account // with the given username, and the last-modified time (time that the account last @@ -45,33 +43,30 @@ type GetRSSFeed func() (string, gtserror.WithCode) // // If the account has not yet posted an RSS-eligible status, the returned last-modified // time will be zero, and the GetRSSFeed func will return a valid RSS xml with no items. -func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) (GetRSSFeed, time.Time, gtserror.WithCode) { - var ( - never = time.Time{} - ) +func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string, page *paging.Page) (GetRSSFeed, time.Time, gtserror.WithCode) { + // Fetch local (i.e. empty domain) account from database by username. account, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, "") if err != nil { - if errors.Is(err, db.ErrNoEntries) { - // Simply no account with this username. - err = gtserror.New("account not found") - return nil, never, gtserror.NewErrorNotFound(err) - } - - // Real db error. - err = gtserror.Newf("db error getting account %s: %w", username, err) + err := gtserror.Newf("db error getting account %s: %w", username, err) return nil, never, gtserror.NewErrorInternalError(err) } + // Check if exists. + if account == nil { + err := gtserror.New("account not found") + return nil, never, gtserror.NewErrorNotFound(err) + } + // Ensure account has rss feed enabled. if !*account.Settings.EnableRSS { - err = gtserror.New("account RSS feed not enabled") + err := gtserror.New("account RSS feed not enabled") return nil, never, gtserror.NewErrorNotFound(err) } - // Ensure account stats populated. + // Ensure account stats populated for last status fetch information. if err := p.state.DB.PopulateAccountStats(ctx, account); err != nil { - err = gtserror.Newf("db error getting account stats %s: %w", username, err) + err := gtserror.Newf("db error getting account stats %s: %w", username, err) return nil, never, gtserror.NewErrorInternalError(err) } @@ -80,17 +75,45 @@ func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) // eligible to appear in the RSS feed; that's fine. lastPostAt := account.Stats.LastStatusAt - return func() (string, gtserror.WithCode) { - // Assemble author namestring once only. - author := "@" + account.Username + "@" + config.GetAccountDomain() + return func() (*feeds.Feed, gtserror.WithCode) { + var image *feeds.Image + + // Assemble author namestring. + author := "@" + account.Username + + "@" + config.GetAccountDomain() - // Derive image/thumbnail for this account (may be nil). - image, errWithCode := p.rssImageForAccount(ctx, account, author) - if errWithCode != nil { - return "", errWithCode + // Check if account has an avatar media attachment. + if id := account.AvatarMediaAttachmentID; id != "" { + if account.AvatarMediaAttachment == nil { + var err error + + // Populate the account's avatar media attachment from database by its ID. + account.AvatarMediaAttachment, err = p.state.DB.GetAttachmentByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting account avatar: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + // If avatar is found, use as feed image. + if account.AvatarMediaAttachment != nil { + image = &feeds.Image{ + Title: "Avatar for " + author, + Url: account.AvatarMediaAttachment.Thumbnail.URL, + Link: account.URL, + } + } } + // Start creating feed. feed := &feeds.Feed{ + // we specifcally do not set the author, as a lot + // of feed readers rely on the RSS standard of the + // author being an email with optional name. but + // our @username@domain identifiers break this. + // + // attribution is handled in the title/description. + Title: "Posts from " + author, Description: "Posts from " + author, Link: &feeds.Link{Href: account.URL}, @@ -106,7 +129,7 @@ func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) // since we already know there's no eligible statuses. if lastPostAt.IsZero() { feed.Updated = account.CreatedAt - return stringifyFeed(feed) + return feed, nil } // Account has posted at least one status that's @@ -115,65 +138,50 @@ func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) // Reuse the lastPostAt value for feed.Updated. feed.Updated = lastPostAt - // Retrieve latest statuses as they'd be shown on the web view of the account profile. - statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account, rssFeedLength, "") + // Retrieve latest statuses as they'd be shown + // on the web view of the account profile. + // + // Take into account whether the user wants + // their web view laid out in gallery mode. + mediaOnly := (account.Settings != nil && + account.Settings.WebLayout == gtsmodel.WebLayoutGallery) + statuses, err := p.state.DB.GetAccountWebStatuses( + ctx, + account, + page, + mediaOnly, + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = fmt.Errorf("db error getting account web statuses: %w", err) - return "", gtserror.NewErrorInternalError(err) + err := gtserror.Newf("db error getting account web statuses: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Check for no statuses. + if len(statuses) == 0 { + return feed, nil } + // Get next / prev paging parameters. + lo := statuses[len(statuses)-1].ID + hi := statuses[0].ID + next := page.Next(lo, hi) + prev := page.Prev(lo, hi) + // Add each status to the rss feed. for _, status := range statuses { item, err := p.converter.StatusToRSSItem(ctx, status) if err != nil { - err = gtserror.Newf("error converting status to feed item: %w", err) - return "", gtserror.NewErrorInternalError(err) + err := gtserror.Newf("error converting status to feed item: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - feed.Add(item) } - return stringifyFeed(feed) - }, lastPostAt, nil -} - -func (p *Processor) rssImageForAccount(ctx context.Context, account *gtsmodel.Account, author string) (*feeds.Image, gtserror.WithCode) { - if account.AvatarMediaAttachmentID == "" { - // No image, no problem! - return nil, nil - } + // TODO: when we have some manner of supporting + // atom:link in RSS (and Atom), set the paging + // parameters for next / prev feed pages here. + _, _ = next, prev - // Ensure account avatar attachment populated. - if account.AvatarMediaAttachment == nil { - var err error - account.AvatarMediaAttachment, err = p.state.DB.GetAttachmentByID(ctx, account.AvatarMediaAttachmentID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - // No attachment found with this ID (race condition?). - return nil, nil - } - - // Real db error. - err = gtserror.Newf("db error fetching avatar media attachment: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - } - - return &feeds.Image{ - Url: account.AvatarMediaAttachment.Thumbnail.URL, - Title: "Avatar for " + author, - Link: account.URL, - }, nil -} - -func stringifyFeed(feed *feeds.Feed) (string, gtserror.WithCode) { - // Stringify the feed. Even with no statuses, - // this will still produce valid rss xml. - rss, err := feed.ToRss() - if err != nil { - err := gtserror.Newf("error converting feed to rss string: %w", err) - return "", gtserror.NewErrorInternalError(err) - } - - return rss, nil + return feed, nil + }, lastPostAt, nil } diff --git a/internal/processing/account/rss_test.go b/internal/processing/account/rss_test.go index 5606151c2..75aa20891 100644 --- a/internal/processing/account/rss_test.go +++ b/internal/processing/account/rss_test.go @@ -18,9 +18,11 @@ package account_test import ( - "context" "testing" + "time" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "github.com/gorilla/feeds" "github.com/stretchr/testify/suite" ) @@ -29,13 +31,8 @@ type GetRSSTestSuite struct { } func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() { - getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "admin") - suite.NoError(err) - suite.EqualValues(1634726497, lastModified.Unix()) - - feed, err := getFeed() - suite.NoError(err) - suite.Equal(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> + suite.testGetFeedSerializedAs("admin", &paging.Page{Limit: 20}, (*feeds.Feed).ToRss, 1634726497, + `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> <channel> <title>Posts from @admin@localhost:8080</title> <link>http://localhost:8080/@admin</link> @@ -43,11 +40,10 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() { <pubDate>Wed, 20 Oct 2021 10:41:37 +0000</pubDate> <lastBuildDate>Wed, 20 Oct 2021 10:41:37 +0000</lastBuildDate> <item> - <title>open to see some puppies</title> + <title>open to see some <strong>puppies</strong></title> <link>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</link> <description>@admin@localhost:8080 made a new post: "🐕🐕🐕🐕🐕"</description> - <content:encoded><![CDATA[🐕🐕🐕🐕🐕]]></content:encoded> - <author>@admin@localhost:8080</author> + <content:encoded><![CDATA[<p>🐕🐕🐕🐕🐕</p>]]></content:encoded> <guid isPermaLink="true">http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</guid> <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate> <source>http://localhost:8080/@admin/feed.rss</source> @@ -56,25 +52,158 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() { <title>hello world! #welcome ! first post on the instance :rainbow: !</title> <link>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</link> <description>@admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"</description> - <content:encoded><![CDATA[hello world! #welcome ! first post on the instance <img src="http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png" title=":rainbow:" alt=":rainbow:" width="25" height="25" /> !]]></content:encoded> - <author>@admin@localhost:8080</author> + <content:encoded><![CDATA[<p>hello world! <a href="http://localhost:8080/tags/welcome" class="mention hashtag" rel="tag nofollow noreferrer noopener" target="_blank">#<span>welcome</span></a> ! first post on the instance <img src="http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png" title=":rainbow:" alt=":rainbow:" width="25" height="25" /> !</p>]]></content:encoded> <enclosure url="http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg" length="62529" type="image/jpeg"></enclosure> <guid isPermaLink="true">http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</guid> <pubDate>Wed, 20 Oct 2021 11:36:45 +0000</pubDate> <source>http://localhost:8080/@admin/feed.rss</source> </item> </channel> -</rss>`, feed) +</rss>`) +} + +func (suite *GetRSSTestSuite) TestGetAccountAtomAdmin() { + suite.testGetFeedSerializedAs("admin", &paging.Page{Limit: 20}, (*feeds.Feed).ToAtom, 1634726497, + `<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"> + <title>Posts from @admin@localhost:8080</title> + <id>http://localhost:8080/@admin</id> + <updated>2021-10-20T10:41:37Z</updated> + <subtitle>Posts from @admin@localhost:8080</subtitle> + <link href="http://localhost:8080/@admin"></link> + <entry> + <title>open to see some <strong>puppies</strong></title> + <updated>2021-10-20T12:36:45Z</updated> + <id>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</id> + <content type="html"><p>🐕🐕🐕🐕🐕</p></content> + <link href="http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37" rel="alternate"></link> + <summary type="html">@admin@localhost:8080 made a new post: "🐕🐕🐕🐕🐕"</summary> + </entry> + <entry> + <title>hello world! #welcome ! first post on the instance :rainbow: !</title> + <updated>2021-10-20T11:36:45Z</updated> + <id>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</id> + <content type="html"><p>hello world! <a href="http://localhost:8080/tags/welcome" class="mention hashtag" rel="tag nofollow noreferrer noopener" target="_blank">#<span>welcome</span></a> ! first post on the instance <img src="http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png" title=":rainbow:" alt=":rainbow:" width="25" height="25" /> !</p></content> + <link href="http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" rel="alternate"></link> + <link href="http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg" rel="enclosure" type="image/jpeg" length="62529"></link> + <summary type="html">@admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"</summary> + </entry> +</feed>`) +} + +func (suite *GetRSSTestSuite) TestGetAccountJSONAdmin() { + suite.testGetFeedSerializedAs("admin", &paging.Page{Limit: 20}, (*feeds.Feed).ToJSON, 1634726497, + `{ + "version": "https://jsonfeed.org/version/1.1", + "title": "Posts from @admin@localhost:8080", + "home_page_url": "http://localhost:8080/@admin", + "description": "Posts from @admin@localhost:8080", + "items": [ + { + "id": "http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37", + "url": "http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37", + "external_url": "http://localhost:8080/@admin/feed.rss", + "title": "open to see some \u003cstrong\u003epuppies\u003c/strong\u003e", + "content_html": "\u003cp\u003e🐕🐕🐕🐕🐕\u003c/p\u003e", + "summary": "@admin@localhost:8080 made a new post: \"🐕🐕🐕🐕🐕\"", + "date_published": "2021-10-20T12:36:45Z" + }, + { + "id": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "external_url": "http://localhost:8080/@admin/feed.rss", + "title": "hello world! #welcome ! first post on the instance :rainbow: !", + "content_html": "\u003cp\u003ehello world! \u003ca href=\"http://localhost:8080/tags/welcome\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\"\u003e#\u003cspan\u003ewelcome\u003c/span\u003e\u003c/a\u003e ! first post on the instance \u003cimg src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" width=\"25\" height=\"25\" /\u003e !\u003c/p\u003e", + "summary": "@admin@localhost:8080 posted 1 attachment: \"hello world! #welcome ! first post on the instance :rainbow: !\"", + "image": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", + "date_published": "2021-10-20T11:36:45Z" + } + ] +}`) } func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { - getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork") - suite.NoError(err) - suite.EqualValues(1730451600, lastModified.Unix()) + suite.testGetFeedSerializedAs("the_mighty_zork", &paging.Page{Limit: 20}, (*feeds.Feed).ToRss, 1730451600, + `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> + <channel> + <title>Posts from @the_mighty_zork@localhost:8080</title> + <link>http://localhost:8080/@the_mighty_zork</link> + <description>Posts from @the_mighty_zork@localhost:8080</description> + <pubDate>Fri, 01 Nov 2024 09:00:00 +0000</pubDate> + <lastBuildDate>Fri, 01 Nov 2024 09:00:00 +0000</lastBuildDate> + <image> + <url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp</url> + <title>Avatar for @the_mighty_zork@localhost:8080</title> + <link>http://localhost:8080/@the_mighty_zork</link> + </image> + <item> + <title>edited status</title> + <link>http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</link> + <description>@the_mighty_zork@localhost:8080 made a new post: "this is the latest revision of the status, with a content-warning"</description> + <content:encoded><![CDATA[<p>this is the latest revision of the status, with a content-warning</p>]]></content:encoded> + <guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</guid> + <pubDate>Fri, 01 Nov 2024 09:00:00 +0000</pubDate> + <source>http://localhost:8080/@the_mighty_zork/feed.rss</source> + </item> + <item> + <title>HTML in post</title> + <link>http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40</link> + <description>@the_mighty_zork@localhost:8080 made a new post: "Here's a bunch of HTML, read it and weep, weep then!

`+"```"+`html
<section class="about-user">
 <div class="col-header">
 <h2>About</h2>
 </div> 
 <div class="fields">
 <h3 class="sr-only">Fields</h3>
 <dl>
...</description> + <content:encoded><![CDATA[<p>Here's a bunch of HTML, read it and weep, weep then!</p><pre><code class="language-html"><section class="about-user"> + <div class="col-header"> + <h2>About</h2> + </div> + <div class="fields"> + <h3 class="sr-only">Fields</h3> + <dl> + <div class="field"> + <dt>should you follow me?</dt> + <dd>maybe!</dd> + </div> + <div class="field"> + <dt>age</dt> + <dd>120</dd> + </div> + </dl> + </div> + <div class="bio"> + <h3 class="sr-only">Bio</h3> + <p>i post about things that concern me</p> + </div> + <div class="sr-only" role="group"> + <h3 class="sr-only">Stats</h3> + <span>Joined in Jun, 2022.</span> + <span>8 posts.</span> + <span>Followed by 1.</span> + <span>Following 1.</span> + </div> + <div class="accountstats" aria-hidden="true"> + <b>Joined</b><time datetime="2022-06-04T13:12:00.000Z">Jun, 2022</time> + <b>Posts</b><span>8</span> + <b>Followed by</b><span>1</span> + <b>Following</b><span>1</span> + </div> +</section> +</code></pre><p>There, hope you liked that!</p>]]></content:encoded> + <guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40</guid> + <pubDate>Sun, 10 Dec 2023 09:24:00 +0000</pubDate> + <source>http://localhost:8080/@the_mighty_zork/feed.rss</source> + </item> + <item> + <title>introduction post</title> + <link>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</link> + <description>@the_mighty_zork@localhost:8080 made a new post: "hello everyone!"</description> + <content:encoded><![CDATA[<p>hello everyone!</p>]]></content:encoded> + <guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</guid> + <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate> + <source>http://localhost:8080/@the_mighty_zork/feed.rss</source> + </item> + </channel> +</rss>`) +} - feed, err := getFeed() - suite.NoError(err) - suite.Equal(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> +func (suite *GetRSSTestSuite) TestGetAccountAtomZork() { + suite.testGetFeedSerializedAs("the_mighty_zork", &paging.Page{Limit: 20}, (*feeds.Feed).ToRss, 1730451600, + `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> <channel> <title>Posts from @the_mighty_zork@localhost:8080</title> <link>http://localhost:8080/@the_mighty_zork</link> @@ -91,7 +220,6 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { <link>http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</link> <description>@the_mighty_zork@localhost:8080 made a new post: "this is the latest revision of the status, with a content-warning"</description> <content:encoded><![CDATA[<p>this is the latest revision of the status, with a content-warning</p>]]></content:encoded> - <author>@the_mighty_zork@localhost:8080</author> <guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</guid> <pubDate>Fri, 01 Nov 2024 09:00:00 +0000</pubDate> <source>http://localhost:8080/@the_mighty_zork/feed.rss</source> @@ -136,7 +264,6 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { </div> </section> </code></pre><p>There, hope you liked that!</p>]]></content:encoded> - <author>@the_mighty_zork@localhost:8080</author> <guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40</guid> <pubDate>Sun, 10 Dec 2023 09:24:00 +0000</pubDate> <source>http://localhost:8080/@the_mighty_zork/feed.rss</source> @@ -145,18 +272,57 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { <title>introduction post</title> <link>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</link> <description>@the_mighty_zork@localhost:8080 made a new post: "hello everyone!"</description> - <content:encoded><![CDATA[hello everyone!]]></content:encoded> - <author>@the_mighty_zork@localhost:8080</author> + <content:encoded><![CDATA[<p>hello everyone!</p>]]></content:encoded> <guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</guid> <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate> <source>http://localhost:8080/@the_mighty_zork/feed.rss</source> </item> </channel> -</rss>`, feed) +</rss>`) +} + +func (suite *GetRSSTestSuite) TestGetAccountJSONZork() { + suite.testGetFeedSerializedAs("the_mighty_zork", &paging.Page{Limit: 20}, (*feeds.Feed).ToJSON, 1730451600, + `{ + "version": "https://jsonfeed.org/version/1.1", + "title": "Posts from @the_mighty_zork@localhost:8080", + "home_page_url": "http://localhost:8080/@the_mighty_zork", + "description": "Posts from @the_mighty_zork@localhost:8080", + "items": [ + { + "id": "http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR", + "url": "http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR", + "external_url": "http://localhost:8080/@the_mighty_zork/feed.rss", + "title": "edited status", + "content_html": "\u003cp\u003ethis is the latest revision of the status, with a content-warning\u003c/p\u003e", + "summary": "@the_mighty_zork@localhost:8080 made a new post: \"this is the latest revision of the status, with a content-warning\"", + "date_published": "2024-11-01T09:00:00Z", + "date_modified": "2024-11-01T09:02:00Z" + }, + { + "id": "http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40", + "url": "http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40", + "external_url": "http://localhost:8080/@the_mighty_zork/feed.rss", + "title": "HTML in post", + "content_html": "\u003cp\u003eHere's a bunch of HTML, read it and weep, weep then!\u003c/p\u003e\u003cpre\u003e\u003ccode class=\"language-html\"\u003e\u0026lt;section class=\u0026#34;about-user\u0026#34;\u0026gt;\n \u0026lt;div class=\u0026#34;col-header\u0026#34;\u0026gt;\n \u0026lt;h2\u0026gt;About\u0026lt;/h2\u0026gt;\n \u0026lt;/div\u0026gt; \n \u0026lt;div class=\u0026#34;fields\u0026#34;\u0026gt;\n \u0026lt;h3 class=\u0026#34;sr-only\u0026#34;\u0026gt;Fields\u0026lt;/h3\u0026gt;\n \u0026lt;dl\u0026gt;\n \u0026lt;div class=\u0026#34;field\u0026#34;\u0026gt;\n \u0026lt;dt\u0026gt;should you follow me?\u0026lt;/dt\u0026gt;\n \u0026lt;dd\u0026gt;maybe!\u0026lt;/dd\u0026gt;\n \u0026lt;/div\u0026gt;\n \u0026lt;div class=\u0026#34;field\u0026#34;\u0026gt;\n \u0026lt;dt\u0026gt;age\u0026lt;/dt\u0026gt;\n \u0026lt;dd\u0026gt;120\u0026lt;/dd\u0026gt;\n \u0026lt;/div\u0026gt;\n \u0026lt;/dl\u0026gt;\n \u0026lt;/div\u0026gt;\n \u0026lt;div class=\u0026#34;bio\u0026#34;\u0026gt;\n \u0026lt;h3 class=\u0026#34;sr-only\u0026#34;\u0026gt;Bio\u0026lt;/h3\u0026gt;\n \u0026lt;p\u0026gt;i post about things that concern me\u0026lt;/p\u0026gt;\n \u0026lt;/div\u0026gt;\n \u0026lt;div class=\u0026#34;sr-only\u0026#34; role=\u0026#34;group\u0026#34;\u0026gt;\n \u0026lt;h3 class=\u0026#34;sr-only\u0026#34;\u0026gt;Stats\u0026lt;/h3\u0026gt;\n \u0026lt;span\u0026gt;Joined in Jun, 2022.\u0026lt;/span\u0026gt;\n \u0026lt;span\u0026gt;8 posts.\u0026lt;/span\u0026gt;\n \u0026lt;span\u0026gt;Followed by 1.\u0026lt;/span\u0026gt;\n \u0026lt;span\u0026gt;Following 1.\u0026lt;/span\u0026gt;\n \u0026lt;/div\u0026gt;\n \u0026lt;div class=\u0026#34;accountstats\u0026#34; aria-hidden=\u0026#34;true\u0026#34;\u0026gt;\n \u0026lt;b\u0026gt;Joined\u0026lt;/b\u0026gt;\u0026lt;time datetime=\u0026#34;2022-06-04T13:12:00.000Z\u0026#34;\u0026gt;Jun, 2022\u0026lt;/time\u0026gt;\n \u0026lt;b\u0026gt;Posts\u0026lt;/b\u0026gt;\u0026lt;span\u0026gt;8\u0026lt;/span\u0026gt;\n \u0026lt;b\u0026gt;Followed by\u0026lt;/b\u0026gt;\u0026lt;span\u0026gt;1\u0026lt;/span\u0026gt;\n \u0026lt;b\u0026gt;Following\u0026lt;/b\u0026gt;\u0026lt;span\u0026gt;1\u0026lt;/span\u0026gt;\n \u0026lt;/div\u0026gt;\n\u0026lt;/section\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThere, hope you liked that!\u003c/p\u003e", + "summary": "@the_mighty_zork@localhost:8080 made a new post: \"Here's a bunch of HTML, read it and weep, weep then!\n\n`+"```"+`html\n\u003csection class=\"about-user\"\u003e\n \u003cdiv class=\"col-header\"\u003e\n \u003ch2\u003eAbout\u003c/h2\u003e\n \u003c/div\u003e \n \u003cdiv class=\"fields\"\u003e\n \u003ch3 class=\"sr-only\"\u003eFields\u003c/h3\u003e\n \u003cdl\u003e\n...", + "date_published": "2023-12-10T09:24:00Z" + }, + { + "id": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "external_url": "http://localhost:8080/@the_mighty_zork/feed.rss", + "title": "introduction post", + "content_html": "\u003cp\u003ehello everyone!\u003c/p\u003e", + "summary": "@the_mighty_zork@localhost:8080 made a new post: \"hello everyone!\"", + "date_published": "2021-10-20T10:40:37Z" + } + ] +}`) } func (suite *GetRSSTestSuite) TestGetAccountRSSZorkNoPosts() { - ctx := context.Background() + ctx := suite.T().Context() // Get all of zork's posts. statuses, err := suite.db.GetAccountStatuses(ctx, suite.testAccounts["local_account_1"].ID, 0, false, false, "", "", false, false) @@ -171,13 +337,10 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZorkNoPosts() { } } - getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(ctx, "the_mighty_zork") - suite.NoError(err) - suite.Empty(lastModified) + var zeroTime time.Time - feed, err := getFeed() - suite.NoError(err) - suite.Equal(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> + suite.testGetFeedSerializedAs("the_mighty_zork", &paging.Page{Limit: 20}, (*feeds.Feed).ToRss, zeroTime.Unix(), + `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"> <channel> <title>Posts from @the_mighty_zork@localhost:8080</title> <link>http://localhost:8080/@the_mighty_zork</link> @@ -190,7 +353,33 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZorkNoPosts() { <link>http://localhost:8080/@the_mighty_zork</link> </image> </channel> -</rss>`, feed) +</rss>`) +} + +// func (suite *GetRSSTestSuite) testGetAccountRSSPaging(username string, page *paging.Page, expectIDs []string) { +// ctx := suite.T().Context() + +// getFeed, _, errWithCode := suite.accountProcessor.GetRSSFeedForUsername(ctx, username, page) +// suite.NoError(errWithCode) + +// feed, errWithCode := getFeed() +// suite.NoError(errWithCode) + +// } + +func (suite *GetRSSTestSuite) testGetFeedSerializedAs(username string, page *paging.Page, serialize func(*feeds.Feed) (string, error), expectLastMod int64, expectSerialized string) { + ctx := suite.T().Context() + + getFeed, lastMod, errWithCode := suite.accountProcessor.GetRSSFeedForUsername(ctx, username, page) + suite.NoError(errWithCode) + suite.Equal(expectLastMod, lastMod.Unix()) + + feed, errWithCode := getFeed() + suite.NoError(errWithCode) + + feedStr, err := serialize(feed) + suite.NoError(err) + suite.Equal(expectSerialized, feedStr) } func TestGetRSSTestSuite(t *testing.T) { diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 8029a460b..870019f41 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -22,13 +22,13 @@ import ( "errors" "fmt" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/util" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // StatusesGet fetches a number of statuses (in time descending order) from the @@ -97,19 +97,33 @@ func (p *Processor) StatusesGet( return nil, gtserror.NewErrorInternalError(err) } - filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) - if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } + for _, status := range filtered { + // ... + filtered, hide, err := p.statusFilter.StatusFilterResultsInContext(ctx, + requestingAccount, + status, + gtsmodel.FilterContextAccount, + ) + if err != nil { + log.Errorf(ctx, "error filtering status: %v", err) + continue + } + + if hide { + // Don't show. + continue + } - for _, s := range filtered { // Convert filtered statuses to API statuses. - item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters, nil) + item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue } + + // Set any filter results. + item.Filtered = filtered + items = append(items, item) } @@ -143,7 +157,8 @@ func (p *Processor) StatusesGet( func (p *Processor) WebStatusesGet( ctx context.Context, targetAccountID string, - maxID string, + page *paging.Page, + mediaOnly bool, ) (*apimodel.PageableResponse, gtserror.WithCode) { account, err := p.state.DB.GetAccountByID(ctx, targetAccountID) if err != nil { @@ -159,8 +174,13 @@ func (p *Processor) WebStatusesGet( return nil, gtserror.NewErrorNotFound(err) } - statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account, 10, maxID) + statuses, err := p.state.DB.GetAccountWebStatuses(ctx, + account, + page, + mediaOnly, + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting statuses: %w", err) return nil, gtserror.NewErrorInternalError(err) } @@ -198,6 +218,7 @@ func (p *Processor) WebStatusesGet( func (p *Processor) WebStatusesGetPinned( ctx context.Context, targetAccountID string, + mediaOnly bool, ) ([]*apimodel.WebStatus, gtserror.WithCode) { statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID) if err != nil && !errors.Is(err, db.ErrNoEntries) { @@ -206,6 +227,11 @@ func (p *Processor) WebStatusesGetPinned( webStatuses := make([]*apimodel.WebStatus, 0, len(statuses)) for _, status := range statuses { + if mediaOnly && len(status.Attachments) == 0 { + // No media, skip. + continue + } + // Ensure visible via the web. visible, err := p.visFilter.StatusVisible(ctx, nil, status) if err != nil { diff --git a/internal/processing/account/themes.go b/internal/processing/account/themes.go index 4f8cc49a1..88fd3ff6c 100644 --- a/internal/processing/account/themes.go +++ b/internal/processing/account/themes.go @@ -25,11 +25,11 @@ import ( "slices" "strings" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" "codeberg.org/gruf/go-bytesize" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" ) var ( diff --git a/internal/processing/account/themes_test.go b/internal/processing/account/themes_test.go index 9506aee50..fff129f68 100644 --- a/internal/processing/account/themes_test.go +++ b/internal/processing/account/themes_test.go @@ -20,9 +20,9 @@ package account_test import ( "testing" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/processing/account" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/processing/account" ) type ThemesTestSuite struct { diff --git a/internal/processing/account/tokens.go b/internal/processing/account/tokens.go new file mode 100644 index 000000000..eaeffe38b --- /dev/null +++ b/internal/processing/account/tokens.go @@ -0,0 +1,122 @@ +// 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" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" +) + +func (p *Processor) TokensGet( + ctx context.Context, + userID string, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + tokens, err := p.state.DB.GetAccessTokens(ctx, userID, page) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting tokens: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(tokens) + if count == 0 { + return paging.EmptyResponse(), nil + } + + var ( + // Get the lowest and highest + // ID values, used for paging. + lo = tokens[count-1].ID + hi = tokens[0].ID + + // Best-guess items length. + items = make([]interface{}, 0, count) + ) + + for _, token := range tokens { + tokenInfo, err := p.converter.TokenToAPITokenInfo(ctx, token) + if err != nil { + log.Errorf(ctx, "error converting token to api token info: %v", err) + continue + } + + // Append req to return items. + items = append(items, tokenInfo) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/tokens", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} + +func (p *Processor) TokenGet( + ctx context.Context, + userID string, + tokenID string, +) (*apimodel.TokenInfo, gtserror.WithCode) { + token, err := p.state.DB.GetTokenByID(ctx, tokenID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting token %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if token == nil { + err := gtserror.Newf("token %s not found in the db", tokenID) + return nil, gtserror.NewErrorNotFound(err) + } + + if token.UserID != userID { + err := gtserror.Newf("token %s does not belong to user %s", tokenID, userID) + return nil, gtserror.NewErrorNotFound(err) + } + + tokenInfo, err := p.converter.TokenToAPITokenInfo(ctx, token) + if err != nil { + err := gtserror.Newf("error converting token to api token info: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return tokenInfo, nil +} + +func (p *Processor) TokenInvalidate( + ctx context.Context, + userID string, + tokenID string, +) (*apimodel.TokenInfo, gtserror.WithCode) { + tokenInfo, errWithCode := p.TokenGet(ctx, userID, tokenID) + if errWithCode != nil { + return nil, errWithCode + } + + if err := p.state.DB.DeleteTokenByID(ctx, tokenID); err != nil { + err := gtserror.Newf("db error deleting token %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return tokenInfo, nil +} diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 2bdbf96f4..99dd074a5 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -24,19 +24,19 @@ import ( "io" "mime/multipart" + "code.superseriousbusiness.org/gotosocial/internal/ap" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/validate" "codeberg.org/gruf/go-iotools" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/text" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/internal/validate" ) func (p *Processor) selectNoteFormatter(contentType string) text.FormatFunc { @@ -77,9 +77,17 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form acctColumns = append(acctColumns, "discoverable") } - if form.Bot != nil { - account.Bot = form.Bot - acctColumns = append(acctColumns, "bot") + if bot := form.Bot; bot != nil { + if *bot { + // Mark account as an Application. + // See: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application + account.ActorType = gtsmodel.AccountActorTypeApplication + } else { + // Mark account as a Person. + // See: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person + account.ActorType = gtsmodel.AccountActorTypePerson + } + acctColumns = append(acctColumns, "actor_type") } if form.Locked != nil { @@ -97,8 +105,8 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - // Parse new display name (always from plaintext). - account.DisplayName = text.SanitizeToPlaintext(displayName) + // HTML tags not allowed in display name. + account.DisplayName = text.StripHTMLFromText(displayName) acctColumns = append(acctColumns, "display_name") } @@ -145,7 +153,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } if form.AvatarDescription != nil { - desc := text.SanitizeToPlaintext(*form.AvatarDescription) + desc := text.StripHTMLFromText(*form.AvatarDescription) form.AvatarDescription = &desc } @@ -175,7 +183,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } if form.HeaderDescription != nil { - desc := text.SanitizeToPlaintext(*form.HeaderDescription) + desc := text.StripHTMLFromText(*form.HeaderDescription) form.HeaderDescription = util.Ptr(desc) } @@ -204,6 +212,37 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form } } + if form.WebVisibility != nil { + switch apimodel.Visibility(*form.WebVisibility) { + + // Show none. + case apimodel.VisibilityNone: + account.HidesToPublicFromUnauthedWeb = util.Ptr(true) + account.HidesCcPublicFromUnauthedWeb = util.Ptr(true) + + // Show public only (GtS default). + case apimodel.VisibilityPublic: + account.HidesToPublicFromUnauthedWeb = util.Ptr(false) + account.HidesCcPublicFromUnauthedWeb = util.Ptr(true) + + // Show public and unlisted (Masto default). + case apimodel.VisibilityUnlisted: + account.HidesToPublicFromUnauthedWeb = util.Ptr(false) + account.HidesCcPublicFromUnauthedWeb = util.Ptr(false) + + default: + const text = "web_visibility must be one of public, unlisted, or none" + err := errors.New(text) + return nil, gtserror.NewErrorBadRequest(err, text) + } + + acctColumns = append( + acctColumns, + "hides_to_public_from_unauthed_web", + "hides_cc_public_from_unauthed_web", + ) + } + // Account settings flags. if form.Source != nil { @@ -265,7 +304,7 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - account.Settings.CustomCSS = text.SanitizeToPlaintext(customCSS) + account.Settings.CustomCSS = text.StripHTMLFromText(customCSS) settingsColumns = append(settingsColumns, "custom_css") } @@ -279,19 +318,16 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form settingsColumns = append(settingsColumns, "hide_collections") } - if form.WebVisibility != nil { - apiVis := apimodel.Visibility(*form.WebVisibility) - webVisibility := typeutils.APIVisToVis(apiVis) - if webVisibility != gtsmodel.VisibilityPublic && - webVisibility != gtsmodel.VisibilityUnlocked && - webVisibility != gtsmodel.VisibilityNone { - const text = "web_visibility must be one of public, unlocked, or none" + if form.WebLayout != nil { + webLayout := gtsmodel.ParseWebLayout(*form.WebLayout) + if webLayout == gtsmodel.WebLayoutUnknown { + const text = "web_layout must be one of microblog or gallery" err := errors.New(text) return nil, gtserror.NewErrorBadRequest(err, text) } - account.Settings.WebVisibility = webVisibility - settingsColumns = append(settingsColumns, "web_visibility") + account.Settings.WebLayout = webLayout + settingsColumns = append(settingsColumns, "web_layout") } // We've parsed + set everything, do @@ -356,8 +392,8 @@ func (p *Processor) updateFields( // Sanitize raw field values. fieldRaw := >smodel.Field{ - Name: text.SanitizeToPlaintext(name), - Value: text.SanitizeToPlaintext(value), + Name: text.StripHTMLFromText(name), + Value: text.StripHTMLFromText(value), } fieldsRaw = append(fieldsRaw, fieldRaw) } @@ -385,7 +421,7 @@ func (p *Processor) processAccountText( emojis := make(map[string]*gtsmodel.Emoji) // Retrieve display name emojis. - for _, emoji := range p.formatter.FromPlainEmojiOnly( + for _, emoji := range p.formatter.FromPlainBasic( ctx, p.parseMention, account.ID, @@ -413,7 +449,7 @@ func (p *Processor) processAccountText( // Name stays plain, but we still need to // see if there are any emojis set in it. field.Name = fieldRaw.Name - for _, emoji := range p.formatter.FromPlainEmojiOnly( + for _, emoji := range p.formatter.FromPlainBasic( ctx, p.parseMention, account.ID, diff --git a/internal/processing/account/update_test.go b/internal/processing/account/update_test.go index a07562544..b6e315d41 100644 --- a/internal/processing/account/update_test.go +++ b/internal/processing/account/update_test.go @@ -18,14 +18,14 @@ package account_test import ( - "context" "testing" "time" + "code.superseriousbusiness.org/gotosocial/internal/ap" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/util" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) type AccountUpdateTestSuite struct { @@ -37,7 +37,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateSimple() { *testAccount = *suite.testAccounts["local_account_1"] var ( - ctx = context.Background() + ctx = suite.T().Context() locked = true displayName = "new display name" note = "#hello here i am!" @@ -87,7 +87,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateWithMention() { *testAccount = *suite.testAccounts["local_account_1"] var ( - ctx = context.Background() + ctx = suite.T().Context() locked = true displayName = "new display name" note = "#hello here i am!\n\ngo check out @1happyturtle, they have a cool account!" @@ -143,7 +143,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateWithMarkdownNote() { testAccount.Settings = settings var ( - ctx = context.Background() + ctx = suite.T().Context() note = "*hello* ~~here~~ i am!" noteExpected = `<p><em>hello</em> <del>here</del> i am!</p>` ) @@ -192,7 +192,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateWithFields() { *testAccount = *suite.testAccounts["local_account_1"] var ( - ctx = context.Background() + ctx = suite.T().Context() updateFields = []apimodel.UpdateField{ { Name: func() *string { s := "favourite emoji"; return &s }(), @@ -286,7 +286,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateNoteNotFields() { *testAccount = *suite.testAccounts["local_account_2"] var ( - ctx = context.Background() + ctx = suite.T().Context() fieldsRawBefore = len(testAccount.FieldsRaw) fieldsBefore = len(testAccount.Fields) note = "#hello here i am!" @@ -331,6 +331,64 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateNoteNotFields() { suite.Equal(fieldsBefore, len(dbAccount.Fields)) } +func (suite *AccountUpdateTestSuite) TestAccountUpdateBotNotBot() { + testAccount := >smodel.Account{} + *testAccount = *suite.testAccounts["local_account_1"] + ctx := suite.T().Context() + + // Call update function to set bot = true. + apiAccount, errWithCode := suite.accountProcessor.Update( + ctx, + testAccount, + &apimodel.UpdateCredentialsRequest{ + Bot: util.Ptr(true), + }, + ) + if errWithCode != nil { + suite.FailNow(errWithCode.Error()) + } + + // Returned profile should be updated. + suite.True(apiAccount.Bot) + + // We should have an update in the client api channel. + msg, _ := suite.getClientMsg(5 * time.Second) + suite.NotNil(msg) + + // Check database model of account as well. + dbAccount, err := suite.db.GetAccountByID(ctx, testAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(dbAccount.ActorType.IsBot()) + + // Call update function to set bot = false. + apiAccount, errWithCode = suite.accountProcessor.Update( + ctx, + testAccount, + &apimodel.UpdateCredentialsRequest{ + Bot: util.Ptr(false), + }, + ) + if errWithCode != nil { + suite.FailNow(errWithCode.Error()) + } + + // Returned profile should be updated. + suite.False(apiAccount.Bot) + + // We should have an update in the client api channel. + msg, _ = suite.getClientMsg(5 * time.Second) + suite.NotNil(msg) + + // Check database model of account as well. + dbAccount, err = suite.db.GetAccountByID(ctx, testAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(dbAccount.ActorType.IsBot()) +} + func TestAccountUpdateTestSuite(t *testing.T) { suite.Run(t, new(AccountUpdateTestSuite)) } |
