diff options
Diffstat (limited to 'internal/processing')
241 files changed, 8695 insertions, 5292 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)) } diff --git a/internal/processing/admin/account_test.go b/internal/processing/admin/account_test.go index baa6eb646..34eb1d2fd 100644 --- a/internal/processing/admin/account_test.go +++ b/internal/processing/admin/account_test.go @@ -18,13 +18,12 @@ package admin_test import ( - "context" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" ) type AccountTestSuite struct { @@ -33,7 +32,7 @@ type AccountTestSuite struct { func (suite *AccountTestSuite) TestAccountActionSuspend() { var ( - ctx = context.Background() + ctx = suite.T().Context() adminAcct = suite.testAccounts["admin_account"] request = &apimodel.AdminActionRequest{ Category: gtsmodel.AdminActionCategoryAccount.String(), @@ -79,7 +78,7 @@ func (suite *AccountTestSuite) TestAccountActionSuspend() { func (suite *AccountTestSuite) TestAccountActionUnsupported() { var ( - ctx = context.Background() + ctx = suite.T().Context() adminAcct = suite.testAccounts["admin_account"] request = &apimodel.AdminActionRequest{ Category: gtsmodel.AdminActionCategoryAccount.String(), diff --git a/internal/processing/admin/accountaction.go b/internal/processing/admin/accountaction.go index 959f2cfcd..19b04a145 100644 --- a/internal/processing/admin/accountaction.go +++ b/internal/processing/admin/accountaction.go @@ -21,12 +21,12 @@ import ( "context" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/ap" + 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" + "code.superseriousbusiness.org/gotosocial/internal/messages" ) func (p *Processor) AccountAction( diff --git a/internal/processing/admin/accountget.go b/internal/processing/admin/accountget.go index 5a3c34c62..06a47cedc 100644 --- a/internal/processing/admin/accountget.go +++ b/internal/processing/admin/accountget.go @@ -22,9 +22,9 @@ import ( "errors" "fmt" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" ) func (p *Processor) AccountGet(ctx context.Context, accountID string) (*apimodel.AdminAccountInfo, gtserror.WithCode) { diff --git a/internal/processing/admin/accounts.go b/internal/processing/admin/accounts.go index ba2a88ce6..2be71ddc3 100644 --- a/internal/processing/admin/accounts.go +++ b/internal/processing/admin/accounts.go @@ -25,12 +25,12 @@ import ( "net/url" "slices" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/paging" + 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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) var ( diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 08e6bf0d5..7ad2450de 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -18,15 +18,15 @@ package admin import ( - "github.com/superseriousbusiness/gotosocial/internal/cleaner" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/subscriptions" - "github.com/superseriousbusiness/gotosocial/internal/transport" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/cleaner" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/subscriptions" + "code.superseriousbusiness.org/gotosocial/internal/transport" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/admin/admin_test.go b/internal/processing/admin/admin_test.go index ad9d9b2ae..857afcae1 100644 --- a/internal/processing/admin/admin_test.go +++ b/internal/processing/admin/admin_test.go @@ -18,26 +18,28 @@ package admin_test import ( + adminactions "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/cleaner" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "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/oauth" + "code.superseriousbusiness.org/gotosocial/internal/processing" + "code.superseriousbusiness.org/gotosocial/internal/processing/admin" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/storage" + "code.superseriousbusiness.org/gotosocial/internal/subscriptions" + "code.superseriousbusiness.org/gotosocial/internal/transport" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - adminactions "github.com/superseriousbusiness/gotosocial/internal/admin" - "github.com/superseriousbusiness/gotosocial/internal/cleaner" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" - "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/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/processing/admin" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/subscriptions" - "github.com/superseriousbusiness/gotosocial/internal/transport" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" ) type AdminStandardTestSuite struct { @@ -58,7 +60,6 @@ type AdminStandardTestSuite 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 @@ -73,7 +74,6 @@ type AdminStandardTestSuite struct { func (suite *AdminStandardTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() @@ -94,16 +94,10 @@ func (suite *AdminStandardTestSuite) SetupTest() { suite.state.AdminActions = adminactions.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) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.oauthServer = testrig.NewTestOauthServer(&suite.state) suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) suite.federator = testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) @@ -121,7 +115,9 @@ func (suite *AdminStandardTestSuite) SetupTest() { suite.emailSender, testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), interaction.NewFilter(&suite.state), + status.NewFilter(&suite.state), ) testrig.StartWorkers(&suite.state, suite.processor.Workers()) diff --git a/internal/processing/admin/debug_apurl.go b/internal/processing/admin/debug_apurl.go index dbf337dc3..f6098c534 100644 --- a/internal/processing/admin/debug_apurl.go +++ b/internal/processing/admin/debug_apurl.go @@ -23,11 +23,11 @@ import ( "net/http" "net/url" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // DebugAPUrl performs a GET to the given url, using the diff --git a/internal/processing/admin/domainallow.go b/internal/processing/admin/domainallow.go index 13f0307f2..bdf70642b 100644 --- a/internal/processing/admin/domainallow.go +++ b/internal/processing/admin/domainallow.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/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/text" + 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/text" ) func (p *Processor) createDomainAllow( @@ -53,14 +53,14 @@ func (p *Processor) createDomainAllow( ID: id.NewULID(), Domain: domain, CreatedByAccountID: adminAcct.ID, - PrivateComment: text.SanitizeToPlaintext(privateComment), - PublicComment: text.SanitizeToPlaintext(publicComment), + PrivateComment: text.StripHTMLFromText(privateComment), + PublicComment: text.StripHTMLFromText(publicComment), Obfuscate: &obfuscate, SubscriptionID: subscriptionID, } // Insert the new allow into the database. - if err := p.state.DB.CreateDomainAllow(ctx, domainAllow); err != nil { + if err := p.state.DB.PutDomainAllow(ctx, domainAllow); err != nil { err = gtserror.Newf("db error putting domain allow %s: %w", domain, err) return nil, "", gtserror.NewErrorInternalError(err) } @@ -92,6 +92,54 @@ func (p *Processor) createDomainAllow( return apiDomainAllow, action.ID, nil } +func (p *Processor) updateDomainAllow( + ctx context.Context, + domainAllowID string, + obfuscate *bool, + publicComment *string, + privateComment *string, + subscriptionID *string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + domainAllow, err := p.state.DB.GetDomainAllowByID(ctx, domainAllowID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real error. + err = gtserror.Newf("db error getting domain allow: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // There are just no entries for this ID. + err = fmt.Errorf("no domain allow entry exists with ID %s", domainAllowID) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + var columns []string + if obfuscate != nil { + domainAllow.Obfuscate = obfuscate + columns = append(columns, "obfuscate") + } + if publicComment != nil { + domainAllow.PublicComment = *publicComment + columns = append(columns, "public_comment") + } + if privateComment != nil { + domainAllow.PrivateComment = *privateComment + columns = append(columns, "private_comment") + } + if subscriptionID != nil { + domainAllow.SubscriptionID = *subscriptionID + columns = append(columns, "subscription_id") + } + + // Update the domain allow. + if err := p.state.DB.UpdateDomainAllow(ctx, domainAllow, columns...); err != nil { + err = gtserror.Newf("db error updating domain allow: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, domainAllow, false) +} + func (p *Processor) deleteDomainAllow( ctx context.Context, adminAcct *gtsmodel.Account, @@ -128,7 +176,7 @@ func (p *Processor) deleteDomainAllow( ID: id.NewULID(), TargetCategory: gtsmodel.AdminActionCategoryDomain, TargetID: domainAllow.Domain, - Type: gtsmodel.AdminActionUnsuspend, + Type: gtsmodel.AdminActionUnallow, AccountID: adminAcct.ID, } diff --git a/internal/processing/admin/domainblock.go b/internal/processing/admin/domainblock.go index f8c1a6708..1c5d004bd 100644 --- a/internal/processing/admin/domainblock.go +++ b/internal/processing/admin/domainblock.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/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/text" + 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/text" ) func (p *Processor) createDomainBlock( @@ -53,14 +53,14 @@ func (p *Processor) createDomainBlock( ID: id.NewULID(), Domain: domain, CreatedByAccountID: adminAcct.ID, - PrivateComment: text.SanitizeToPlaintext(privateComment), - PublicComment: text.SanitizeToPlaintext(publicComment), + PrivateComment: text.StripHTMLFromText(privateComment), + PublicComment: text.StripHTMLFromText(publicComment), Obfuscate: &obfuscate, SubscriptionID: subscriptionID, } // Insert the new block into the database. - if err := p.state.DB.CreateDomainBlock(ctx, domainBlock); err != nil { + if err := p.state.DB.PutDomainBlock(ctx, domainBlock); err != nil { err = gtserror.Newf("db error putting domain block %s: %w", domain, err) return nil, "", gtserror.NewErrorInternalError(err) } @@ -93,6 +93,54 @@ func (p *Processor) createDomainBlock( return apiDomainBlock, action.ID, nil } +func (p *Processor) updateDomainBlock( + ctx context.Context, + domainBlockID string, + obfuscate *bool, + publicComment *string, + privateComment *string, + subscriptionID *string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, domainBlockID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real error. + err = gtserror.Newf("db error getting domain block: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // There are just no entries for this ID. + err = fmt.Errorf("no domain block entry exists with ID %s", domainBlockID) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + var columns []string + if obfuscate != nil { + domainBlock.Obfuscate = obfuscate + columns = append(columns, "obfuscate") + } + if publicComment != nil { + domainBlock.PublicComment = *publicComment + columns = append(columns, "public_comment") + } + if privateComment != nil { + domainBlock.PrivateComment = *privateComment + columns = append(columns, "private_comment") + } + if subscriptionID != nil { + domainBlock.SubscriptionID = *subscriptionID + columns = append(columns, "subscription_id") + } + + // Update the domain block. + if err := p.state.DB.UpdateDomainBlock(ctx, domainBlock, columns...); err != nil { + err = gtserror.Newf("db error updating domain block: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, domainBlock, false) +} + func (p *Processor) deleteDomainBlock( ctx context.Context, adminAcct *gtsmodel.Account, diff --git a/internal/processing/admin/domainkeysexpire.go b/internal/processing/admin/domainkeysexpire.go index 0613f502d..39fe77dc7 100644 --- a/internal/processing/admin/domainkeysexpire.go +++ b/internal/processing/admin/domainkeysexpire.go @@ -20,9 +20,9 @@ package admin import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" ) // DomainKeysExpire iterates through all diff --git a/internal/processing/admin/domainpermission.go b/internal/processing/admin/domainpermission.go index 55800f458..d7546fb15 100644 --- a/internal/processing/admin/domainpermission.go +++ b/internal/processing/admin/domainpermission.go @@ -18,6 +18,7 @@ package admin import ( + "cmp" "context" "encoding/json" "errors" @@ -25,10 +26,11 @@ import ( "mime/multipart" "net/http" - 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" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // DomainPermissionCreate creates an instance-level permission @@ -84,6 +86,50 @@ func (p *Processor) DomainPermissionCreate( } } +// DomainPermissionUpdate updates a domain permission +// of the given permissionType, with the given ID. +func (p *Processor) DomainPermissionUpdate( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + permID string, + obfuscate *bool, + publicComment *string, + privateComment *string, + subscriptionID *string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + switch permissionType { + + // Explicitly block a domain. + case gtsmodel.DomainPermissionBlock: + return p.updateDomainBlock( + ctx, + permID, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + // Explicitly allow a domain. + case gtsmodel.DomainPermissionAllow: + return p.updateDomainAllow( + ctx, + permID, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + // 🎵 Why don't we all strap bombs to our chests, + // and ride our bikes to the next G7 picnic? + // Seems easier with every clock-tick. 🎵 + default: + err := gtserror.Newf("unrecognized permission type %d", permissionType) + return nil, gtserror.NewErrorInternalError(err) + } +} + // DomainPermissionDelete removes one domain block with the given ID, // and processes side effects of removing the block asynchronously. // @@ -153,14 +199,14 @@ func (p *Processor) DomainPermissionsImport( } defer file.Close() - // Parse file as slice of domain blocks. - domainPerms := make([]*apimodel.DomainPermission, 0) - if err := json.NewDecoder(file).Decode(&domainPerms); err != nil { + // Parse file as slice of domain permissions. + apiDomainPerms := make([]*apimodel.DomainPermission, 0) + if err := json.NewDecoder(file).Decode(&apiDomainPerms); err != nil { err = gtserror.Newf("error parsing attachment as domain permissions: %w", err) return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - count := len(domainPerms) + count := len(apiDomainPerms) if count == 0 { err = gtserror.New("error importing domain permissions: 0 entries provided") return nil, gtserror.NewErrorBadRequest(err, err.Error()) @@ -170,50 +216,95 @@ func (p *Processor) DomainPermissionsImport( // between successes and errors so that the caller can // try failed imports again if desired. multiStatusEntries := make([]apimodel.MultiStatusEntry, 0, count) - - for _, domainPerm := range domainPerms { - var ( - domain = domainPerm.Domain.Domain - obfuscate = domainPerm.Obfuscate - publicComment = domainPerm.PublicComment - privateComment = domainPerm.PrivateComment - subscriptionID = "" // No sub ID for imports. - errWithCode gtserror.WithCode + for _, apiDomainPerm := range apiDomainPerms { + multiStatusEntries = append( + multiStatusEntries, + p.importOrUpdateDomainPerm( + ctx, + permissionType, + account, + apiDomainPerm, + ), ) + } + + return apimodel.NewMultiStatus(multiStatusEntries), nil +} - domainPerm, _, errWithCode = p.DomainPermissionCreate( +func (p *Processor) importOrUpdateDomainPerm( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + account *gtsmodel.Account, + apiDomainPerm *apimodel.DomainPermission, +) apimodel.MultiStatusEntry { + var ( + domain = apiDomainPerm.Domain.Domain + obfuscate = apiDomainPerm.Obfuscate + publicComment = cmp.Or(apiDomainPerm.PublicComment, apiDomainPerm.Comment) + privateComment = apiDomainPerm.PrivateComment + subscriptionID = "" // No sub ID for imports. + ) + + // Check if this domain + // perm already exists. + var ( + domainPerm gtsmodel.DomainPermission + err error + ) + if permType == gtsmodel.DomainPermissionBlock { + domainPerm, err = p.state.DB.GetDomainBlock(ctx, domain) + } else { + domainPerm, err = p.state.DB.GetDomainAllow(ctx, domain) + } + + if err != nil && !errors.Is(err, db.ErrNoEntries) { + // Real db error. + return apimodel.MultiStatusEntry{ + Resource: domain, + Message: "db error checking for existence of domain permission", + Status: http.StatusInternalServerError, + } + } + + var errWithCode gtserror.WithCode + if !util.IsNil(domainPerm) { + // Permission already exists, update it. + apiDomainPerm, errWithCode = p.DomainPermissionUpdate( ctx, - permissionType, - account, - domain, + permType, + domainPerm.GetID(), obfuscate, publicComment, privateComment, + nil, + ) + } else { + // Permission didn't exist yet, create it. + apiDomainPerm, _, errWithCode = p.DomainPermissionCreate( + ctx, + permType, + account, + domain, + util.PtrOrZero(obfuscate), + util.PtrOrZero(publicComment), + util.PtrOrZero(privateComment), subscriptionID, ) + } - var entry *apimodel.MultiStatusEntry - - if errWithCode != nil { - entry = &apimodel.MultiStatusEntry{ - // Use the failed domain entry as the resource value. - Resource: domain, - Message: errWithCode.Safe(), - Status: errWithCode.Code(), - } - } else { - entry = &apimodel.MultiStatusEntry{ - // Use successfully created API model domain block as the resource value. - Resource: domainPerm, - Message: http.StatusText(http.StatusOK), - Status: http.StatusOK, - } + if errWithCode != nil { + return apimodel.MultiStatusEntry{ + Resource: domain, + Message: errWithCode.Safe(), + Status: errWithCode.Code(), } - - multiStatusEntries = append(multiStatusEntries, *entry) } - return apimodel.NewMultiStatus(multiStatusEntries), nil + return apimodel.MultiStatusEntry{ + Resource: apiDomainPerm, + Message: http.StatusText(http.StatusOK), + Status: http.StatusOK, + } } // DomainPermissionsGet returns all existing domain @@ -303,15 +394,21 @@ func (p *Processor) DomainPermissionGet( err = gtserror.New("unrecognized permission type") } - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - err = fmt.Errorf("no domain %s exists with id %s", permissionType.String(), id) - return nil, gtserror.NewErrorNotFound(err, err.Error()) - } - - err = gtserror.Newf("error getting domain %s with id %s: %w", permissionType.String(), id, err) + if err != nil && errors.Is(err, db.ErrNoEntries) { + err = gtserror.Newf( + "db error getting domain %s with id %s: %w", + permissionType.String(), id, err, + ) return nil, gtserror.NewErrorInternalError(err) } + if util.IsNil(domainPerm) { + errText := fmt.Sprintf( + "no domain %s exists with id %s", + permissionType.String(), id, + ) + return nil, gtserror.NewErrorNotFound(errors.New(errText), errText) + } + return p.apiDomainPerm(ctx, domainPerm, export) } diff --git a/internal/processing/admin/domainpermission_test.go b/internal/processing/admin/domainpermission_test.go index c8f3560c3..fccb9e10f 100644 --- a/internal/processing/admin/domainpermission_test.go +++ b/internal/processing/admin/domainpermission_test.go @@ -22,13 +22,13 @@ import ( "net/http" "testing" + 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/id" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - 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/id" - "github.com/superseriousbusiness/gotosocial/testrig" ) type DomainBlockTestSuite struct { @@ -82,7 +82,7 @@ func (suite *DomainBlockTestSuite) runDomainPermTest(t domainPermTest) { config.SetInstanceFederationMode(t.instanceFederationMode) for _, action := range t.actions { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(suite.T().Context()) defer cancel() // Run the desired action. @@ -102,7 +102,7 @@ func (suite *DomainBlockTestSuite) runDomainPermTest(t domainPermTest) { // Check expected results // against each account. accounts, err := suite.db.GetInstanceAccounts( - context.Background(), + suite.T().Context(), action.domain, "", 0, ) @@ -123,7 +123,7 @@ func (suite *DomainBlockTestSuite) createDomainPerm( permissionType gtsmodel.DomainPermissionType, domain string, ) (*apimodel.DomainPermission, string) { - ctx := context.Background() + ctx := suite.T().Context() apiPerm, actionID, errWithCode := suite.adminProcessor.DomainPermissionCreate( ctx, @@ -148,7 +148,7 @@ func (suite *DomainBlockTestSuite) deleteDomainPerm( domain string, ) (*apimodel.DomainPermission, string) { var ( - ctx = context.Background() + ctx = suite.T().Context() domainPermission gtsmodel.DomainPermission ) @@ -183,7 +183,7 @@ func (suite *DomainBlockTestSuite) deleteDomainPerm( // waits for given actionID to be completed. func (suite *DomainBlockTestSuite) awaitAction(actionID string) { - ctx := context.Background() + ctx := suite.T().Context() if !testrig.WaitFor(func() bool { return suite.state.AdminActions.TotalRunning() == 0 diff --git a/internal/processing/admin/domainpermissiondraft.go b/internal/processing/admin/domainpermissiondraft.go index 0dc17a45a..9e067f20b 100644 --- a/internal/processing/admin/domainpermissiondraft.go +++ b/internal/processing/admin/domainpermissiondraft.go @@ -23,16 +23,16 @@ import ( "fmt" "net/url" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "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/log" - "github.com/superseriousbusiness/gotosocial/internal/paging" - "github.com/superseriousbusiness/gotosocial/internal/util" + 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/gtscontext" + "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" ) // DomainPermissionDraftGet returns one diff --git a/internal/processing/admin/domainpermissionexclude.go b/internal/processing/admin/domainpermissionexclude.go index 761ca8b9c..b2134777d 100644 --- a/internal/processing/admin/domainpermissionexclude.go +++ b/internal/processing/admin/domainpermissionexclude.go @@ -23,13 +23,13 @@ import ( "fmt" "net/url" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "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/paging" + 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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) func (p *Processor) DomainPermissionExcludeCreate( diff --git a/internal/processing/admin/domainpermissionsubscription.go b/internal/processing/admin/domainpermissionsubscription.go index bdc38df63..9afe6ee5c 100644 --- a/internal/processing/admin/domainpermissionsubscription.go +++ b/internal/processing/admin/domainpermissionsubscription.go @@ -24,13 +24,13 @@ import ( "net/url" "slices" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "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/paging" + 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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) // DomainPermissionSubscriptionGet returns one @@ -142,6 +142,8 @@ func (p *Processor) DomainPermissionSubscriptionCreate( contentType gtsmodel.DomainPermSubContentType, permType gtsmodel.DomainPermissionType, asDraft bool, + adoptOrphans *bool, + removeRetracted *bool, fetchUsername string, fetchPassword string, ) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { @@ -151,12 +153,14 @@ func (p *Processor) DomainPermissionSubscriptionCreate( Title: title, PermissionType: permType, AsDraft: &asDraft, + AdoptOrphans: adoptOrphans, CreatedByAccountID: acct.ID, CreatedByAccount: acct, URI: uri, ContentType: contentType, FetchUsername: fetchUsername, FetchPassword: fetchPassword, + RemoveRetracted: removeRetracted, } err := p.state.DB.PutDomainPermissionSubscription(ctx, permSub) @@ -184,6 +188,7 @@ func (p *Processor) DomainPermissionSubscriptionUpdate( contentType *gtsmodel.DomainPermSubContentType, asDraft *bool, adoptOrphans *bool, + removeRetracted *bool, fetchUsername *string, fetchPassword *string, ) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { @@ -230,6 +235,11 @@ func (p *Processor) DomainPermissionSubscriptionUpdate( columns = append(columns, "adopt_orphans") } + if removeRetracted != nil { + permSub.RemoveRetracted = removeRetracted + columns = append(columns, "remove_retracted") + } + if fetchPassword != nil { permSub.FetchPassword = *fetchPassword columns = append(columns, "fetch_password") @@ -342,12 +352,13 @@ func (p *Processor) DomainPermissionSubscriptionTest( // Call the permSub.URI and parse a list of perms from it. // Any error returned here is a "real" one, not an error // from fetching / parsing the list. - createdPerms, err := p.subscriptions.ProcessDomainPermissionSubscription( + createdPerms, _, err := p.subscriptions.ProcessDomainPermissionSubscription( ctx, permSub, tsport, higherPrios, true, // Dry run. + true, // Skip caching. ) if err != nil { err := gtserror.Newf("error doing dry-run: %w", err) diff --git a/internal/processing/admin/email.go b/internal/processing/admin/email.go index 949be6e4b..fe69236db 100644 --- a/internal/processing/admin/email.go +++ b/internal/processing/admin/email.go @@ -21,10 +21,10 @@ import ( "context" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // EmailTest sends a generic test email to the given diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go index 5a7da445e..8d568b9a8 100644 --- a/internal/processing/admin/emoji.go +++ b/internal/processing/admin/emoji.go @@ -25,15 +25,16 @@ import ( "mime/multipart" "strings" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "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/id" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" "codeberg.org/gruf/go-iotools" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "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/media" - "github.com/superseriousbusiness/gotosocial/internal/util" ) // EmojiCreate creates a custom emoji on this instance. @@ -262,11 +263,7 @@ func (p *Processor) EmojiCategoriesGet( apiCategories := make([]*apimodel.EmojiCategory, 0, len(categories)) for _, category := range categories { - apiCategory, err := p.converter.EmojiCategoryToAPIEmojiCategory(ctx, category) - if err != nil { - err := gtserror.Newf("error converting emoji category to api emoji category: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } + apiCategory := typeutils.EmojiCategoryToAPIEmojiCategory(category) apiCategories = append(apiCategories, apiCategory) } @@ -293,6 +290,7 @@ func (p *Processor) emojiUpdateCopy( // Ensure target emoji is locally cached. target, err := p.federator.RecacheEmoji(ctx, target, + false, ) if err != nil { err := gtserror.Newf("error recaching emoji %s: %w", target.ImageRemoteURL, err) diff --git a/internal/processing/admin/emoji_test.go b/internal/processing/admin/emoji_test.go index 17f5fc864..b050cefe8 100644 --- a/internal/processing/admin/emoji_test.go +++ b/internal/processing/admin/emoji_test.go @@ -18,13 +18,12 @@ package admin_test import ( - "context" "testing" + 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" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/util" ) type EmojiTestSuite struct { @@ -32,7 +31,7 @@ type EmojiTestSuite struct { } func (suite *EmojiTestSuite) TestUpdateEmojiCategory() { - ctx := context.Background() + ctx := suite.T().Context() testEmoji := new(gtsmodel.Emoji) *testEmoji = *suite.testEmojis["rainbow"] diff --git a/internal/processing/admin/headerfilter.go b/internal/processing/admin/headerfilter.go index 13105d191..dc74c151b 100644 --- a/internal/processing/admin/headerfilter.go +++ b/internal/processing/admin/headerfilter.go @@ -23,13 +23,13 @@ import ( "net/textproto" "regexp" - 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/headerfilter" - "github.com/superseriousbusiness/gotosocial/internal/id" - "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/headerfilter" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // GetAllowHeaderFilter fetches allow HTTP header filter with provided ID from the database. diff --git a/internal/processing/admin/media.go b/internal/processing/admin/media.go index 9cd68d88b..11394bbed 100644 --- a/internal/processing/admin/media.go +++ b/internal/processing/admin/media.go @@ -21,10 +21,10 @@ import ( "context" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" ) // MediaRefetch forces a refetch of remote emojis. @@ -56,11 +56,11 @@ func (p *Processor) MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gt return gtserror.NewErrorBadRequest(err, err.Error()) } - // Start background task performing all media cleanup tasks. go func() { - ctx := context.Background() - p.cleaner.Media().All(ctx, mediaRemoteCacheDays) - p.cleaner.Emoji().All(ctx, mediaRemoteCacheDays) + // Start background task performing all media cleanup tasks. + ctx := gtscontext.WithValues(context.Background(), ctx) + p.cleaner.Media().AllAndFix(ctx, mediaRemoteCacheDays) + p.cleaner.Emoji().AllAndFix(ctx, mediaRemoteCacheDays) }() return nil diff --git a/internal/processing/admin/report.go b/internal/processing/admin/report.go index ed34a4e83..bb94e67f5 100644 --- a/internal/processing/admin/report.go +++ b/internal/processing/admin/report.go @@ -25,14 +25,14 @@ import ( "strconv" "time" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "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" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "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" ) // ReportsGet returns reports stored on this diff --git a/internal/processing/admin/rule.go b/internal/processing/admin/rule.go index 8134c21cd..de19cba0b 100644 --- a/internal/processing/admin/rule.go +++ b/internal/processing/admin/rule.go @@ -22,13 +22,13 @@ import ( "errors" "fmt" - 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/typeutils" - "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/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // RulesGet returns all rules stored on this instance. @@ -64,17 +64,12 @@ func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInst // RuleCreate adds a new rule to the instance. func (p *Processor) RuleCreate(ctx context.Context, form *apimodel.InstanceRuleCreateRequest) (*apimodel.AdminInstanceRule, gtserror.WithCode) { - ruleID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating id for new instance rule: %s", err), "error creating rule ID") - } - rule := >smodel.Rule{ - ID: ruleID, + ID: id.NewRandomULID(), Text: form.Text, } - if err = p.state.DB.PutRule(ctx, rule); err != nil { + if err := p.state.DB.PutRule(ctx, rule); err != nil { return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/admin/signupapprove.go b/internal/processing/admin/signupapprove.go index 84e04fa8d..d33227bb2 100644 --- a/internal/processing/admin/signupapprove.go +++ b/internal/processing/admin/signupapprove.go @@ -22,12 +22,12 @@ 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/messages" + "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" ) func (p *Processor) SignupApprove( diff --git a/internal/processing/admin/signupapprove_test.go b/internal/processing/admin/signupapprove_test.go index 58b8fdade..5739325c2 100644 --- a/internal/processing/admin/signupapprove_test.go +++ b/internal/processing/admin/signupapprove_test.go @@ -18,12 +18,11 @@ package admin_test import ( - "context" "testing" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" ) type AdminApproveTestSuite struct { @@ -32,7 +31,7 @@ type AdminApproveTestSuite struct { func (suite *AdminApproveTestSuite) TestApprove() { var ( - ctx = context.Background() + ctx = suite.T().Context() adminAcct = suite.testAccounts["admin_account"] targetAcct = suite.testAccounts["unconfirmed_account"] targetUser = new(gtsmodel.User) diff --git a/internal/processing/admin/signupreject.go b/internal/processing/admin/signupreject.go index 39eff0b87..5b7872a19 100644 --- a/internal/processing/admin/signupreject.go +++ b/internal/processing/admin/signupreject.go @@ -22,12 +22,12 @@ 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/messages" + "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" ) func (p *Processor) SignupReject( diff --git a/internal/processing/admin/signupreject_test.go b/internal/processing/admin/signupreject_test.go index cb6a25eb3..ea517f579 100644 --- a/internal/processing/admin/signupreject_test.go +++ b/internal/processing/admin/signupreject_test.go @@ -18,13 +18,12 @@ package admin_test import ( - "context" "testing" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" ) type AdminRejectTestSuite struct { @@ -33,7 +32,7 @@ type AdminRejectTestSuite struct { func (suite *AdminRejectTestSuite) TestReject() { var ( - ctx = context.Background() + ctx = suite.T().Context() adminAcct = suite.testAccounts["admin_account"] targetAcct = suite.testAccounts["unconfirmed_account"] targetUser = suite.testUsers["unconfirmed_account"] @@ -95,7 +94,7 @@ func (suite *AdminRejectTestSuite) TestReject() { func (suite *AdminRejectTestSuite) TestRejectRemote() { var ( - ctx = context.Background() + ctx = suite.T().Context() adminAcct = suite.testAccounts["admin_account"] targetAcct = suite.testAccounts["remote_account_1"] privateComment = "It's a no from me chief." @@ -117,7 +116,7 @@ func (suite *AdminRejectTestSuite) TestRejectRemote() { func (suite *AdminRejectTestSuite) TestRejectApproved() { var ( - ctx = context.Background() + ctx = suite.T().Context() adminAcct = suite.testAccounts["admin_account"] targetAcct = suite.testAccounts["local_account_1"] privateComment = "It's a no from me chief." diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index f04b3654b..2ad0069d7 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -20,9 +20,9 @@ package admin import ( "context" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // apiDomainPerm is a cheeky shortcut for returning diff --git a/internal/processing/admin/workertask.go b/internal/processing/admin/workertask.go index 6d7cc7b7a..9fcd1f5a1 100644 --- a/internal/processing/admin/workertask.go +++ b/internal/processing/admin/workertask.go @@ -23,12 +23,12 @@ import ( "slices" "time" - "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/transport" - "github.com/superseriousbusiness/gotosocial/internal/transport/delivery" + "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/transport" + "code.superseriousbusiness.org/gotosocial/internal/transport/delivery" ) // NOTE: diff --git a/internal/processing/admin/workertask_test.go b/internal/processing/admin/workertask_test.go index bf326bafd..8ce0a3436 100644 --- a/internal/processing/admin/workertask_test.go +++ b/internal/processing/admin/workertask_test.go @@ -27,14 +27,14 @@ import ( "net/url" "testing" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/httpclient" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/transport/delivery" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/httpclient" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/transport/delivery" - "github.com/superseriousbusiness/gotosocial/testrig" ) var ( @@ -94,7 +94,7 @@ type WorkerTaskTestSuite struct { } func (suite *WorkerTaskTestSuite) TestFillWorkerQueues() { - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() var tasks []*gtsmodel.WorkerTask @@ -255,7 +255,7 @@ func (suite *WorkerTaskTestSuite) TestFillWorkerQueues() { } func (suite *WorkerTaskTestSuite) TestPersistWorkerQueues() { - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Push all test worker tasks to their respective queues. diff --git a/internal/processing/advancedmigrations/advancedmigrations.go b/internal/processing/advancedmigrations/advancedmigrations.go index 3f1876539..d4404bb85 100644 --- a/internal/processing/advancedmigrations/advancedmigrations.go +++ b/internal/processing/advancedmigrations/advancedmigrations.go @@ -21,7 +21,7 @@ import ( "context" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/processing/conversations" + "code.superseriousbusiness.org/gotosocial/internal/processing/conversations" ) // Processor holds references to any other processor that has migrations to run. diff --git a/internal/processing/app.go b/internal/processing/app.go deleted file mode 100644 index d492b3bc4..000000000 --- a/internal/processing/app.go +++ /dev/null @@ -1,88 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. - -package processing - -import ( - "context" - - "github.com/google/uuid" - 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" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -func (p *Processor) AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, gtserror.WithCode) { - // set default 'read' for scopes if it's not set - var scopes string - if form.Scopes == "" { - scopes = "read" - } else { - scopes = form.Scopes - } - - // generate new IDs for this application and its associated client - clientID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - clientSecret := uuid.NewString() - - appID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - // generate the application to put in the database - app := >smodel.Application{ - ID: appID, - Name: form.ClientName, - Website: form.Website, - RedirectURI: form.RedirectURIs, - ClientID: clientID, - ClientSecret: clientSecret, - Scopes: scopes, - } - - // chuck it in the db - if err := p.state.DB.PutApplication(ctx, app); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - // now we need to model an oauth client from the application that the oauth library can use - oc := >smodel.Client{ - ID: clientID, - Secret: clientSecret, - Domain: form.RedirectURIs, - // This client isn't yet associated with a specific user, it's just an app client right now - UserID: "", - } - - // chuck it in the db - if err := p.state.DB.PutClient(ctx, oc); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return apiApp, nil -} diff --git a/internal/processing/filters/v2/convert.go b/internal/processing/application/application.go index 1e544e6e4..acecd7f85 100644 --- a/internal/processing/filters/v2/convert.go +++ b/internal/processing/application/application.go @@ -15,24 +15,24 @@ // 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 v2 +package application import ( - "context" - "fmt" - - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) -// apiFilter is a shortcut to return the API v2 filter version of the given -// filter, or return an appropriate error if conversion fails. -func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.Filter) (*apimodel.FilterV2, gtserror.WithCode) { - apiFilter, err := p.converter.FilterToAPIFilterV2(ctx, filterKeyword) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter to API v2 filter: %w", err)) - } +type Processor struct { + state *state.State + converter *typeutils.Converter +} - return apiFilter, nil +func New( + state *state.State, + converter *typeutils.Converter, +) Processor { + return Processor{ + state: state, + converter: converter, + } } diff --git a/internal/processing/application/create.go b/internal/processing/application/create.go new file mode 100644 index 000000000..d63b682d6 --- /dev/null +++ b/internal/processing/application/create.go @@ -0,0 +1,113 @@ +// 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 application + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + 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" + "code.superseriousbusiness.org/gotosocial/internal/oauth" + "github.com/google/uuid" +) + +func (p *Processor) Create( + ctx context.Context, + managedByUserID string, + form *apimodel.ApplicationCreateRequest, +) (*apimodel.Application, gtserror.WithCode) { + // Set default 'read' for + // scopes if it's not set. + var scopes string + if form.Scopes == "" { + scopes = "read" + } else { + scopes = form.Scopes + } + + // Normalize + parse requested redirect URIs. + form.RedirectURIs = strings.TrimSpace(form.RedirectURIs) + var redirectURIs []string + if form.RedirectURIs != "" { + // Redirect URIs can be just one value, or can be passed + // as a newline-separated list of strings. Ensure each URI + // is parseable + normalize it by reconstructing from *url.URL. + // Also ensure we don't add multiple copies of the same URI. + redirectStrs := strings.Split(form.RedirectURIs, "\n") + added := make(map[string]struct{}, len(redirectStrs)) + + for _, redirectStr := range redirectStrs { + redirectStr = strings.TrimSpace(redirectStr) + if redirectStr == "" { + continue + } + + redirectURI, err := url.Parse(redirectStr) + if err != nil { + errText := fmt.Sprintf("error parsing redirect URI: %v", err) + return nil, gtserror.NewErrorBadRequest(err, errText) + } + + redirectURIStr := redirectURI.String() + if _, alreadyAdded := added[redirectURIStr]; !alreadyAdded { + redirectURIs = append(redirectURIs, redirectURIStr) + added[redirectURIStr] = struct{}{} + } + } + + if len(redirectURIs) == 0 { + errText := "no redirect URIs left after trimming space" + return nil, gtserror.NewErrorBadRequest(errors.New(errText), errText) + } + } else { + // No redirect URI(s) provided, just set default oob. + redirectURIs = append(redirectURIs, oauth.OOBURI) + } + + // Generate random client ID. + clientID := id.NewRandomULID() + + // Generate + store app + // to put in the database. + app := >smodel.Application{ + ID: id.NewULID(), + Name: form.ClientName, + Website: form.Website, + RedirectURIs: redirectURIs, + ClientID: clientID, + ClientSecret: uuid.NewString(), + Scopes: scopes, + ManagedByUserID: managedByUserID, + } + if err := p.state.DB.PutApplication(ctx, app); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return apiApp, nil +} diff --git a/internal/processing/application/delete.go b/internal/processing/application/delete.go new file mode 100644 index 000000000..6b3856bf0 --- /dev/null +++ b/internal/processing/application/delete.go @@ -0,0 +1,76 @@ +// 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 application + +import ( + "context" + "errors" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" +) + +func (p *Processor) Delete( + ctx context.Context, + userID string, + appID string, +) (*apimodel.Application, gtserror.WithCode) { + app, err := p.state.DB.GetApplicationByID(ctx, appID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting app %s: %w", appID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if app == nil { + err := gtserror.Newf("app %s not found in the db", appID) + return nil, gtserror.NewErrorNotFound(err) + } + + if app.ManagedByUserID != userID { + err := gtserror.Newf("app %s not managed by user %s", appID, userID) + return nil, gtserror.NewErrorNotFound(err) + } + + // Convert app before deletion. + apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) + if err != nil { + err := gtserror.Newf("error converting app to api app: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Delete app itself. + if err := p.state.DB.DeleteApplicationByID(ctx, appID); err != nil { + err := gtserror.Newf("db error deleting app %s: %w", appID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Delete all tokens owned by app. + if err := p.state.DB.DeleteTokensByClientID(ctx, app.ClientID); err != nil { + err := gtserror.Newf("db error deleting tokens for app %s: %w", appID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Delete all scheduled statuses posted from the app. + if err := p.state.DB.DeleteScheduledStatusesByApplicationID(ctx, appID); err != nil { + err := gtserror.Newf("db error deleting scheduled statuses for app %s: %w", appID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiApp, nil +} diff --git a/internal/processing/application/get.go b/internal/processing/application/get.go new file mode 100644 index 000000000..25cc634a6 --- /dev/null +++ b/internal/processing/application/get.go @@ -0,0 +1,104 @@ +// 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 application + +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) Get( + ctx context.Context, + userID string, + appID string, +) (*apimodel.Application, gtserror.WithCode) { + app, err := p.state.DB.GetApplicationByID(ctx, appID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting app %s: %w", appID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if app == nil { + err := gtserror.Newf("app %s not found in the db", appID) + return nil, gtserror.NewErrorNotFound(err) + } + + if app.ManagedByUserID != userID { + err := gtserror.Newf("app %s not managed by user %s", appID, userID) + return nil, gtserror.NewErrorNotFound(err) + } + + apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) + if err != nil { + err := gtserror.Newf("error converting app to api app: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiApp, nil +} + +func (p *Processor) GetPage( + ctx context.Context, + userID string, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + apps, err := p.state.DB.GetApplicationsManagedByUserID(ctx, userID, page) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting apps: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(apps) + if count == 0 { + return paging.EmptyResponse(), nil + } + + var ( + // Get the lowest and highest + // ID values, used for paging. + lo = apps[count-1].ID + hi = apps[0].ID + + // Best-guess items length. + items = make([]interface{}, 0, count) + ) + + for _, app := range apps { + apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) + if err != nil { + log.Errorf(ctx, "error converting app to api app: %v", err) + continue + } + + // Append req to return items. + items = append(items, apiApp) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/apps", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} diff --git a/internal/processing/common/account.go b/internal/processing/common/account.go index ae26e4ebd..07644e839 100644 --- a/internal/processing/common/account.go +++ b/internal/processing/common/account.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/log" + 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" ) // GetTargetAccountBy fetches the target account with db load function, given the authorized (or, nil) requester's diff --git a/internal/processing/common/common.go b/internal/processing/common/common.go index 29def3506..2b3adb9a0 100644 --- a/internal/processing/common/common.go +++ b/internal/processing/common/common.go @@ -18,22 +18,26 @@ package common import ( - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "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/media" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Processor provides a processor with logic // common to multiple logical domains of the // processing subsection of the codebase. type Processor struct { - state *state.State - media *media.Manager - converter *typeutils.Converter - federator *federation.Federator - visFilter *visibility.Filter + state *state.State + media *media.Manager + converter *typeutils.Converter + federator *federation.Federator + visFilter *visibility.Filter + muteFilter *mutes.Filter + statusFilter *status.Filter } // New returns a new Processor instance. @@ -43,12 +47,16 @@ func New( converter *typeutils.Converter, federator *federation.Federator, visFilter *visibility.Filter, + muteFilter *mutes.Filter, + statusFilter *status.Filter, ) Processor { return Processor{ - state: state, - media: media, - converter: converter, - federator: federator, - visFilter: visFilter, + state: state, + media: media, + converter: converter, + federator: federator, + visFilter: visFilter, + muteFilter: muteFilter, + statusFilter: statusFilter, } } diff --git a/internal/processing/common/media.go b/internal/processing/common/media.go index 7957470cd..e379bfe40 100644 --- a/internal/processing/common/media.go +++ b/internal/processing/common/media.go @@ -22,10 +22,10 @@ import ( "errors" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/media" ) // StoreLocalMedia is a wrapper around CreateMedia() and diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index 01f2ab72d..2bcf89a02 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -21,14 +21,12 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" - statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" - "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/federation/dereferencing" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" ) // GetOwnStatus fetches the given status with ID, @@ -214,9 +212,6 @@ func (p *Processor) GetAPIStatus( apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, - statusfilter.FilterContextNone, - nil, - nil, ) if err != nil { err := gtserror.Newf("error converting: %w", err) @@ -236,9 +231,7 @@ func (p *Processor) GetVisibleAPIStatuses( ctx context.Context, requester *gtsmodel.Account, statuses []*gtsmodel.Status, - filterContext statusfilter.FilterContext, - filters []*gtsmodel.Filter, - userMutes []*gtsmodel.UserMute, + filterCtx gtsmodel.FilterContext, ) []apimodel.Status { // Start new log entry with @@ -247,9 +240,6 @@ func (p *Processor) GetVisibleAPIStatuses( l := log.WithContext(ctx). WithField("caller", log.Caller(3)) - // Compile mutes to useable user mutes for type converter. - compUserMutes := usermute.NewCompiledUserMuteList(userMutes) - // Iterate filtered statuses for conversion to API model. apiStatuses := make([]apimodel.Status, 0, len(statuses)) for _, status := range statuses { @@ -268,24 +258,44 @@ func (p *Processor) GetVisibleAPIStatuses( continue } + // Check whether this status is muted by requesting account. + muted, err := p.muteFilter.StatusMuted(ctx, requester, status) + if err != nil { + log.Errorf(ctx, "error checking mute: %v", err) + continue + } + + if muted { + continue + } + + // Check whether status is filtered in context by requesting account. + filtered, hide, err := p.statusFilter.StatusFilterResultsInContext(ctx, + requester, + status, + filterCtx, + ) + if err != nil { + l.Errorf("error filtering: %v", err) + continue + } + + if hide { + continue + } + // Convert to API status, taking mute / filter into account. apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requester, - filterContext, - filters, - compUserMutes, ) - if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { + if err != nil { l.Errorf("error converting: %v", err) continue } - if apiStatus == nil { - // Status was - // filtered out. - continue - } + // Set filter results on status. + apiStatus.Filtered = filtered // Append converted status to return slice. apiStatuses = append(apiStatuses, *apiStatus) @@ -306,25 +316,10 @@ func (p *Processor) InvalidateTimelinedStatus(ctx context.Context, accountID str return gtserror.Newf("db error getting lists for account %s: %w", accountID, err) } - // Start new log entry with - // the above calling func's name. - l := log. - WithContext(ctx). - WithField("caller", log.Caller(3)). - WithField("accountID", accountID). - WithField("statusID", statusID) - - // Unprepare item from home + list timelines, just log - // if something goes wrong since this is not a showstopper. - - if err := p.state.Timelines.Home.UnprepareItem(ctx, accountID, statusID); err != nil { - l.Errorf("error unpreparing item from home timeline: %v", err) - } - + // Unprepare item from home + list timelines. + p.state.Caches.Timelines.Home.MustGet(accountID).UnprepareByStatusIDs(statusID) for _, list := range lists { - if err := p.state.Timelines.List.UnprepareItem(ctx, list.ID, statusID); err != nil { - l.Errorf("error unpreparing item from list timeline %s: %v", list.ID, err) - } + p.state.Caches.Timelines.List.MustGet(list.ID).UnprepareByStatusIDs(statusID) } return nil diff --git a/internal/processing/conversations/conversations.go b/internal/processing/conversations/conversations.go index d95740605..b80ba659a 100644 --- a/internal/processing/conversations/conversations.go +++ b/internal/processing/conversations/conversations.go @@ -21,31 +21,37 @@ import ( "context" "errors" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/db" + "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { - state *state.State - converter *typeutils.Converter - filter *visibility.Filter + state *state.State + converter *typeutils.Converter + visFilter *visibility.Filter + muteFilter *mutes.Filter + statusFilter *status.Filter } func New( state *state.State, converter *typeutils.Converter, - filter *visibility.Filter, + visFilter *visibility.Filter, + muteFilter *mutes.Filter, + statusFilter *status.Filter, ) Processor { return Processor{ - state: state, - converter: converter, - filter: filter, + state: state, + converter: converter, + visFilter: visFilter, + muteFilter: muteFilter, + statusFilter: statusFilter, } } @@ -93,34 +99,3 @@ func (p *Processor) getConversationOwnedBy( return conversation, nil } - -// getFiltersAndMutes gets the given account's filters and compiled mute list. -func (p *Processor) getFiltersAndMutes( - ctx context.Context, - requestingAccount *gtsmodel.Account, -) ([]*gtsmodel.Filter, *usermute.CompiledUserMuteList, gtserror.WithCode) { - filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) - if err != nil { - return nil, nil, gtserror.NewErrorInternalError( - gtserror.Newf( - "DB error getting filters for account %s: %w", - requestingAccount.ID, - err, - ), - ) - } - - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) - if err != nil { - return nil, nil, gtserror.NewErrorInternalError( - gtserror.Newf( - "DB error getting mutes for account %s: %w", - requestingAccount.ID, - err, - ), - ) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - return filters, compiledMutes, nil -} diff --git a/internal/processing/conversations/conversations_test.go b/internal/processing/conversations/conversations_test.go index 831ba1a43..407623964 100644 --- a/internal/processing/conversations/conversations_test.go +++ b/internal/processing/conversations/conversations_test.go @@ -22,23 +22,25 @@ import ( "testing" "time" + "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/db" + dbtest "code.superseriousbusiness.org/gotosocial/internal/db/test" + "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/log" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/processing/conversations" + "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" - dbtest "github.com/superseriousbusiness/gotosocial/internal/db/test" - "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/log" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/processing/conversations" - "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 ConversationsTestSuite struct { @@ -53,11 +55,11 @@ type ConversationsTestSuite struct { federator *federation.Federator emailSender email.Sender sentEmails map[string]string - filter *visibility.Filter + visFilter *visibility.Filter + muteFilter *mutes.Filter // 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 @@ -76,7 +78,7 @@ type ConversationsTestSuite struct { } func (suite *ConversationsTestSuite) 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) @@ -84,7 +86,6 @@ func (suite *ConversationsTestSuite) getClientMsg(timeout time.Duration) (*messa func (suite *ConversationsTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() @@ -106,13 +107,8 @@ func (suite *ConversationsTestSuite) SetupTest() { suite.state.DB = suite.db suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers) suite.tc = typeutils.NewConverter(&suite.state) - suite.filter = visibility.NewFilter(&suite.state) - - testrig.StartTimelines( - &suite.state, - suite.filter, - suite.tc, - ) + suite.visFilter = visibility.NewFilter(&suite.state) + suite.muteFilter = mutes.NewFilter(&suite.state) suite.storage = testrig.NewInMemoryStorage() suite.state.Storage = suite.storage @@ -123,7 +119,7 @@ func (suite *ConversationsTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) - suite.conversationsProcessor = conversations.New(&suite.state, suite.tc, suite.filter) + suite.conversationsProcessor = conversations.New(&suite.state, suite.tc, suite.visFilter, suite.muteFilter, status.NewFilter(&suite.state)) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") @@ -138,8 +134,8 @@ func (suite *ConversationsTestSuite) TearDownTest() { (*gtsmodel.ConversationToStatus)(nil), } for _, model := range conversationModels { - if err := suite.db.DropTable(context.Background(), model); err != nil { - log.Error(context.Background(), err) + if err := suite.db.DropTable(suite.T().Context(), model); err != nil { + log.Error(suite.T().Context(), err) } } diff --git a/internal/processing/conversations/delete.go b/internal/processing/conversations/delete.go index 5cbdd00a5..0f8281716 100644 --- a/internal/processing/conversations/delete.go +++ b/internal/processing/conversations/delete.go @@ -20,9 +20,9 @@ package conversations import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) func (p *Processor) Delete( diff --git a/internal/processing/conversations/delete_test.go b/internal/processing/conversations/delete_test.go index 23b4f1c1a..d3d8d47a6 100644 --- a/internal/processing/conversations/delete_test.go +++ b/internal/processing/conversations/delete_test.go @@ -17,11 +17,9 @@ package conversations_test -import "context" - func (suite *ConversationsTestSuite) TestDelete() { conversation := suite.NewTestConversation(suite.testAccount, 0) - err := suite.conversationsProcessor.Delete(context.Background(), suite.testAccount, conversation.ID) + err := suite.conversationsProcessor.Delete(suite.T().Context(), suite.testAccount, conversation.ID) suite.NoError(err) } diff --git a/internal/processing/conversations/get.go b/internal/processing/conversations/get.go index 0c7832cae..cdc0756c3 100644 --- a/internal/processing/conversations/get.go +++ b/internal/processing/conversations/get.go @@ -21,13 +21,13 @@ 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/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/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // GetAll returns conversations owned by the given account. @@ -64,23 +64,29 @@ func (p *Processor) GetAll( items := make([]interface{}, 0, count) - filters, mutes, errWithCode := p.getFiltersAndMutes(ctx, requestingAccount) - if errWithCode != nil { - return nil, errWithCode - } - for _, conversation := range conversations { + // Check whether status if filtered by local participant in context. + filtered, hide, err := p.statusFilter.StatusFilterResultsInContext(ctx, + requestingAccount, + conversation.LastStatus, + gtsmodel.FilterContextNotifications, + ) + if err != nil { + log.Errorf(ctx, "error filtering status: %v", err) + continue + } + + if hide { + continue + } + // Convert conversation to frontend API model. - apiConversation, err := p.converter.ConversationToAPIConversation( - ctx, + apiConversation, err := p.converter.ConversationToAPIConversation(ctx, conversation, requestingAccount, - filters, - mutes, ) if err != nil { - log.Errorf( - ctx, + log.Errorf(ctx, "error converting conversation %s to API representation: %v", conversation.ID, err, @@ -88,6 +94,9 @@ func (p *Processor) GetAll( continue } + // Set filter results on attached status model. + apiConversation.LastStatus.Filtered = filtered + // Append conversation to return items. items = append(items, apiConversation) } diff --git a/internal/processing/conversations/get_test.go b/internal/processing/conversations/get_test.go index 7b3d60749..9106c930c 100644 --- a/internal/processing/conversations/get_test.go +++ b/internal/processing/conversations/get_test.go @@ -18,16 +18,15 @@ package conversations_test import ( - "context" "time" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" ) func (suite *ConversationsTestSuite) TestGetAll() { conversation := suite.NewTestConversation(suite.testAccount, 0) - resp, err := suite.conversationsProcessor.GetAll(context.Background(), suite.testAccount, nil) + resp, err := suite.conversationsProcessor.GetAll(suite.T().Context(), suite.testAccount, nil) if suite.NoError(err) && suite.Len(resp.Items, 1) && suite.IsType((*apimodel.Conversation)(nil), resp.Items[0]) { apiConversation := resp.Items[0].(*apimodel.Conversation) suite.Equal(conversation.ID, apiConversation.ID) @@ -46,11 +45,11 @@ func (suite *ConversationsTestSuite) TestGetAllOrder() { // Add an even newer status than that to conversation1. conversation1Status2 := suite.NewTestStatus(suite.testAccount, conversation1.LastStatus.ThreadID, 2*time.Second, conversation1.LastStatus) conversation1.LastStatusID = conversation1Status2.ID - if err := suite.db.UpsertConversation(context.Background(), conversation1, "last_status_id"); err != nil { + if err := suite.db.UpsertConversation(suite.T().Context(), conversation1, "last_status_id"); err != nil { suite.FailNow(err.Error()) } - resp, err := suite.conversationsProcessor.GetAll(context.Background(), suite.testAccount, nil) + resp, err := suite.conversationsProcessor.GetAll(suite.T().Context(), suite.testAccount, nil) if suite.NoError(err) && suite.Len(resp.Items, 2) { // conversation1 should be the first conversation returned. apiConversation1 := resp.Items[0].(*apimodel.Conversation) diff --git a/internal/processing/conversations/migrate.go b/internal/processing/conversations/migrate.go index 959ffcca4..902024944 100644 --- a/internal/processing/conversations/migrate.go +++ b/internal/processing/conversations/migrate.go @@ -22,12 +22,12 @@ import ( "encoding/json" "errors" - "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/util" + "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/util" ) const advancedMigrationID = "20240611190733_add_conversations" diff --git a/internal/processing/conversations/migrate_test.go b/internal/processing/conversations/migrate_test.go index b625e59ba..a6e506e49 100644 --- a/internal/processing/conversations/migrate_test.go +++ b/internal/processing/conversations/migrate_test.go @@ -18,18 +18,16 @@ package conversations_test import ( - "context" - - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/bundb" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/db/bundb" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // Test that we can migrate DMs to conversations. // This test assumes that we're using the standard test fixtures, which contain some conversation-eligible DMs. func (suite *ConversationsTestSuite) TestMigrateDMsToConversations() { advancedMigrationID := "20240611190733_add_conversations" - ctx := context.Background() + ctx := suite.T().Context() rawDB := (suite.db).(*bundb.DBService).DB() // Precondition: we shouldn't have any conversations yet. diff --git a/internal/processing/conversations/read.go b/internal/processing/conversations/read.go index 512a004a3..c7e4f1acd 100644 --- a/internal/processing/conversations/read.go +++ b/internal/processing/conversations/read.go @@ -20,10 +20,11 @@ package conversations 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/util" + 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" + "code.superseriousbusiness.org/gotosocial/internal/util" ) func (p *Processor) Read( @@ -44,22 +45,27 @@ func (p *Processor) Read( return nil, gtserror.NewErrorInternalError(err) } - filters, mutes, errWithCode := p.getFiltersAndMutes(ctx, requestingAccount) - if errWithCode != nil { - return nil, errWithCode + // Check whether status if filtered by local participant in context. + filtered, _, err := p.statusFilter.StatusFilterResultsInContext(ctx, + requestingAccount, + conversation.LastStatus, + gtsmodel.FilterContextNotifications, + ) + if err != nil { + log.Errorf(ctx, "error filtering status: %v", err) } - apiConversation, err := p.converter.ConversationToAPIConversation( - ctx, + apiConversation, err := p.converter.ConversationToAPIConversation(ctx, conversation, requestingAccount, - filters, - mutes, ) if err != nil { err = gtserror.Newf("error converting conversation %s to API representation: %w", id, err) return nil, gtserror.NewErrorInternalError(err) } + // Set filter results on attached status model. + apiConversation.LastStatus.Filtered = filtered + return apiConversation, nil } diff --git a/internal/processing/conversations/read_test.go b/internal/processing/conversations/read_test.go index ebd8f7fe5..a15ddcca3 100644 --- a/internal/processing/conversations/read_test.go +++ b/internal/processing/conversations/read_test.go @@ -18,16 +18,14 @@ package conversations_test import ( - "context" - - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/util" ) func (suite *ConversationsTestSuite) TestRead() { conversation := suite.NewTestConversation(suite.testAccount, 0) suite.False(util.PtrOrValue(conversation.Read, false)) - apiConversation, err := suite.conversationsProcessor.Read(context.Background(), suite.testAccount, conversation.ID) + apiConversation, err := suite.conversationsProcessor.Read(suite.T().Context(), suite.testAccount, conversation.ID) if suite.NoError(err) { suite.False(apiConversation.Unread) } diff --git a/internal/processing/conversations/update.go b/internal/processing/conversations/update.go index 7445994ae..21f1cf915 100644 --- a/internal/processing/conversations/update.go +++ b/internal/processing/conversations/update.go @@ -21,20 +21,23 @@ 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/id" - "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/id" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/util" ) -// ConversationNotification carries the arguments to processing/stream.Processor.Conversation. +// ConversationNotification carries the arguments +// to processing/stream.Processor.Conversation. type ConversationNotification struct { - // AccountID of a local account to deliver the notification to. + + // AccountID of a local account to + // deliver the notification to. AccountID string + // Conversation as the notification payload. Conversation *apimodel.Conversation } @@ -46,11 +49,13 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt // Only DMs are considered part of conversations. return nil, nil } + if status.BoostOfID != "" { // Boosts can't be part of conversations. // FUTURE: This may change if we ever implement quote posts. return nil, nil } + if status.ThreadID == "" { // If the status doesn't have a thread ID, it didn't mention a local account, // and thus can't be part of a conversation. @@ -77,51 +82,15 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt } localAccount := participant - // If the status is not visible to this account, skip processing it for this account. - visible, err := p.filter.StatusVisible(ctx, localAccount, status) + // If status not visible to this account, skip further processing. + visible, err := p.visFilter.StatusVisible(ctx, localAccount, status) if err != nil { - log.Errorf( - ctx, - "error checking status %s visibility for account %s: %v", - status.ID, - localAccount.ID, - err, - ) + log.Errorf(ctx, "error checking status %s visibility for account %s: %v", status.URI, localAccount.URI, err) continue } else if !visible { continue } - // Is the status filtered or muted for this user? - // Converting the status to an API status runs the filter/mute checks. - filters, mutes, errWithCode := p.getFiltersAndMutes(ctx, localAccount) - if errWithCode != nil { - log.Error(ctx, errWithCode) - continue - } - _, err = p.converter.StatusToAPIStatus( - ctx, - status, - localAccount, - statusfilter.FilterContextNotifications, - filters, - mutes, - ) - if err != nil { - // If the status matched a hide filter, skip processing it for this account. - // If there was another kind of error, log that and skip it anyway. - if !errors.Is(err, statusfilter.ErrHideStatus) { - log.Errorf( - ctx, - "error checking status %s filtering/muting for account %s: %v", - status.ID, - localAccount.ID, - err, - ) - } - continue - } - // Collect other accounts participating in the conversation. otherAccounts := make([]*gtsmodel.Account, 0, len(allParticipantsSet)-1) otherAccountIDs := make([]string, 0, len(allParticipantsSet)-1) @@ -133,20 +102,14 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt } // Check for a previously existing conversation, if there is one. - conversation, err := p.state.DB.GetConversationByThreadAndAccountIDs( - ctx, + conversation, err := p.state.DB.GetConversationByThreadAndAccountIDs(ctx, status.ThreadID, localAccount.ID, otherAccountIDs, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - log.Errorf( - ctx, - "error trying to find a previous conversation for status %s and account %s: %v", - status.ID, - localAccount.ID, - err, - ) + log.Errorf(ctx, "error finding previous conversation for status %s and account %s: %v", + status.URI, localAccount.URI, err) continue } @@ -172,6 +135,7 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt conversation.LastStatusID = status.ID conversation.LastStatus = status } + // If the conversation is unread, leave it marked as unread. // If the conversation is read but this status might not have been, mark the conversation as unread. if !statusAuthoredByConversationOwner { @@ -181,61 +145,75 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt // Create or update the conversation. err = p.state.DB.UpsertConversation(ctx, conversation) if err != nil { - log.Errorf( - ctx, - "error creating or updating conversation %s for status %s and account %s: %v", - conversation.ID, - status.ID, - localAccount.ID, - err, - ) + log.Errorf(ctx, "error creating or updating conversation %s for status %s and account %s: %v", + conversation.ID, status.URI, localAccount.URI, err) continue } // Link the conversation to the status. if err := p.state.DB.LinkConversationToStatus(ctx, conversation.ID, status.ID); err != nil { - log.Errorf( - ctx, - "error linking conversation %s to status %s: %v", - conversation.ID, - status.ID, - err, - ) + log.Errorf(ctx, "error linking conversation %s to status %s: %v", + conversation.ID, status.URI, err) + continue + } + + // If status was authored by this participant, + // don't bother notifying, they already know! + if status.AccountID == localAccount.ID { + continue + } + + // Check whether status is muted to local participant. + muted, err := p.muteFilter.StatusNotificationsMuted(ctx, + localAccount, + status, + ) + if err != nil { + log.Errorf(ctx, "error checking status mute: %v", err) + continue + } + + if muted { + continue + } + + // Check whether status if filtered by local participant in context. + filtered, hide, err := p.statusFilter.StatusFilterResultsInContext(ctx, + localAccount, + status, + gtsmodel.FilterContextNotifications, + ) + if err != nil { + log.Errorf(ctx, "error filtering status: %v", err) + continue + } + + if hide { continue } // Convert the conversation to API representation. - apiConversation, err := p.converter.ConversationToAPIConversation( - ctx, + apiConversation, err := p.converter.ConversationToAPIConversation(ctx, conversation, localAccount, - filters, - mutes, ) if err != nil { - // If the conversation's last status matched a hide filter, skip it. - // If there was another kind of error, log that and skip it anyway. - if !errors.Is(err, statusfilter.ErrHideStatus) { - log.Errorf( - ctx, - "error converting conversation %s to API representation for account %s: %v", - status.ID, - localAccount.ID, - err, - ) - } + log.Errorf(ctx, "error converting conversation %s to API representation for account %s: %v", + status.ID, + localAccount.ID, + err, + ) continue } + // Set filter results on attached status model. + apiConversation.LastStatus.Filtered = filtered + // Generate a notification, - // unless the status was authored by the user who would be notified, - // in which case they already know. - if status.AccountID != localAccount.ID { - notifications = append(notifications, ConversationNotification{ - AccountID: localAccount.ID, - Conversation: apiConversation, - }) - } + notifications = append(notifications, ConversationNotification{ + AccountID: localAccount.ID, + Conversation: apiConversation, + }) } return notifications, nil diff --git a/internal/processing/conversations/update_test.go b/internal/processing/conversations/update_test.go index 8ba2800fe..b547fae7c 100644 --- a/internal/processing/conversations/update_test.go +++ b/internal/processing/conversations/update_test.go @@ -17,13 +17,9 @@ package conversations_test -import ( - "context" -) - // Test that we can create conversations when a new status comes in. func (suite *ConversationsTestSuite) TestUpdateConversationsForStatus() { - ctx := context.Background() + ctx := suite.T().Context() // Precondition: the test user shouldn't have any conversations yet. conversations, err := suite.db.GetConversationsByOwnerAccountID(ctx, suite.testAccount.ID, nil) diff --git a/internal/processing/fedi/accept.go b/internal/processing/fedi/accept.go deleted file mode 100644 index fc699ee08..000000000 --- a/internal/processing/fedi/accept.go +++ /dev/null @@ -1,85 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. - -package fedi - -import ( - "context" - "errors" - - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// AcceptGet handles the getting of a fedi/activitypub -// representation of a local interaction acceptance. -// -// It performs appropriate authentication before -// returning a JSON serializable interface. -func (p *Processor) AcceptGet( - ctx context.Context, - requestedUser string, - reqID string, -) (interface{}, gtserror.WithCode) { - // Authenticate incoming request, getting related accounts. - auth, errWithCode := p.authenticate(ctx, requestedUser) - if errWithCode != nil { - return nil, errWithCode - } - - if auth.handshakingURI != nil { - // We're currently handshaking, which means - // we don't know this account yet. This should - // be a very rare race condition. - err := gtserror.Newf("network race handshaking %s", auth.handshakingURI) - return nil, gtserror.NewErrorInternalError(err) - } - - receivingAcct := auth.receivingAcct - - req, err := p.state.DB.GetInteractionRequestByID(ctx, reqID) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error getting interaction request %s: %w", reqID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - if req == nil || !req.IsAccepted() { - // Request doesn't exist or hasn't been accepted. - err := gtserror.Newf("interaction request %s not found", reqID) - return nil, gtserror.NewErrorNotFound(err) - } - - if req.TargetAccountID != receivingAcct.ID { - const text = "interaction request does not belong to receiving account" - return nil, gtserror.NewErrorNotFound(errors.New(text)) - } - - accept, err := p.converter.InteractionReqToASAccept(ctx, req) - if err != nil { - err := gtserror.Newf("error converting accept: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - data, err := ap.Serialize(accept) - if err != nil { - err := gtserror.Newf("error serializing accept: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - return data, nil -} diff --git a/internal/processing/fedi/authorization.go b/internal/processing/fedi/authorization.go new file mode 100644 index 000000000..276dd3a82 --- /dev/null +++ b/internal/processing/fedi/authorization.go @@ -0,0 +1,142 @@ +// 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 fedi + +import ( + "context" + "errors" + "fmt" + + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" +) + +// AuthorizationGet handles the getting of a fedi/activitypub +// representation of a local interaction authorization. +// +// It performs appropriate authentication before +// returning a JSON serializable interface. +func (p *Processor) AuthorizationGet( + ctx context.Context, + requestedUser string, + intReqID string, +) (any, gtserror.WithCode) { + // Ensure valid request, intReq exists, etc. + intReq, errWithCode := p.validateAuthGetRequest(ctx, requestedUser, intReqID) + if errWithCode != nil { + return nil, errWithCode + } + + // Convert + serialize the Authorization. + authorization, err := p.converter.InteractionReqToASAuthorization(ctx, intReq) + if err != nil { + err := gtserror.Newf("error converting to authorization: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + data, err := ap.Serialize(authorization) + if err != nil { + err := gtserror.Newf("error serializing accept: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return data, nil +} + +// AcceptGet handles the getting of a fedi/activitypub +// representation of a local interaction acceptance. +// +// It performs appropriate authentication before +// returning a JSON serializable interface. +func (p *Processor) AcceptGet( + ctx context.Context, + requestedUser string, + intReqID string, +) (any, gtserror.WithCode) { + // Ensure valid request, intReq exists, etc. + intReq, errWithCode := p.validateAuthGetRequest(ctx, requestedUser, intReqID) + if errWithCode != nil { + return nil, errWithCode + } + + // Convert + serialize the Accept. + accept, err := p.converter.InteractionReqToASAccept(ctx, intReq) + if err != nil { + err := gtserror.Newf("error converting to accept: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + data, err := ap.Serialize(accept) + if err != nil { + err := gtserror.Newf("error serializing accept: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return data, nil +} + +// validateAuthGetRequest is a shortcut function +// for returning an accepted interaction request +// targeting `requestedUser`. +func (p *Processor) validateAuthGetRequest( + ctx context.Context, + requestedUser string, + intReqID string, +) (*gtsmodel.InteractionRequest, gtserror.WithCode) { + // Authenticate incoming request, getting related accounts. + auth, errWithCode := p.authenticate(ctx, requestedUser) + if errWithCode != nil { + return nil, errWithCode + } + + if auth.handshakingURI != nil { + // We're currently handshaking, which means we don't know + // this account yet. This should be a very rare race condition. + err := gtserror.Newf("network race handshaking %s", auth.handshakingURI) + return nil, gtserror.NewErrorInternalError(err) + } + + // Fetch interaction request with the given ID. + req, err := p.state.DB.GetInteractionRequestByID(ctx, intReqID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting interaction request %s: %w", intReqID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Ensure that this is an existing + // and *accepted* interaction request. + if req == nil || !req.IsAccepted() { + const text = "interaction request not found" + return nil, gtserror.NewErrorNotFound(errors.New(text)) + } + + // Ensure interaction request was accepted + // by the account in the request path. + if req.TargetAccountID != auth.receiver.ID { + text := fmt.Sprintf( + "account %s is not targeted by interaction request %s and therefore can't accept it", + requestedUser, intReqID, + ) + return nil, gtserror.NewErrorNotFound(errors.New(text)) + } + + // All fine. + return req, nil +} diff --git a/internal/processing/fedi/collections.go b/internal/processing/fedi/collections.go index fd84e7688..b67651dff 100644 --- a/internal/processing/fedi/collections.go +++ b/internal/processing/fedi/collections.go @@ -23,14 +23,14 @@ import ( "net/http" "net/url" - "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/paging" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/activity/streams/vocab" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // InboxPost handles POST requests to a user's inbox for new activitypub messages. @@ -54,24 +54,24 @@ func (p *Processor) OutboxGet( ctx context.Context, requestedUser string, page *paging.Page, -) (interface{}, gtserror.WithCode) { +) (any, gtserror.WithCode) { // Authenticate incoming request, getting related accounts. auth, errWithCode := p.authenticate(ctx, requestedUser) if errWithCode != nil { return nil, errWithCode } - receivingAcct := auth.receivingAcct + receiver := auth.receiver // Parse the collection ID object from account's followers URI. - collectionID, err := url.Parse(receivingAcct.OutboxURI) + collectionID, err := url.Parse(receiver.OutboxURI) if err != nil { - err := gtserror.Newf("error parsing account outbox uri %s: %w", receivingAcct.OutboxURI, err) + err := gtserror.Newf("error parsing account outbox uri %s: %w", receiver.OutboxURI, err) return nil, gtserror.NewErrorInternalError(err) } // Ensure we have stats for this account. - if err := p.state.DB.PopulateAccountStats(ctx, receivingAcct); err != nil { - err := gtserror.Newf("error getting stats for account %s: %w", receivingAcct.ID, err) + if err := p.state.DB.PopulateAccountStats(ctx, receiver); err != nil { + err := gtserror.Newf("error getting stats for account %s: %w", receiver.ID, err) return nil, gtserror.NewErrorInternalError(err) } @@ -83,8 +83,8 @@ func (p *Processor) OutboxGet( switch { - case *receivingAcct.Settings.HideCollections || - receivingAcct.IsInstance(): + case receiver.IsInstance() || + *receiver.Settings.HideCollections: // If account that hides collections, or instance // account (ie., can't post / have relationships), // just return barest stub of collection. @@ -94,7 +94,7 @@ func (p *Processor) OutboxGet( // If paging disabled, or we're currently handshaking // the requester, just return collection that links // to first page (i.e. path below), with no items. - params.Total = util.Ptr(*receivingAcct.Stats.StatusesCount) + params.Total = util.Ptr(*receiver.Stats.StatusesCount) params.First = new(paging.Page) params.Query = make(url.Values, 1) params.Query.Set("limit", "40") // enables paging @@ -105,7 +105,7 @@ func (p *Processor) OutboxGet( // Get page of full public statuses. statuses, err := p.state.DB.GetAccountStatuses( ctx, - receivingAcct.ID, + receiver.ID, page.GetLimit(), // limit true, // excludeReplies true, // excludeReblogs @@ -133,7 +133,7 @@ func (p *Processor) OutboxGet( // (eg., local-only statuses, if the requester is remote). statuses, err = p.visFilter.StatusesVisible( ctx, - auth.requestingAcct, + auth.requester, statuses, ) if err != nil { @@ -142,7 +142,7 @@ func (p *Processor) OutboxGet( } // Start building AS collection page params. - params.Total = util.Ptr(*receivingAcct.Stats.StatusesCount) + params.Total = util.Ptr(*receiver.Stats.StatusesCount) var pageParams ap.CollectionPageParams pageParams.CollectionParams = params @@ -194,24 +194,24 @@ func (p *Processor) FollowersGet( ctx context.Context, requestedUser string, page *paging.Page, -) (interface{}, gtserror.WithCode) { +) (any, gtserror.WithCode) { // Authenticate incoming request, getting related accounts. auth, errWithCode := p.authenticate(ctx, requestedUser) if errWithCode != nil { return nil, errWithCode } - receivingAcct := auth.receivingAcct + receiver := auth.receiver // Parse the collection ID object from account's followers URI. - collectionID, err := url.Parse(receivingAcct.FollowersURI) + collectionID, err := url.Parse(receiver.FollowersURI) if err != nil { - err := gtserror.Newf("error parsing account followers uri %s: %w", receivingAcct.FollowersURI, err) + err := gtserror.Newf("error parsing account followers uri %s: %w", receiver.FollowersURI, err) return nil, gtserror.NewErrorInternalError(err) } // Ensure we have stats for this account. - if err := p.state.DB.PopulateAccountStats(ctx, receivingAcct); err != nil { - err := gtserror.Newf("error getting stats for account %s: %w", receivingAcct.ID, err) + if err := p.state.DB.PopulateAccountStats(ctx, receiver); err != nil { + err := gtserror.Newf("error getting stats for account %s: %w", receiver.ID, err) return nil, gtserror.NewErrorInternalError(err) } @@ -223,8 +223,8 @@ func (p *Processor) FollowersGet( switch { - case receivingAcct.IsInstance() || - *receivingAcct.Settings.HideCollections: + case receiver.IsInstance() || + *receiver.Settings.HideCollections: // If account that hides collections, or instance // account (ie., can't post / have relationships), // just return barest stub of collection. @@ -234,7 +234,7 @@ func (p *Processor) FollowersGet( // If paging disabled, or we're currently handshaking // the requester, just return collection that links // to first page (i.e. path below), with no items. - params.Total = util.Ptr(*receivingAcct.Stats.FollowersCount) + params.Total = util.Ptr(*receiver.Stats.FollowersCount) params.First = new(paging.Page) params.Query = make(url.Values, 1) params.Query.Set("limit", "40") // enables paging @@ -243,7 +243,7 @@ func (p *Processor) FollowersGet( default: // Paging enabled. // Get page of full follower objects with attached accounts. - followers, err := p.state.DB.GetAccountFollowers(ctx, receivingAcct.ID, page) + followers, err := p.state.DB.GetAccountFollowers(ctx, receiver.ID, page) if err != nil { err := gtserror.Newf("error getting followers: %w", err) return nil, gtserror.NewErrorInternalError(err) @@ -260,7 +260,7 @@ func (p *Processor) FollowersGet( } // Start building AS collection page params. - params.Total = util.Ptr(*receivingAcct.Stats.FollowersCount) + params.Total = util.Ptr(*receiver.Stats.FollowersCount) var pageParams ap.CollectionPageParams pageParams.CollectionParams = params @@ -306,24 +306,24 @@ func (p *Processor) FollowersGet( // FollowingGet returns the serialized ActivityPub // collection of a local account's following collection, // which contains links to accounts followed by this account. -func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page *paging.Page) (interface{}, gtserror.WithCode) { +func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page *paging.Page) (any, gtserror.WithCode) { // Authenticate incoming request, getting related accounts. auth, errWithCode := p.authenticate(ctx, requestedUser) if errWithCode != nil { return nil, errWithCode } - receivingAcct := auth.receivingAcct + receiver := auth.receiver // Parse collection ID from account's following URI. - collectionID, err := url.Parse(receivingAcct.FollowingURI) + collectionID, err := url.Parse(receiver.FollowingURI) if err != nil { - err := gtserror.Newf("error parsing account following uri %s: %w", receivingAcct.FollowingURI, err) + err := gtserror.Newf("error parsing account following uri %s: %w", receiver.FollowingURI, err) return nil, gtserror.NewErrorInternalError(err) } // Ensure we have stats for this account. - if err := p.state.DB.PopulateAccountStats(ctx, receivingAcct); err != nil { - err := gtserror.Newf("error getting stats for account %s: %w", receivingAcct.ID, err) + if err := p.state.DB.PopulateAccountStats(ctx, receiver); err != nil { + err := gtserror.Newf("error getting stats for account %s: %w", receiver.ID, err) return nil, gtserror.NewErrorInternalError(err) } @@ -334,8 +334,8 @@ func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page params.ID = collectionID switch { - case receivingAcct.IsInstance() || - *receivingAcct.Settings.HideCollections: + case receiver.IsInstance() || + *receiver.Settings.HideCollections: // If account that hides collections, or instance // account (ie., can't post / have relationships), // just return barest stub of collection. @@ -345,7 +345,7 @@ func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page // If paging disabled, or we're currently handshaking // the requester, just return collection that links // to first page (i.e. path below), with no items. - params.Total = util.Ptr(*receivingAcct.Stats.FollowingCount) + params.Total = util.Ptr(*receiver.Stats.FollowingCount) params.First = new(paging.Page) params.Query = make(url.Values, 1) params.Query.Set("limit", "40") // enables paging @@ -354,7 +354,7 @@ func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page default: // Paging enabled. // Get page of full follower objects with attached accounts. - follows, err := p.state.DB.GetAccountFollows(ctx, receivingAcct.ID, page) + follows, err := p.state.DB.GetAccountFollows(ctx, receiver.ID, page) if err != nil { err := gtserror.Newf("error getting follows: %w", err) return nil, gtserror.NewErrorInternalError(err) @@ -371,7 +371,7 @@ func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page } // Start AS collection page params. - params.Total = util.Ptr(*receivingAcct.Stats.FollowingCount) + params.Total = util.Ptr(*receiver.Stats.FollowingCount) var pageParams ap.CollectionPageParams pageParams.CollectionParams = params @@ -416,28 +416,29 @@ func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page // FeaturedCollectionGet returns an ordered collection of the requested username's Pinned posts. // The returned collection have an `items` property which contains an ordered list of status URIs. -func (p *Processor) FeaturedCollectionGet(ctx context.Context, requestedUser string) (interface{}, gtserror.WithCode) { +func (p *Processor) FeaturedCollectionGet(ctx context.Context, requestedUser string) (any, gtserror.WithCode) { // Authenticate incoming request, getting related accounts. auth, errWithCode := p.authenticate(ctx, requestedUser) if errWithCode != nil { return nil, errWithCode } - receivingAcct := auth.receivingAcct + receiver := auth.receiver - statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, receivingAcct.ID) - if err != nil { - if !errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorInternalError(err) - } + statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, receiver.ID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting pinned statuses: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - collection, err := p.converter.StatusesToASFeaturedCollection(ctx, receivingAcct.FeaturedCollectionURI, statuses) + collection, err := p.converter.StatusesToASFeaturedCollection(ctx, receiver.FeaturedCollectionURI, statuses) if err != nil { + err := gtserror.Newf("error converting pinned statuses: %w", err) return nil, gtserror.NewErrorInternalError(err) } data, err := ap.Serialize(collection) if err != nil { + err := gtserror.Newf("error serializing: %w", err) return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/fedi/common.go b/internal/processing/fedi/common.go index 1a4d38bc1..ff6ed6fd4 100644 --- a/internal/processing/fedi/common.go +++ b/internal/processing/fedi/common.go @@ -22,28 +22,30 @@ import ( "errors" "net/url" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) type commonAuth struct { handshakingURI *url.URL // Set to requestingAcct's URI if we're currently handshaking them. - requestingAcct *gtsmodel.Account // Remote account making request to this instance. - receivingAcct *gtsmodel.Account // Local account receiving the request. + requester *gtsmodel.Account // Remote account making request to this instance. + receiver *gtsmodel.Account // Local account receiving the request. } +// authenticate is a util function for authenticating a signed GET +// request to one of the AP/fedi resources handled in this package. func (p *Processor) authenticate(ctx context.Context, requestedUser string) (*commonAuth, gtserror.WithCode) { - // First get the requested (receiving) LOCAL account with username from database. + // Get the requested local account + // with given username from database. receiver, err := p.state.DB.GetAccountByUsernameDomain(ctx, requestedUser, "") - if err != nil { - if !errors.Is(err, db.ErrNoEntries) { - // Real db error. - err = gtserror.Newf("db error getting account %s: %w", requestedUser, err) - return nil, gtserror.NewErrorInternalError(err) - } + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = gtserror.Newf("db error getting account %s: %w", requestedUser, err) + return nil, gtserror.NewErrorInternalError(err) + } - // Account just not found in the db. + if receiver == nil { + err := gtserror.Newf("account %s not found in the db", requestedUser) return nil, gtserror.NewErrorNotFound(err) } @@ -59,25 +61,44 @@ func (p *Processor) authenticate(ctx context.Context, requestedUser string) (*co // don't know the requester yet. return &commonAuth{ handshakingURI: pubKeyAuth.OwnerURI, - receivingAcct: receiver, + receiver: receiver, }, nil } // Get requester from auth. requester := pubKeyAuth.Owner - // Ensure block does not exist between receiver and requester. - blocked, err := p.state.DB.IsEitherBlocked(ctx, receiver.ID, requester.ID) + // Check if requester is suspended. + switch { + case !requester.IsSuspended(): + // No problem. + + case requester.DeletedSelf(): + // Requester deleted their own account. + // Why are they now requesting something? + err := gtserror.Newf("requester %s self-deleted", requester.UsernameDomain()) + return nil, gtserror.NewErrorUnauthorized(err) + + default: + // Admin from our instance likely suspended account. + err := gtserror.Newf("requester %s is suspended", requester.UsernameDomain()) + return nil, gtserror.NewErrorForbidden(err) + } + + // Ensure receiver does not block requester. + blocked, err := p.state.DB.IsBlocked(ctx, receiver.ID, requester.ID) if err != nil { - err := gtserror.Newf("error checking block: %w", err) + err := gtserror.Newf("db error checking block: %w", err) return nil, gtserror.NewErrorInternalError(err) - } else if blocked { - const text = "block exists between accounts" + } + + if blocked { + var text = requestedUser + " blocks " + requester.Username return nil, gtserror.NewErrorForbidden(errors.New(text)) } return &commonAuth{ - requestingAcct: requester, - receivingAcct: receiver, + requester: requester, + receiver: receiver, }, nil } diff --git a/internal/processing/fedi/emoji.go b/internal/processing/fedi/emoji.go index 9ac0ea244..e7e3ec406 100644 --- a/internal/processing/fedi/emoji.go +++ b/internal/processing/fedi/emoji.go @@ -19,38 +19,69 @@ package fedi import ( "context" - "fmt" + "errors" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" ) -// EmojiGet handles the GET for a federated emoji originating from this instance. -func (p *Processor) EmojiGet(ctx context.Context, requestedEmojiID string) (interface{}, gtserror.WithCode) { - if _, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, ""); errWithCode != nil { +// EmojiGet handles the GET for an emoji originating from this instance. +func (p *Processor) EmojiGet(ctx context.Context, emojiID string) (any, gtserror.WithCode) { + // Authenticate incoming request. + // + // Pass hostname string to this function to indicate + // it's the instance account being requested, as + // emojis are always owned by the instance account. + auth, errWithCode := p.authenticate(ctx, config.GetHost()) + if errWithCode != nil { return nil, errWithCode } - requestedEmoji, err := p.state.DB.GetEmojiByID(ctx, requestedEmojiID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting emoji with id %s: %s", requestedEmojiID, err)) + if auth.handshakingURI != nil { + // We're currently handshaking, which means + // we don't know this account yet. This should + // be a very rare race condition. + err := gtserror.Newf("network race handshaking %s", auth.handshakingURI) + return nil, gtserror.NewErrorInternalError(err) + } + + // Get the requested emoji. + emoji, err := p.state.DB.GetEmojiByID(ctx, emojiID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting emoji %s: %w", emojiID, err) + return nil, gtserror.NewErrorNotFound(err) } - if !requestedEmoji.IsLocal() { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji with id %s doesn't belong to this instance (domain %s)", requestedEmojiID, requestedEmoji.Domain)) + if emoji == nil { + err := gtserror.Newf("emoji %s not found in the db", emojiID) + return nil, gtserror.NewErrorNotFound(err) } - if *requestedEmoji.Disabled { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji with id %s has been disabled", requestedEmojiID)) + // Only serve *our* + // emojis on this path. + if !emoji.IsLocal() { + err := gtserror.Newf("emoji %s doesn't belong to this instance (domain is %s)", emojiID, emoji.Domain) + return nil, gtserror.NewErrorNotFound(err) } - apEmoji, err := p.converter.EmojiToAS(ctx, requestedEmoji) + // Don't serve emojis that have + // been disabled by an admin. + if *emoji.Disabled { + err := gtserror.Newf("emoji with id %s has been disabled by an admin", emojiID) + return nil, gtserror.NewErrorNotFound(err) + } + + apEmoji, err := p.converter.EmojiToAS(ctx, emoji) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting gtsmodel emoji with id %s to ap emoji: %s", requestedEmojiID, err)) + err := gtserror.Newf("error converting emoji %s to ap: %s", emojiID, err) + return nil, gtserror.NewErrorInternalError(err) } data, err := ap.Serialize(apEmoji) if err != nil { + err := gtserror.Newf("error serializing emoji %s: %w", emojiID, err) return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/fedi/fedi.go b/internal/processing/fedi/fedi.go index 52a9d70bf..d770f3927 100644 --- a/internal/processing/fedi/fedi.go +++ b/internal/processing/fedi/fedi.go @@ -18,11 +18,11 @@ package fedi import ( - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/fedi/status.go b/internal/processing/fedi/status.go index fe07b5a95..d1de6f4c1 100644 --- a/internal/processing/fedi/status.go +++ b/internal/processing/fedi/status.go @@ -24,18 +24,23 @@ import ( "slices" "strconv" - "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/paging" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/activity/streams/vocab" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "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" ) -// StatusGet handles the getting of a fedi/activitypub representation of a local status. +// StatusGet handles getting an AP representation of a local status. // It performs appropriate authentication before returning a JSON serializable interface. -func (p *Processor) StatusGet(ctx context.Context, requestedUser string, statusID string) (interface{}, gtserror.WithCode) { +func (p *Processor) StatusGet( + ctx context.Context, + requestedUser string, + statusID string, +) (any, gtserror.WithCode) { // Authenticate incoming request, getting related accounts. auth, errWithCode := p.authenticate(ctx, requestedUser) if errWithCode != nil { @@ -49,16 +54,23 @@ func (p *Processor) StatusGet(ctx context.Context, requestedUser string, statusI err := gtserror.Newf("network race handshaking %s", auth.handshakingURI) return nil, gtserror.NewErrorInternalError(err) } - - receivingAcct := auth.receivingAcct - requestingAcct := auth.requestingAcct + receiver := auth.receiver + requester := auth.requester status, err := p.state.DB.GetStatusByID(ctx, statusID) - if err != nil { + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if status == nil { + // TODO: Update this to serve "gone" + // when a status has been deleted. + err := gtserror.Newf("status %s not found in the db", statusID) return nil, gtserror.NewErrorNotFound(err) } - if status.AccountID != receivingAcct.ID { + if status.AccountID != receiver.ID { const text = "status does not belong to receiving account" return nil, gtserror.NewErrorNotFound(errors.New(text)) } @@ -68,7 +80,7 @@ func (p *Processor) StatusGet(ctx context.Context, requestedUser string, statusI return nil, gtserror.NewErrorNotFound(errors.New(text)) } - visible, err := p.visFilter.StatusVisible(ctx, requestingAcct, status) + visible, err := p.visFilter.StatusVisible(ctx, requester, status) if err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -93,7 +105,7 @@ func (p *Processor) StatusGet(ctx context.Context, requestedUser string, statusI return data, nil } -// GetStatus handles the getting of a fedi/activitypub representation of replies to a status, +// GetStatus handles getting an AP representation of replies to a status, // performing appropriate authentication before returning a JSON serializable interface to the caller. func (p *Processor) StatusRepliesGet( ctx context.Context, @@ -101,7 +113,7 @@ func (p *Processor) StatusRepliesGet( statusID string, page *paging.Page, onlyOtherAccounts bool, -) (interface{}, gtserror.WithCode) { +) (any, gtserror.WithCode) { // Authenticate incoming request, getting related accounts. auth, errWithCode := p.authenticate(ctx, requestedUser) if errWithCode != nil { @@ -116,8 +128,8 @@ func (p *Processor) StatusRepliesGet( return nil, gtserror.NewErrorInternalError(err) } - receivingAcct := auth.receivingAcct - requestingAcct := auth.requestingAcct + receivingAcct := auth.receiver + requestingAcct := auth.requester // Get target status and ensure visible to requester. status, errWithCode := p.c.GetVisibleTargetStatus(ctx, diff --git a/internal/processing/fedi/user.go b/internal/processing/fedi/user.go index 79c1b4fdb..9fb338673 100644 --- a/internal/processing/fedi/user.go +++ b/internal/processing/fedi/user.go @@ -20,96 +20,83 @@ package fedi import ( "context" "errors" - "fmt" - "net/url" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" ) -// UserGet handles the getting of a fedi/activitypub representation of a user/account, -// performing authentication before returning a JSON serializable interface to the caller. -func (p *Processor) UserGet(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) { - // (Try to) get the requested local account from the db. - receiver, err := p.state.DB.GetAccountByUsernameDomain(ctx, requestedUsername, "") - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - // Account just not found w/ this username. - err := fmt.Errorf("account with username %s not found in the db", requestedUsername) - return nil, gtserror.NewErrorNotFound(err) - } +// UserGet handles getting an AP representation of an account. +// It does auth before returning a JSON serializable interface to the caller. +func (p *Processor) UserGet( + ctx context.Context, + requestedUser string, +) (any, gtserror.WithCode) { + // Authenticate incoming request, getting related accounts. + // + // We may currently be handshaking with the remote account + // making the request. Unlike with other fedi endpoints, + // don't bother checking this; if we're still handshaking + // just serve the AP representation of our account anyway. + // + // This ensures that we don't get stuck in a loop with another + // GtS instance, where each instance is trying repeatedly to + // dereference the other account that's making the request + // before it will reveal its own account. + // + // Instead, we end up in an 'I'll show you mine if you show me + // yours' situation, where we sort of agree to reveal each + // other's profiles at the same time. + auth, errWithCode := p.authenticate(ctx, requestedUser) + if errWithCode != nil { + return nil, errWithCode + } - // Real db error. - err := fmt.Errorf("db error getting account with username %s: %w", requestedUsername, err) + // Generate the proper AP representation. + accountable, err := p.converter.AccountToAS(ctx, auth.receiver) + if err != nil { + err := gtserror.Newf("error converting to accountable: %w", err) return nil, gtserror.NewErrorInternalError(err) } - if uris.IsPublicKeyPath(requestURL) { - // If request is on a public key path, we don't need to - // authenticate this request. However, we'll only serve - // the bare minimum user profile needed for the pubkey. - // - // TODO: https://github.com/superseriousbusiness/gotosocial/issues/1186 - minimalPerson, err := p.converter.AccountToASMinimal(ctx, receiver) - if err != nil { - err := gtserror.Newf("error converting to minimal account: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Return early with bare minimum data. - return data(minimalPerson) + data, err := ap.Serialize(accountable) + if err != nil { + err := gtserror.Newf("error serializing accountable: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // If the request is not on a public key path, we want to - // try to authenticate it before we serve any data, so that - // we can serve a more complete profile. - pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) - if errWithCode != nil { - return nil, errWithCode // likely 401 - } + return data, nil +} - // Auth passed, generate the proper AP representation. - accountable, err := p.converter.AccountToAS(ctx, receiver) - if err != nil { - err := gtserror.Newf("error converting account: %w", err) +// UserGetMinimal returns a minimal AP representation +// of the requested account, containing just the public +// key, without doing authentication. +func (p *Processor) UserGetMinimal( + ctx context.Context, + requestedUser string, +) (any, gtserror.WithCode) { + acct, err := p.state.DB.GetAccountByUsernameDomain( + gtscontext.SetBarebones(ctx), + requestedUser, "", + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting account %s: %w", requestedUser, err) return nil, gtserror.NewErrorInternalError(err) } - if pubKeyAuth.Handshaking { - // If we are currently handshaking with the remote account - // making the request, then don't be coy: just serve the AP - // representation of the target account. - // - // This handshake check ensures that we don't get stuck in - // a loop with another GtS instance, where each instance is - // trying repeatedly to dereference the other account that's - // making the request before it will reveal its own account. - // - // Instead, we end up in an 'I'll show you mine if you show me - // yours' situation, where we sort of agree to reveal each - // other's profiles at the same time. - return data(accountable) + if acct == nil { + err := gtserror.Newf("account %s not found in the db", requestedUser) + return nil, gtserror.NewErrorNotFound(err) } - // Get requester from auth. - requester := pubKeyAuth.Owner - - // Check that block does not exist between receiver and requester. - blocked, err := p.state.DB.IsBlocked(ctx, receiver.ID, requester.ID) + // Generate minimal AP representation. + accountable, err := p.converter.AccountToASMinimal(ctx, acct) if err != nil { - err := gtserror.Newf("error checking block: %w", err) + err := gtserror.Newf("error converting to accountable: %w", err) return nil, gtserror.NewErrorInternalError(err) - } else if blocked { - const text = "block exists between accounts" - return nil, gtserror.NewErrorForbidden(errors.New(text)) } - return data(accountable) -} - -func data(accountable ap.Accountable) (interface{}, gtserror.WithCode) { data, err := ap.Serialize(accountable) if err != nil { err := gtserror.Newf("error serializing accountable: %w", err) diff --git a/internal/processing/fedi/wellknown.go b/internal/processing/fedi/wellknown.go index 93fd3b28f..236f09257 100644 --- a/internal/processing/fedi/wellknown.go +++ b/internal/processing/fedi/wellknown.go @@ -21,9 +21,9 @@ import ( "context" "fmt" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" ) const ( @@ -34,7 +34,7 @@ const ( nodeInfoSoftwareName = "gotosocial" nodeInfo20Rel = "http://nodeinfo.diaspora.software/ns/schema/2.0" nodeInfo21Rel = "http://nodeinfo.diaspora.software/ns/schema/2.1" - nodeInfoRepo = "https://github.com/superseriousbusiness/gotosocial" + nodeInfoRepo = "https://codeberg.org/superseriousbusiness/gotosocial" nodeInfoHomepage = "https://docs.gotosocial.org" webfingerProfilePage = "http://webfinger.net/rel/profile-page" webFingerProfilePageContentType = "text/html" @@ -47,7 +47,7 @@ var ( nodeInfoProtocols = []string{"activitypub"} nodeInfoInbound = []string{} nodeInfoOutbound = []string{} - nodeInfoMetadata = make(map[string]interface{}) + nodeInfoMetadata = make(map[string]any) ) // NodeInfoRelGet returns a well known response giving the path to node info. @@ -156,11 +156,12 @@ func (p *Processor) HostMetaGet() *apimodel.HostMeta { } // WebfingerGet handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups. -func (p *Processor) WebfingerGet(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode) { +func (p *Processor) WebfingerGet(ctx context.Context, requestedUser string) (*apimodel.WellKnownResponse, gtserror.WithCode) { // Get the local account the request is referring to. - requestedAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, requestedUsername, "") + requestedAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, requestedUser, "") if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + err := gtserror.Newf("db error getting account %s: %s", requestedUser, err) + return nil, gtserror.NewErrorNotFound(err) } return &apimodel.WellKnownResponse{ diff --git a/internal/processing/filters/common/common.go b/internal/processing/filters/common/common.go new file mode 100644 index 000000000..8930b3aaf --- /dev/null +++ b/internal/processing/filters/common/common.go @@ -0,0 +1,212 @@ +// 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 common + +import ( + "context" + "errors" + "fmt" + "net/http" + + 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" + "code.superseriousbusiness.org/gotosocial/internal/processing/stream" + "code.superseriousbusiness.org/gotosocial/internal/state" +) + +type Processor struct { + state *state.State + stream *stream.Processor +} + +func New(state *state.State, stream *stream.Processor) *Processor { + return &Processor{state, stream} +} + +// CheckFilterExists calls .GetFilter() with a barebones context to not +// fetch any sub-models, and not returning the result. this functionally +// just uses .GetFilter() for the ownership and existence checks. +func (p *Processor) CheckFilterExists( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) gtserror.WithCode { + _, errWithCode := p.GetFilter(gtscontext.SetBarebones(ctx), requester, id) + return errWithCode +} + +// GetFilter fetches the filter with given ID, also checking +// the given requesting account is the owner of the filter. +func (p *Processor) GetFilter( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) ( + *gtsmodel.Filter, + gtserror.WithCode, +) { + // Get the filter from the database with given ID. + filter, err := p.state.DB.GetFilterByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Check it exists. + if filter == nil { + const text = "filter not found" + return nil, gtserror.NewWithCode(http.StatusNotFound, text) + } + + // Check that the requester owns it. + if filter.AccountID != requester.ID { + const text = "filter not found" + err := gtserror.New("filter does not belong to account") + return nil, gtserror.NewErrorNotFound(err, text) + } + + return filter, nil +} + +// GetFilterStatus fetches the filter status with given ID, also +// checking the given requesting account is the owner of it. +func (p *Processor) GetFilterStatus( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) ( + *gtsmodel.FilterStatus, + *gtsmodel.Filter, + gtserror.WithCode, +) { + + // Get the filter status from the database with given ID. + filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter status: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + // Check it even exists. + if filterStatus == nil { + const text = "filter status not found" + return nil, nil, gtserror.NewWithCode(http.StatusNotFound, text) + } + + // Get the filter this filter status is + // associated with, without sub-models. + // (this also checks filter ownership). + filter, errWithCode := p.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + filterStatus.FilterID, + ) + if errWithCode != nil { + return nil, nil, errWithCode + } + + return filterStatus, filter, nil +} + +// GetFilterKeyword fetches the filter keyword with given ID, +// also checking the given requesting account is the owner of it. +func (p *Processor) GetFilterKeyword( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) ( + *gtsmodel.FilterKeyword, + *gtsmodel.Filter, + gtserror.WithCode, +) { + + // Get the filter keyword from the database with given ID. + keyword, err := p.state.DB.GetFilterKeywordByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter keyword: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + // Check it exists. + if keyword == nil { + const text = "filter keyword not found" + return nil, nil, gtserror.NewWithCode(http.StatusNotFound, text) + } + + // Get the filter this filter keyword is + // associated with, without sub-models. + // (this also checks filter ownership). + filter, errWithCode := p.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + keyword.FilterID, + ) + if errWithCode != nil { + return nil, nil, errWithCode + } + + return keyword, filter, nil +} + +// OnFilterChanged ... +func (p *Processor) OnFilterChanged(ctx context.Context, requester *gtsmodel.Account) { + + // Get list of list IDs created by this requesting account. + listIDs, err := p.state.DB.GetListIDsByAccountID(ctx, requester.ID) + if err != nil { + log.Errorf(ctx, "error getting account '%s' lists: %v", requester.Username, err) + } + + // Unprepare this requester's home timeline. + p.state.Caches.Timelines.Home.Unprepare(requester.ID) + + // Unprepare list timelines. + for _, id := range listIDs { + p.state.Caches.Timelines.List.Unprepare(id) + } + + // Send filter changed event for account. + p.stream.FiltersChanged(ctx, requester) +} + +// FromAPIContexts converts a slice of frontend API model FilterContext types to our internal FilterContexts bit field. +func FromAPIContexts(apiContexts []apimodel.FilterContext) (gtsmodel.FilterContexts, gtserror.WithCode) { + var contexts gtsmodel.FilterContexts + for _, context := range apiContexts { + switch context { + case apimodel.FilterContextHome: + contexts.SetHome() + case apimodel.FilterContextNotifications: + contexts.SetNotifications() + case apimodel.FilterContextPublic: + contexts.SetPublic() + case apimodel.FilterContextThread: + contexts.SetThread() + case apimodel.FilterContextAccount: + contexts.SetAccount() + default: + text := fmt.Sprintf("unsupported filter context: %s", context) + return 0, gtserror.NewWithCode(http.StatusBadRequest, text) + } + } + return contexts, nil +} diff --git a/internal/processing/filters/v1/convert.go b/internal/processing/filters/v1/convert.go deleted file mode 100644 index 1e0db5ff1..000000000 --- a/internal/processing/filters/v1/convert.go +++ /dev/null @@ -1,38 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. - -package v1 - -import ( - "context" - "fmt" - - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -// apiFilter is a shortcut to return the API v1 filter version of the given -// filter keyword, or return an appropriate error if conversion fails. -func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) (*apimodel.FilterV1, gtserror.WithCode) { - apiFilter, err := p.converter.FilterKeywordToAPIFilterV1(ctx, filterKeyword) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter keyword to API v1 filter: %w", err)) - } - - return apiFilter, nil -} diff --git a/internal/processing/filters/v1/create.go b/internal/processing/filters/v1/create.go index 86019d2a6..9f3fc17e0 100644 --- a/internal/processing/filters/v1/create.go +++ b/internal/processing/filters/v1/create.go @@ -20,76 +20,80 @@ package v1 import ( "context" "errors" - "fmt" + "net/http" "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/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/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // Create a new filter and filter keyword for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. -func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateUpdateRequestV1) (*apimodel.FilterV1, gtserror.WithCode) { +func (p *Processor) Create(ctx context.Context, requester *gtsmodel.Account, form *apimodel.FilterCreateUpdateRequestV1) (*apimodel.FilterV1, gtserror.WithCode) { + var errWithCode gtserror.WithCode + + // Create new wrapping filter. filter := >smodel.Filter{ ID: id.NewULID(), - AccountID: account.ID, + AccountID: requester.ID, Title: form.Phrase, - Action: gtsmodel.FilterActionWarn, } + if *form.Irreversible { + // Irreversible = action hide. filter.Action = gtsmodel.FilterActionHide + } else { + // Default action = action warn. + filter.Action = gtsmodel.FilterActionWarn } - if form.ExpiresIn != nil && *form.ExpiresIn != 0 { - filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn)) + + // Check form for valid expiry and set on filter. + if form.ExpiresIn != nil && *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + filter.ExpiresAt = time.Now().Add(expiresIn) } - for _, context := range form.Context { - switch context { - case apimodel.FilterContextHome: - filter.ContextHome = util.Ptr(true) - case apimodel.FilterContextNotifications: - filter.ContextNotifications = util.Ptr(true) - case apimodel.FilterContextPublic: - filter.ContextPublic = util.Ptr(true) - case apimodel.FilterContextThread: - filter.ContextThread = util.Ptr(true) - case apimodel.FilterContextAccount: - filter.ContextAccount = util.Ptr(true) - default: - return nil, gtserror.NewErrorUnprocessableEntity( - fmt.Errorf("unsupported filter context '%s'", context), - ) - } + + // Parse contexts filter applies in from incoming request form data. + filter.Contexts, errWithCode = common.FromAPIContexts(form.Context) + if errWithCode != nil { + return nil, errWithCode } + // Create new keyword attached to filter. filterKeyword := >smodel.FilterKeyword{ ID: id.NewULID(), - AccountID: account.ID, FilterID: filter.ID, - Filter: filter, Keyword: form.Phrase, WholeWord: util.Ptr(util.PtrOrValue(form.WholeWord, false)), } - filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} - if err := p.state.DB.PutFilter(ctx, filter); err != nil { - if errors.Is(err, db.ErrAlreadyExists) { - err = errors.New("you already have a filter with this title") - return nil, gtserror.NewErrorConflict(err, err.Error()) - } - return nil, gtserror.NewErrorInternalError(err) - } + // Attach the new keyword to filter before insert. + filter.Keywords = append(filter.Keywords, filterKeyword) + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) - apiFilter, errWithCode := p.apiFilter(ctx, filterKeyword) - if errWithCode != nil { - return nil, errWithCode + // Insert newly created filter into the database. + switch err := p.state.DB.PutFilter(ctx, filter); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) - return apiFilter, nil + // Return as converted frontend filter keyword model. + return typeutils.FilterKeywordToAPIFilterV1(filter, filterKeyword), nil } diff --git a/internal/processing/filters/v1/delete.go b/internal/processing/filters/v1/delete.go index 89282c65d..65768140a 100644 --- a/internal/processing/filters/v1/delete.go +++ b/internal/processing/filters/v1/delete.go @@ -19,52 +19,52 @@ package v1 import ( "context" - "errors" + "slices" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) -// Delete an existing filter keyword and (if empty afterwards) filter for the given account. +// Delete an existing filter keyword and (if empty +// afterwards) filter for the given account. func (p *Processor) Delete( ctx context.Context, - account *gtsmodel.Account, + requester *gtsmodel.Account, filterKeywordID string, ) gtserror.WithCode { - // Get enough of the filter keyword that we can look up its filter ID. - filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return gtserror.NewErrorNotFound(err) - } - return gtserror.NewErrorInternalError(err) - } - if filterKeyword.AccountID != account.ID { - return gtserror.NewErrorNotFound(nil) - } - - // Get the filter for this keyword. - filter, err := p.state.DB.GetFilterByID(ctx, filterKeyword.FilterID) - if err != nil { - return gtserror.NewErrorNotFound(err) + // Get the filter keyword with given ID, and associated filter, also checking ownership. + filterKeyword, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return errWithCode } if len(filter.Keywords) > 1 || len(filter.Statuses) > 0 { - // The filter has other keywords or statuses. Delete only the requested filter keyword. - if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil { + // The filter has other keywords or statuses, just delete the one filter keyword. + if err := p.state.DB.DeleteFilterKeywordsByIDs(ctx, filterKeyword.ID); err != nil { + err := gtserror.Newf("error deleting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Delete this filter keyword from the slice of IDs attached to filter. + filter.KeywordIDs = slices.DeleteFunc(filter.KeywordIDs, func(id string) bool { + return filterKeyword.ID == id + }) + + // Update filter in the database now the keyword has been unattached. + if err := p.state.DB.UpdateFilter(ctx, filter, "keywords"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) return gtserror.NewErrorInternalError(err) } } else { - // Delete the entire filter. - if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil { + // Delete the filter and this keyword that is attached to it. + if err := p.state.DB.DeleteFilter(ctx, filter); err != nil { + err := gtserror.Newf("error deleting filter: %w", err) return gtserror.NewErrorInternalError(err) } } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v1/filters.go b/internal/processing/filters/v1/filters.go index daa9087a9..4492b4e76 100644 --- a/internal/processing/filters/v1/filters.go +++ b/internal/processing/filters/v1/filters.go @@ -18,21 +18,24 @@ package v1 import ( - "github.com/superseriousbusiness/gotosocial/internal/processing/stream" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { + // embedded common logic + c *common.Processor + state *state.State converter *typeutils.Converter - stream *stream.Processor } -func New(state *state.State, converter *typeutils.Converter, stream *stream.Processor) Processor { +func New(state *state.State, converter *typeutils.Converter, common *common.Processor) Processor { return Processor{ + c: common, + state: state, converter: converter, - stream: stream, } } diff --git a/internal/processing/filters/v1/get.go b/internal/processing/filters/v1/get.go index 3ead09b20..bdde123e9 100644 --- a/internal/processing/filters/v1/get.go +++ b/internal/processing/filters/v1/get.go @@ -23,49 +23,60 @@ import ( "slices" "strings" - 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/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Get looks up a filter keyword by ID and returns it as a v1 filter. -func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterV1, gtserror.WithCode) { - filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterKeywordID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) +func (p *Processor) Get(ctx context.Context, requester *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterV1, gtserror.WithCode) { + filterKeyword, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } - - return p.apiFilter(ctx, filterKeyword) + return typeutils.FilterKeywordToAPIFilterV1(filter, filterKeyword), nil } // GetAll looks up all filter keywords for the current account and returns them as v1 filters. -func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.FilterV1, gtserror.WithCode) { - filters, err := p.state.DB.GetFilterKeywordsForAccountID( - ctx, - account.ID, +func (p *Processor) GetAll(ctx context.Context, requester *gtsmodel.Account) ([]*apimodel.FilterV1, gtserror.WithCode) { + var totalKeywords int + + // Get a list of all filters owned by this account, + // (without any sub-models attached, done later). + filters, err := p.state.DB.GetFiltersByAccountID( + gtscontext.SetBarebones(ctx), + requester.ID, ) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filters: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilters := make([]*apimodel.FilterV1, 0, len(filters)) + // Get a total count of all expected + // keywords for slice preallocation. for _, filter := range filters { - apiFilter, errWithCode := p.apiFilter(ctx, filter) - if errWithCode != nil { - return nil, errWithCode + totalKeywords += len(filter.KeywordIDs) + } + + // Create a slice to store converted V1 frontend models. + apiFilters := make([]*apimodel.FilterV1, 0, totalKeywords) + + for _, filter := range filters { + // For each of the fetched filters, fetch all of their associated keywords. + keywords, err := p.state.DB.GetFilterKeywordsByIDs(ctx, filter.KeywordIDs) + if err != nil { + err := gtserror.Newf("error getting filter keywords: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - apiFilters = append(apiFilters, apiFilter) + // Convert each keyword to frontend. + for _, keyword := range keywords { + apiFilter := typeutils.FilterKeywordToAPIFilterV1(filter, keyword) + apiFilters = append(apiFilters, apiFilter) + } } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v1/update.go b/internal/processing/filters/v1/update.go index 15c5de365..19699f328 100644 --- a/internal/processing/filters/v1/update.go +++ b/internal/processing/filters/v1/update.go @@ -21,77 +21,59 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "time" - 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/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/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Update an existing filter and filter keyword for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. func (p *Processor) Update( ctx context.Context, - account *gtsmodel.Account, + requester *gtsmodel.Account, filterKeywordID string, form *apimodel.FilterCreateUpdateRequestV1, ) (*apimodel.FilterV1, gtserror.WithCode) { - // Get enough of the filter keyword that we can look up its filter ID. - filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + // Get the filter keyword with given ID, and associated filter, also checking ownership. + filterKeyword, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } - // Get the filter for this keyword. - filter, err := p.state.DB.GetFilterByID(ctx, filterKeyword.FilterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } + var title string + var action gtsmodel.FilterAction + var contexts gtsmodel.FilterContexts + var expiresAt time.Time + var wholeword bool + + // Get filter title. + title = form.Phrase - title := form.Phrase - action := gtsmodel.FilterActionWarn if *form.Irreversible { + // Irreversible = action hide. action = gtsmodel.FilterActionHide + } else { + // Default action = action warn. + action = gtsmodel.FilterActionWarn } - expiresAt := time.Time{} - if form.ExpiresIn != nil && *form.ExpiresIn != 0 { - expiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn)) + + // Check form for valid expiry and set on filter. + if form.ExpiresIn != nil && *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + expiresAt = time.Now().Add(expiresIn) } - contextHome := false - contextNotifications := false - contextPublic := false - contextThread := false - contextAccount := false - for _, context := range form.Context { - switch context { - case apimodel.FilterContextHome: - contextHome = true - case apimodel.FilterContextNotifications: - contextNotifications = true - case apimodel.FilterContextPublic: - contextPublic = true - case apimodel.FilterContextThread: - contextThread = true - case apimodel.FilterContextAccount: - contextAccount = true - default: - return nil, gtserror.NewErrorUnprocessableEntity( - fmt.Errorf("unsupported filter context '%s'", context), - ) - } + + // Parse contexts filter applies in from incoming form data. + contexts, errWithCode = common.FromAPIContexts(form.Context) + if errWithCode != nil { + return nil, errWithCode } // v1 filter APIs can't change certain fields for a filter with multiple keywords or any statuses, @@ -108,11 +90,7 @@ func (p *Processor) Update( if expiresAt != filter.ExpiresAt { forbiddenFields = append(forbiddenFields, "expires_in") } - if contextHome != util.PtrOrValue(filter.ContextHome, false) || - contextNotifications != util.PtrOrValue(filter.ContextNotifications, false) || - contextPublic != util.PtrOrValue(filter.ContextPublic, false) || - contextThread != util.PtrOrValue(filter.ContextThread, false) || - contextAccount != util.PtrOrValue(filter.ContextAccount, false) { + if contexts != filter.Contexts { forbiddenFields = append(forbiddenFields, "context") } if len(forbiddenFields) > 0 { @@ -122,54 +100,75 @@ func (p *Processor) Update( } } - // Now that we've checked that the changes are legal, apply them to the filter and keyword. - filter.Title = title - filter.Action = action - filter.ExpiresAt = expiresAt - filter.ContextHome = &contextHome - filter.ContextNotifications = &contextNotifications - filter.ContextPublic = &contextPublic - filter.ContextThread = &contextThread - filter.ContextAccount = &contextAccount - filterKeyword.Keyword = form.Phrase - filterKeyword.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false)) - - // We only want to update the relevant filter keyword. - filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} - filter.Statuses = nil - filterKeyword.Filter = filter - - filterColumns := []string{ - "title", - "action", - "expires_at", - "context_home", - "context_notifications", - "context_public", - "context_thread", - "context_account", + // Filter columns that + // we're going to update. + var filterCols []string + var keywordCols []string + + // Check for changed filter title / filter keyword phrase. + if title != filter.Title || title != filterKeyword.Keyword { + keywordCols = append(keywordCols, "keyword") + filterCols = append(filterCols, "title") + filterKeyword.Keyword = title + filter.Title = title } - filterKeywordColumns := [][]string{ - { - "keyword", - "whole_word", - }, + + // Check for changed action. + if action != filter.Action { + filterCols = append(filterCols, "action") + filter.Action = action } - if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, nil, nil); err != nil { - if errors.Is(err, db.ErrAlreadyExists) { - err = errors.New("you already have a filter with this title") - return nil, gtserror.NewErrorConflict(err, err.Error()) - } + + // Check for changed filter expiry time. + if !expiresAt.Equal(filter.ExpiresAt) { + filterCols = append(filterCols, "expires_at") + filter.ExpiresAt = expiresAt + } + + // Check for changed filter context. + if contexts != filter.Contexts { + filterCols = append(filterCols, "contexts") + filter.Contexts = contexts + } + + // Check for changed wholeword flag. + if form.WholeWord != nil && + *form.WholeWord != *filterKeyword.WholeWord { + keywordCols = append(keywordCols, "whole_word") + filterKeyword.WholeWord = &wholeword + } + + // Update filter keyword model in the database with determined changed cols. + switch err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, keywordCols...); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate keyword" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error updating filter: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilter, errWithCode := p.apiFilter(ctx, filterKeyword) - if errWithCode != nil { - return nil, errWithCode + // Update filter model in the database with determined changed cols. + switch err := p.state.DB.UpdateFilter(ctx, filter, filterCols...); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error updating filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) - return apiFilter, nil + // Return as converted frontend filter keyword model. + return typeutils.FilterKeywordToAPIFilterV1(filter, filterKeyword), nil } diff --git a/internal/processing/filters/v2/create.go b/internal/processing/filters/v2/create.go index 60dd46f43..93f93d493 100644 --- a/internal/processing/filters/v2/create.go +++ b/internal/processing/filters/v2/create.go @@ -20,87 +20,193 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" "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/typeutils" - "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/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Create a new filter for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. -func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateRequestV2) (*apimodel.FilterV2, gtserror.WithCode) { +func (p *Processor) Create(ctx context.Context, requester *gtsmodel.Account, form *apimodel.FilterCreateRequestV2) (*apimodel.FilterV2, gtserror.WithCode) { + var errWithCode gtserror.WithCode + + // Create new filter model. filter := >smodel.Filter{ ID: id.NewULID(), - AccountID: account.ID, + AccountID: requester.ID, Title: form.Title, - Action: typeutils.APIFilterActionToFilterAction(*form.FilterAction), } - if form.ExpiresIn != nil && *form.ExpiresIn != 0 { - filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn)) + + // Parse filter action from form and set on filter, checking for validity. + filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction) + if filter.Action == 0 { + const text = "invalid filter action" + return nil, gtserror.NewWithCode(http.StatusBadRequest, text) } - for _, context := range form.Context { - switch context { - case apimodel.FilterContextHome: - filter.ContextHome = util.Ptr(true) - case apimodel.FilterContextNotifications: - filter.ContextNotifications = util.Ptr(true) - case apimodel.FilterContextPublic: - filter.ContextPublic = util.Ptr(true) - case apimodel.FilterContextThread: - filter.ContextThread = util.Ptr(true) - case apimodel.FilterContextAccount: - filter.ContextAccount = util.Ptr(true) - default: - return nil, gtserror.NewErrorUnprocessableEntity( - fmt.Errorf("unsupported filter context '%s'", context), - ) + + // Parse contexts filter applies in from incoming request form data. + filter.Contexts, errWithCode = common.FromAPIContexts(form.Context) + if errWithCode != nil { + return nil, errWithCode + } + + // Check form for valid expiry and set on filter. + if form.ExpiresIn != nil && *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + filter.ExpiresAt = time.Now().Add(expiresIn) + } + + // Create new attached filter keywords. + keywordQueries, errWithCode := p.createFilterKeywords(ctx, + filter, form.Keywords) + if errWithCode != nil { + return nil, errWithCode + } + + // Create new attached filter statuses. + statusQueries, errWithCode := p.createFilterStatuses(ctx, + filter, form.Statuses) + if errWithCode != nil { + return nil, errWithCode + } + + for _, keywordCreate := range keywordQueries { + if errWithCode := keywordCreate(); errWithCode != nil { + return nil, errWithCode } } - for _, formKeyword := range form.Keywords { - filterKeyword := >smodel.FilterKeyword{ - ID: id.NewULID(), - AccountID: account.ID, - FilterID: filter.ID, - Filter: filter, - Keyword: formKeyword.Keyword, - WholeWord: formKeyword.WholeWord, + for _, statusCreate := range statusQueries { + if errWithCode := statusCreate(); errWithCode != nil { + return nil, errWithCode } - filter.Keywords = append(filter.Keywords, filterKeyword) } - for _, formStatus := range form.Statuses { - filterStatus := >smodel.FilterStatus{ + // Insert the new filter model into the database. + switch err := p.state.DB.PutFilter(ctx, filter); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title, keyword or status" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) + + // Return as converted frontend filter model. + return typeutils.FilterToAPIFilterV2(filter), nil +} + +func (p *Processor) createFilterKeywords(ctx context.Context, filter *gtsmodel.Filter, form []apimodel.FilterKeywordCreateUpdateRequest) ([]func() gtserror.WithCode, gtserror.WithCode) { + if len(form) == 0 { + // No keywords created. + return nil, nil + } + + var deferred []func() gtserror.WithCode + + // Create filter keywords in the database. + for _, request := range form { + // Check for valid request. + if request.Keyword == "" { + const text = "missing keyword" + return deferred, gtserror.NewWithCode(http.StatusBadRequest, text) + } + + // Create new filter keyword for insert. + filterKeyword := >smodel.FilterKeyword{ ID: id.NewULID(), - AccountID: account.ID, FilterID: filter.ID, - Filter: filter, - StatusID: formStatus.StatusID, + Keyword: request.Keyword, + WholeWord: request.WholeWord, } - filter.Statuses = append(filter.Statuses, filterStatus) - } - if err := p.state.DB.PutFilter(ctx, filter); err != nil { - if errors.Is(err, db.ErrAlreadyExists) { - err = errors.New("duplicate title, keyword, or status") - return nil, gtserror.NewErrorConflict(err, err.Error()) + // Verify that this is valid regular expression. + if err := filterKeyword.Compile(); err != nil { + const text = "invalid regular expression" + err := gtserror.Newf("invalid regular expression: %w", err) + return deferred, gtserror.NewWithCodeSafe( + http.StatusBadRequest, + err, text, + ) } - return nil, gtserror.NewErrorInternalError(err) + + // Append new filter keyword to filter and list of IDs. + filter.Keywords = append(filter.Keywords, filterKeyword) + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) + + // Append database insert to funcs for later processing by caller. + deferred = append(deferred, func() gtserror.WithCode { + if err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate keyword" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error inserting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) } - apiFilter, errWithCode := p.apiFilter(ctx, filter) - if errWithCode != nil { - return nil, errWithCode + return deferred, nil +} + +func (p *Processor) createFilterStatuses(ctx context.Context, filter *gtsmodel.Filter, form []apimodel.FilterStatusCreateRequest) ([]func() gtserror.WithCode, gtserror.WithCode) { + if len(form) == 0 { + // No statuses added. + return nil, nil } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + var deferred []func() gtserror.WithCode + + // Create filter statuses in the database. + for _, request := range form { + // Check for valid request. + if request.StatusID == "" { + const text = "missing status" + return deferred, gtserror.NewWithCode(http.StatusBadRequest, text) + } + + // Create new filter status for insert. + filterStatus := >smodel.FilterStatus{ + ID: id.NewULID(), + FilterID: filter.ID, + StatusID: request.StatusID, + } + + // Append new filter status to filter and list of IDs. + filter.Statuses = append(filter.Statuses, filterStatus) + filter.StatusIDs = append(filter.StatusIDs, filterStatus.ID) + + // Append database insert to funcs for later processing by caller. + deferred = append(deferred, func() gtserror.WithCode { + if err := p.state.DB.PutFilterStatus(ctx, filterStatus); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate status" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error inserting filter status: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) + } - return apiFilter, nil + return deferred, nil } diff --git a/internal/processing/filters/v2/delete.go b/internal/processing/filters/v2/delete.go index a312180b8..fdd6cca92 100644 --- a/internal/processing/filters/v2/delete.go +++ b/internal/processing/filters/v2/delete.go @@ -19,38 +19,33 @@ package v2 import ( "context" - "fmt" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) -// Delete an existing filter and all its attached keywords and statuses for the given account. +// Delete an existing filter and all its attached +// keywords and statuses for the given account. func (p *Processor) Delete( ctx context.Context, - account *gtsmodel.Account, + requester *gtsmodel.Account, filterID string, ) gtserror.WithCode { - // Get the filter for this keyword. - filter, err := p.state.DB.GetFilterByID(ctx, filterID) - if err != nil { - return gtserror.NewErrorNotFound(err) - } - // Check that the account owns it. - if filter.AccountID != account.ID { - return gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return errWithCode } - // Delete the entire filter. - if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil { + // Delete filter from the database with all associated models. + if err := p.state.DB.DeleteFilter(ctx, filter); err != nil { + err := gtserror.Newf("error deleting filter: %w", err) return gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v2/filters.go b/internal/processing/filters/v2/filters.go index 85da4df6b..08725ccde 100644 --- a/internal/processing/filters/v2/filters.go +++ b/internal/processing/filters/v2/filters.go @@ -18,21 +18,24 @@ package v2 import ( - "github.com/superseriousbusiness/gotosocial/internal/processing/stream" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { + // embedded common logic + c *common.Processor + state *state.State converter *typeutils.Converter - stream *stream.Processor } -func New(state *state.State, converter *typeutils.Converter, stream *stream.Processor) Processor { +func New(state *state.State, converter *typeutils.Converter, common *common.Processor) Processor { return Processor{ + c: common, + state: state, converter: converter, - stream: stream, } } diff --git a/internal/processing/filters/v2/get.go b/internal/processing/filters/v2/get.go index 39b937eb2..4cdf9e8ee 100644 --- a/internal/processing/filters/v2/get.go +++ b/internal/processing/filters/v2/get.go @@ -19,56 +19,43 @@ package v2 import ( "context" - "errors" - "fmt" "slices" "strings" - 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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Get looks up a filter by ID and returns it with keywords and statuses. -func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterID string) (*apimodel.FilterV2, gtserror.WithCode) { - filter, err := p.state.DB.GetFilterByID(ctx, filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) +func (p *Processor) Get(ctx context.Context, requester *gtsmodel.Account, filterID string) (*apimodel.FilterV2, gtserror.WithCode) { + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return nil, errWithCode } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) - } - - return p.apiFilter(ctx, filter) + return typeutils.FilterToAPIFilterV2(filter), nil } // GetAll looks up all filters for the current account and returns them with keywords and statuses. -func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.FilterV2, gtserror.WithCode) { - filters, err := p.state.DB.GetFiltersForAccountID( - ctx, - account.ID, - ) +func (p *Processor) GetAll(ctx context.Context, requester *gtsmodel.Account) ([]*apimodel.FilterV2, gtserror.WithCode) { + + // Get all filters belonging to this requester from the database. + filters, err := p.state.DB.GetFiltersByAccountID(ctx, requester.ID) if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + err := gtserror.Newf("error getting account filters: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilters := make([]*apimodel.FilterV2, 0, len(filters)) - for _, filter := range filters { - apiFilter, errWithCode := p.apiFilter(ctx, filter) - if errWithCode != nil { - return nil, errWithCode - } - - apiFilters = append(apiFilters, apiFilter) + // Convert all these filters to frontend API models. + apiFilters := make([]*apimodel.FilterV2, len(filters)) + if len(apiFilters) != len(filters) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) + } + for i, filter := range filters { + apiFilter := typeutils.FilterToAPIFilterV2(filter) + apiFilters[i] = apiFilter } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v2/keywordcreate.go b/internal/processing/filters/v2/keywordcreate.go index 92d9e5dfd..7ad7c3bd9 100644 --- a/internal/processing/filters/v2/keywordcreate.go +++ b/internal/processing/filters/v2/keywordcreate.go @@ -20,51 +20,60 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" - 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" + 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/typeutils" ) // KeywordCreate adds a filter keyword to an existing filter for the given account, using the provided parameters. // These params should have already been normalized and validated by the time they reach this function. -func (p *Processor) KeywordCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterKeywordCreateUpdateRequest) (*apimodel.FilterKeyword, gtserror.WithCode) { - // Check that the filter is owned by the given account. - filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) +func (p *Processor) KeywordCreate(ctx context.Context, requester *gtsmodel.Account, filterID string, form *apimodel.FilterKeywordCreateUpdateRequest) (*apimodel.FilterKeyword, gtserror.WithCode) { + + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return nil, errWithCode } + // Create new filter keyword model. filterKeyword := >smodel.FilterKeyword{ ID: id.NewULID(), - AccountID: account.ID, FilterID: filter.ID, Keyword: form.Keyword, WholeWord: form.WholeWord, } - if err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); err != nil { - if errors.Is(err, db.ErrAlreadyExists) { - err = errors.New("duplicate keyword") - return nil, gtserror.NewErrorConflict(err, err.Error()) - } + // Insert the new filter keyword model into the database. + switch err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate keyword" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter keyword: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Now update the filter it is attached to with new keyword. + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) + filter.Keywords = append(filter.Keywords, filterKeyword) + + // Update the existing filter model in the database (only the needed col). + if err := p.state.DB.UpdateFilter(ctx, filter, "keywords"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) - return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil + return typeutils.FilterKeywordToAPIFilterKeyword(filterKeyword), nil } diff --git a/internal/processing/filters/v2/keyworddelete.go b/internal/processing/filters/v2/keyworddelete.go index 024991109..5393ffd53 100644 --- a/internal/processing/filters/v2/keyworddelete.go +++ b/internal/processing/filters/v2/keyworddelete.go @@ -19,38 +19,43 @@ package v2 import ( "context" - "fmt" + "slices" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // KeywordDelete deletes an existing filter keyword from a filter. func (p *Processor) KeywordDelete( ctx context.Context, - account *gtsmodel.Account, - filterID string, + requester *gtsmodel.Account, + filterKeywordID string, ) gtserror.WithCode { - // Get the filter keyword. - filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterID) - if err != nil { - return gtserror.NewErrorNotFound(err) + // Get filter keyword with given ID, also checking ownership to requester. + _, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return errWithCode } - // Check that the account owns it. - if filterKeyword.AccountID != account.ID { - return gtserror.NewErrorNotFound( - fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), - ) + // Delete this one filter keyword from the database, now ownership is confirmed. + if err := p.state.DB.DeleteFilterKeywordsByIDs(ctx, filterKeywordID); err != nil { + err := gtserror.Newf("error deleting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) } - // Delete the filter keyword. - if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil { + // Delete this filter keyword from the slice of IDs attached to filter. + filter.KeywordIDs = slices.DeleteFunc(filter.KeywordIDs, func(id string) bool { + return filterKeywordID == id + }) + + // Update filter in the database now the keyword has been unattached. + if err := p.state.DB.UpdateFilter(ctx, filter, "keywords"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) return gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v2/keywordget.go b/internal/processing/filters/v2/keywordget.go index 5f5a63b26..3cf120ed8 100644 --- a/internal/processing/filters/v2/keywordget.go +++ b/internal/processing/filters/v2/keywordget.go @@ -20,63 +20,55 @@ package v2 import ( "context" "errors" - "fmt" "slices" "strings" - 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" + 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/typeutils" ) // KeywordGet looks up a filter keyword by ID. -func (p *Processor) KeywordGet(ctx context.Context, account *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterKeyword, gtserror.WithCode) { - filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterKeywordID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), - ) +func (p *Processor) KeywordGet(ctx context.Context, requester *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterKeyword, gtserror.WithCode) { + filterKeyword, _, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } - - return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil + return typeutils.FilterKeywordToAPIFilterKeyword(filterKeyword), nil } // KeywordsGetForFilterID looks up all filter keywords for the given filter. -func (p *Processor) KeywordsGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterKeyword, gtserror.WithCode) { - // Check that the filter is owned by the given account. - filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) - } +func (p *Processor) KeywordsGetForFilterID(ctx context.Context, requester *gtsmodel.Account, filterID string) ([]*apimodel.FilterKeyword, gtserror.WithCode) { - filterKeywords, err := p.state.DB.GetFilterKeywordsForFilterID( - ctx, - filter.ID, + // Get the filter with given ID (but + // without any sub-models attached). + filter, errWithCode := p.c.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + filterID, ) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + if errWithCode != nil { + return nil, errWithCode + } + + // Fetch all associated filter keywords to the determined existent filter. + filterKeywords, err := p.state.DB.GetFilterKeywordsByIDs(ctx, filter.KeywordIDs) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter keywords: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilterKeywords := make([]*apimodel.FilterKeyword, 0, len(filterKeywords)) - for _, filterKeyword := range filterKeywords { - apiFilterKeywords = append(apiFilterKeywords, p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword)) + // Convert all of the filter keyword models from internal to frontend form. + apiFilterKeywords := make([]*apimodel.FilterKeyword, len(filterKeywords)) + if len(apiFilterKeywords) != len(filterKeywords) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) + } + for i, filterKeyword := range filterKeywords { + apiFilterKeywords[i] = typeutils.FilterKeywordToAPIFilterKeyword(filterKeyword) } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v2/keywordupdate.go b/internal/processing/filters/v2/keywordupdate.go index 9492e7b3a..047b079db 100644 --- a/internal/processing/filters/v2/keywordupdate.go +++ b/internal/processing/filters/v2/keywordupdate.go @@ -20,50 +20,51 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" - 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" + 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/typeutils" ) // KeywordUpdate updates an existing filter keyword for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. func (p *Processor) KeywordUpdate( ctx context.Context, - account *gtsmodel.Account, + requester *gtsmodel.Account, filterKeywordID string, form *apimodel.FilterKeywordCreateUpdateRequest, ) (*apimodel.FilterKeyword, gtserror.WithCode) { - // Get the filter keyword by ID. - filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), - ) + + // Get the filter keyword with given ID, also checking ownership to requester. + filterKeyword, _, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } + // Update the keyword model fields. filterKeyword.Keyword = form.Keyword filterKeyword.WholeWord = form.WholeWord - if err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, "keyword", "whole_word"); err != nil { - if errors.Is(err, db.ErrAlreadyExists) { - err = errors.New("duplicate keyword") - return nil, gtserror.NewErrorConflict(err, err.Error()) - } + // Update existing filter keyword model in the database, (only necessary cols). + switch err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, []string{ + "keyword", "whole_word"}...); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate keyword" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter keyword: %w", err) return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) - return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil + return typeutils.FilterKeywordToAPIFilterKeyword(filterKeyword), nil } diff --git a/internal/processing/filters/v2/statuscreate.go b/internal/processing/filters/v2/statuscreate.go index 7d4469eef..2a3c3d74b 100644 --- a/internal/processing/filters/v2/statuscreate.go +++ b/internal/processing/filters/v2/statuscreate.go @@ -20,50 +20,59 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" - 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" + 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/typeutils" ) // StatusCreate adds a filter status to an existing filter for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. -func (p *Processor) StatusCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterStatusCreateRequest) (*apimodel.FilterStatus, gtserror.WithCode) { - // Check that the filter is owned by the given account. - filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) +func (p *Processor) StatusCreate(ctx context.Context, requester *gtsmodel.Account, filterID string, form *apimodel.FilterStatusCreateRequest) (*apimodel.FilterStatus, gtserror.WithCode) { + + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return nil, errWithCode } + // Create new filter status model. filterStatus := >smodel.FilterStatus{ - ID: id.NewULID(), - AccountID: account.ID, - FilterID: filter.ID, - StatusID: form.StatusID, + ID: id.NewULID(), + FilterID: filter.ID, + StatusID: form.StatusID, } - if err := p.state.DB.PutFilterStatus(ctx, filterStatus); err != nil { - if errors.Is(err, db.ErrAlreadyExists) { - err = errors.New("duplicate status") - return nil, gtserror.NewErrorConflict(err, err.Error()) - } + // Insert the new filter status model into the database. + switch err := p.state.DB.PutFilterStatus(ctx, filterStatus); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate status" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Now update the filter it is attached to with new status. + filter.StatusIDs = append(filter.StatusIDs, filterStatus.ID) + filter.Statuses = append(filter.Statuses, filterStatus) + + // Update the existing filter model in the database (only the needed col). + if err := p.state.DB.UpdateFilter(ctx, filter, "statuses"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) - return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil + return typeutils.FilterStatusToAPIFilterStatus(filterStatus), nil } diff --git a/internal/processing/filters/v2/statusdelete.go b/internal/processing/filters/v2/statusdelete.go index 706ca691d..321dc88e9 100644 --- a/internal/processing/filters/v2/statusdelete.go +++ b/internal/processing/filters/v2/statusdelete.go @@ -19,38 +19,43 @@ package v2 import ( "context" - "fmt" + "slices" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // StatusDelete deletes an existing filter status from a filter. func (p *Processor) StatusDelete( ctx context.Context, - account *gtsmodel.Account, - filterID string, + requester *gtsmodel.Account, + filterStatusID string, ) gtserror.WithCode { - // Get the filter status. - filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterID) - if err != nil { - return gtserror.NewErrorNotFound(err) + // Get filter status with given ID, also checking ownership to requester. + _, filter, errWithCode := p.c.GetFilterStatus(ctx, requester, filterStatusID) + if errWithCode != nil { + return errWithCode } - // Check that the account owns it. - if filterStatus.AccountID != account.ID { - return gtserror.NewErrorNotFound( - fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID), - ) + // Delete this one filter status from the database, now ownership is confirmed. + if err := p.state.DB.DeleteFilterStatusesByIDs(ctx, filterStatusID); err != nil { + err := gtserror.Newf("error deleting filter status: %w", err) + return gtserror.NewErrorInternalError(err) } - // Delete the filter status. - if err := p.state.DB.DeleteFilterStatusByID(ctx, filterStatus.ID); err != nil { + // Delete this filter keyword from the slice of IDs attached to filter. + filter.StatusIDs = slices.DeleteFunc(filter.StatusIDs, func(id string) bool { + return filterStatusID == id + }) + + // Update filter in the database now the status has been unattached. + if err := p.state.DB.UpdateFilter(ctx, filter, "statuses"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) return gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v2/statusget.go b/internal/processing/filters/v2/statusget.go index 197a3872e..7aa51f830 100644 --- a/internal/processing/filters/v2/statusget.go +++ b/internal/processing/filters/v2/statusget.go @@ -20,63 +20,55 @@ package v2 import ( "context" "errors" - "fmt" "slices" "strings" - 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" + 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/typeutils" ) // StatusGet looks up a filter status by ID. -func (p *Processor) StatusGet(ctx context.Context, account *gtsmodel.Account, filterStatusID string) (*apimodel.FilterStatus, gtserror.WithCode) { - filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterStatusID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filterStatus.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID), - ) +func (p *Processor) StatusGet(ctx context.Context, requester *gtsmodel.Account, filterStatusID string) (*apimodel.FilterStatus, gtserror.WithCode) { + filterStatus, _, errWithCode := p.c.GetFilterStatus(ctx, requester, filterStatusID) + if errWithCode != nil { + return nil, errWithCode } - - return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil + return typeutils.FilterStatusToAPIFilterStatus(filterStatus), nil } // StatusesGetForFilterID looks up all filter statuses for the given filter. -func (p *Processor) StatusesGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterStatus, gtserror.WithCode) { - // Check that the filter is owned by the given account. - filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) - } +func (p *Processor) StatusesGetForFilterID(ctx context.Context, requester *gtsmodel.Account, filterID string) ([]*apimodel.FilterStatus, gtserror.WithCode) { - filterStatuses, err := p.state.DB.GetFilterStatusesForFilterID( - ctx, - filter.ID, + // Get the filter with given ID (but + // without any sub-models attached). + filter, errWithCode := p.c.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + filterID, ) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + if errWithCode != nil { + return nil, errWithCode + } + + // Fetch all associated filter statuses to the determined existent filter. + filterStatuses, err := p.state.DB.GetFilterStatusesByIDs(ctx, filter.StatusIDs) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter statuses: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilterStatuses := make([]*apimodel.FilterStatus, 0, len(filterStatuses)) - for _, filterStatus := range filterStatuses { - apiFilterStatuses = append(apiFilterStatuses, p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus)) + // Convert all of the filter status models from internal to frontend form. + apiFilterStatuses := make([]*apimodel.FilterStatus, len(filterStatuses)) + if len(apiFilterStatuses) != len(filterStatuses) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) + } + for i, filterStatus := range filterStatuses { + apiFilterStatuses[i] = typeutils.FilterStatusToAPIFilterStatus(filterStatus) } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go index d5b5cce01..f55f99bd5 100644 --- a/internal/processing/filters/v2/update.go +++ b/internal/processing/filters/v2/update.go @@ -20,250 +20,365 @@ package v2 import ( "context" "errors" - "fmt" - 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/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" + "net/http" + "slices" "time" + + 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/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Update an existing filter for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. func (p *Processor) Update( ctx context.Context, - account *gtsmodel.Account, + requester *gtsmodel.Account, filterID string, form *apimodel.FilterUpdateRequestV2, ) (*apimodel.FilterV2, gtserror.WithCode) { - var errWithCode gtserror.WithCode - - // Get the filter by ID, with existing keywords and statuses. - filter, err := p.state.DB.GetFilterByID(ctx, filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return nil, errWithCode } - // Filter columns that we're going to update. - filterColumns := []string{} + // Filter columns that + // we're going to update. + cols := make([]string, 0, 6) - // Apply filter changes. + // Check for title change. if form.Title != nil { - filterColumns = append(filterColumns, "title") + cols = append(cols, "title") filter.Title = *form.Title } + + // Check action type change. if form.FilterAction != nil { - filterColumns = append(filterColumns, "action") + cols = append(cols, "action") + + // Parse filter action from form and set on filter, checking for validity. filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction) + if filter.Action == 0 { + const text = "invalid filter action" + return nil, gtserror.NewWithCode(http.StatusBadRequest, text) + } } + + // Check expiry change. if form.ExpiresIn != nil { - expiresIn := *form.ExpiresIn - filterColumns = append(filterColumns, "expires_at") - if expiresIn == 0 { - // Unset the expiration date. - filter.ExpiresAt = time.Time{} - } else { - // Update the expiration date. - filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(expiresIn)) + cols = append(cols, "expires_at") + filter.ExpiresAt = time.Time{} + + // Check form for valid + // expiry and set on filter. + if *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + filter.ExpiresAt = time.Now().Add(expiresIn) } } + + // Check context change. if form.Context != nil { - filterColumns = append(filterColumns, - "context_home", - "context_notifications", - "context_public", - "context_thread", - "context_account", - ) - filter.ContextHome = util.Ptr(false) - filter.ContextNotifications = util.Ptr(false) - filter.ContextPublic = util.Ptr(false) - filter.ContextThread = util.Ptr(false) - filter.ContextAccount = util.Ptr(false) - for _, context := range *form.Context { - switch context { - case apimodel.FilterContextHome: - filter.ContextHome = util.Ptr(true) - case apimodel.FilterContextNotifications: - filter.ContextNotifications = util.Ptr(true) - case apimodel.FilterContextPublic: - filter.ContextPublic = util.Ptr(true) - case apimodel.FilterContextThread: - filter.ContextThread = util.Ptr(true) - case apimodel.FilterContextAccount: - filter.ContextAccount = util.Ptr(true) - default: - return nil, gtserror.NewErrorUnprocessableEntity( - fmt.Errorf("unsupported filter context '%s'", context), - ) - } + cols = append(cols, "contexts") + + // Parse contexts filter applies in from incoming request form data. + filter.Contexts, errWithCode = common.FromAPIContexts(*form.Context) + if errWithCode != nil { + return nil, errWithCode } } - filterKeywordColumns, deleteFilterKeywordIDs, errWithCode := applyKeywordChanges(filter, form.Keywords) - if err != nil { + // Check for any changes to attached keywords on filter. + keywordQs, errWithCode := p.updateFilterKeywords(ctx, + filter, form.Keywords) + if errWithCode != nil { return nil, errWithCode + } else if len(keywordQs.create) > 0 || len(keywordQs.delete) > 0 { + + // Attached keywords have changed. + cols = append(cols, "keywords") } - deleteFilterStatusIDs, errWithCode := applyStatusChanges(filter, form.Statuses) - if err != nil { + // Check for any changes to attached statuses on filter. + statusQs, errWithCode := p.updateFilterStatuses(ctx, + filter, form.Statuses) + if errWithCode != nil { return nil, errWithCode - } + } else if len(statusQs.create) > 0 || len(statusQs.delete) > 0 { - if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, deleteFilterKeywordIDs, deleteFilterStatusIDs); err != nil { - if errors.Is(err, db.ErrAlreadyExists) { - err = errors.New("you already have a filter with this title") - return nil, gtserror.NewErrorConflict(err, err.Error()) - } - return nil, gtserror.NewErrorInternalError(err) + // Attached statuses have changed. + cols = append(cols, "statuses") } - apiFilter, errWithCode := p.apiFilter(ctx, filter) + // Perform all the deferred database queries. + errWithCode = performTxs(keywordQs, statusQs) if errWithCode != nil { return nil, errWithCode } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Update the filter model in the database with determined cols. + switch err := p.state.DB.UpdateFilter(ctx, filter, cols...); { + case err == nil: + // no issue - return apiFilter, nil -} + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title" + return nil, gtserror.NewWithCode(http.StatusConflict, text) -// applyKeywordChanges applies the provided changes to the filter's keywords in place, -// and returns a list of lists of filter columns to update, and a list of filter keyword IDs to delete. -func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.FilterKeywordCreateUpdateDeleteRequest) ([][]string, []string, gtserror.WithCode) { - if len(formKeywords) == 0 { - // Detach currently existing keywords from the filter so we don't change them. - filter.Keywords = nil - return nil, nil, nil + default: + err := gtserror.Newf("error updating filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - deleteFilterKeywordIDs := []string{} - filterKeywordsByID := map[string]*gtsmodel.FilterKeyword{} - filterKeywordColumnsByID := map[string][]string{} - for _, filterKeyword := range filter.Keywords { - filterKeywordsByID[filterKeyword.ID] = filterKeyword + // Handle filter change side-effects. + p.c.OnFilterChanged(ctx, requester) + + // Return as converted frontend filter model. + return typeutils.FilterToAPIFilterV2(filter), nil +} + +func (p *Processor) updateFilterKeywords(ctx context.Context, filter *gtsmodel.Filter, form []apimodel.FilterKeywordCreateUpdateDeleteRequest) (deferredQs, gtserror.WithCode) { + if len(form) == 0 { + // No keyword changes. + return deferredQs{}, nil } - for _, formKeyword := range formKeywords { - if formKeyword.ID != nil { - id := *formKeyword.ID - filterKeyword, ok := filterKeywordsByID[id] - if !ok { - return nil, nil, gtserror.NewErrorNotFound( - fmt.Errorf("couldn't find filter keyword '%s' to update or delete", id), - ) + var deferred deferredQs + for _, request := range form { + if request.ID != nil { + // Look by ID for keyword attached to filter. + idx := slices.IndexFunc(filter.Keywords, + func(f *gtsmodel.FilterKeyword) bool { + return f.ID == (*request.ID) + }) + if idx == -1 { + const text = "filter keyword not found" + return deferred, gtserror.NewWithCode(http.StatusNotFound, text) } - // Process deletes. - if *formKeyword.Destroy { - delete(filterKeywordsByID, id) - deleteFilterKeywordIDs = append(deleteFilterKeywordIDs, id) + // If this is a delete, update filter's id list. + if request.Destroy != nil && *request.Destroy { + filter.Keywords = slices.Delete(filter.Keywords, idx, idx+1) + filter.KeywordIDs = slices.Delete(filter.KeywordIDs, idx, idx+1) + + // Append database delete to funcs for later processing by caller. + deferred.delete = append(deferred.delete, func() gtserror.WithCode { + if err := p.state.DB.DeleteFilterKeywordsByIDs(ctx, *request.ID); // + err != nil { + err := gtserror.Newf("error deleting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) continue } - // Process updates. - columns := make([]string, 0, 2) - if formKeyword.Keyword != nil { - columns = append(columns, "keyword") - filterKeyword.Keyword = *formKeyword.Keyword + // Get the filter keyword at index. + filterKeyword := filter.Keywords[idx] + + // Filter keywords database + // columns we need to update. + cols := make([]string, 0, 2) + + // Check for changes to keyword string. + if val := request.Keyword; val != nil { + cols = append(cols, "keyword") + filterKeyword.Keyword = *val + } + + // Check for changes to wholeword flag. + if val := request.WholeWord; val != nil { + cols = append(cols, "whole_word") + filterKeyword.WholeWord = val } - if formKeyword.WholeWord != nil { - columns = append(columns, "whole_word") - filterKeyword.WholeWord = formKeyword.WholeWord + + // Verify that this is valid regular expression. + if err := filterKeyword.Compile(); err != nil { + const text = "invalid regular expression" + err := gtserror.Newf("invalid regular expression: %w", err) + return deferred, gtserror.NewWithCodeSafe( + http.StatusBadRequest, + err, text, + ) + } + + if len(cols) > 0 { + // Append database update to funcs for later processing by caller. + deferred.update = append(deferred.update, func() gtserror.WithCode { + if err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, cols...); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate keyword" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error updating filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) } - filterKeywordColumnsByID[id] = columns + continue } - // Process creates. + // Check for valid request. + if request.Keyword == nil { + const text = "missing keyword" + return deferred, gtserror.NewWithCode(http.StatusBadRequest, text) + } + + // Create new filter keyword for insert. filterKeyword := >smodel.FilterKeyword{ ID: id.NewULID(), - AccountID: filter.AccountID, FilterID: filter.ID, - Filter: filter, - Keyword: *formKeyword.Keyword, - WholeWord: util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false)), + Keyword: *request.Keyword, + WholeWord: request.WholeWord, } - filterKeywordsByID[filterKeyword.ID] = filterKeyword - // Don't need to set columns, as we're using all of them. - } - // Replace the filter's keywords list with our updated version. - filterKeywordColumns := [][]string{} - filter.Keywords = nil - for id, filterKeyword := range filterKeywordsByID { + // Verify that this is valid regular expression. + if err := filterKeyword.Compile(); err != nil { + const text = "invalid regular expression" + err := gtserror.Newf("invalid regular expression: %w", err) + return deferred, gtserror.NewWithCodeSafe( + http.StatusBadRequest, + err, text, + ) + } + + // Append new filter keyword to filter and list of IDs. filter.Keywords = append(filter.Keywords, filterKeyword) - // Okay to use the nil slice zero value for entries being created instead of updated. - filterKeywordColumns = append(filterKeywordColumns, filterKeywordColumnsByID[id]) + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) + + // Append database insert to funcs for later processing by caller. + deferred.create = append(deferred.create, func() gtserror.WithCode { + if err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate keyword" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error inserting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) } - return filterKeywordColumns, deleteFilterKeywordIDs, nil + return deferred, nil } -// applyKeywordChanges applies the provided changes to the filter's keywords in place, -// and returns a list of filter status IDs to delete. -func applyStatusChanges(filter *gtsmodel.Filter, formStatuses []apimodel.FilterStatusCreateDeleteRequest) ([]string, gtserror.WithCode) { - if len(formStatuses) == 0 { - // Detach currently existing statuses from the filter so we don't change them. - filter.Statuses = nil - return nil, nil - } - - deleteFilterStatusIDs := []string{} - filterStatusesByID := map[string]*gtsmodel.FilterStatus{} - for _, filterStatus := range filter.Statuses { - filterStatusesByID[filterStatus.ID] = filterStatus +func (p *Processor) updateFilterStatuses(ctx context.Context, filter *gtsmodel.Filter, form []apimodel.FilterStatusCreateDeleteRequest) (deferredQs, gtserror.WithCode) { + if len(form) == 0 { + // No keyword changes. + return deferredQs{}, nil } - for _, formStatus := range formStatuses { - if formStatus.ID != nil { - id := *formStatus.ID - _, ok := filterStatusesByID[id] - if !ok { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("couldn't find filter status '%s' to delete", id), - ) + var deferred deferredQs + for _, request := range form { + if request.ID != nil { + // Look by ID for status attached to filter. + idx := slices.IndexFunc(filter.Statuses, + func(f *gtsmodel.FilterStatus) bool { + return f.ID == *request.ID + }) + if idx == -1 { + const text = "filter status not found" + return deferred, gtserror.NewWithCode(http.StatusNotFound, text) } - // Process deletes. - if *formStatus.Destroy { - delete(filterStatusesByID, id) - deleteFilterStatusIDs = append(deleteFilterStatusIDs, id) - continue - } + // If this is a delete, update filter's id list. + if request.Destroy != nil && *request.Destroy { + filter.Statuses = slices.Delete(filter.Statuses, idx, idx+1) + filter.StatusIDs = slices.Delete(filter.StatusIDs, idx, idx+1) - // Filter statuses don't have updates. + // Append database delete to funcs for later processing by caller. + deferred.delete = append(deferred.delete, func() gtserror.WithCode { + if err := p.state.DB.DeleteFilterStatusesByIDs(ctx, *request.ID); // + err != nil { + err := gtserror.Newf("error deleting filter status: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) + } continue } - // Process creates. + // Check for valid request. + if request.StatusID == nil { + const text = "missing status" + return deferred, gtserror.NewWithCode(http.StatusBadRequest, text) + } + + // Create new filter status for insert. filterStatus := >smodel.FilterStatus{ - ID: id.NewULID(), - AccountID: filter.AccountID, - FilterID: filter.ID, - Filter: filter, - StatusID: *formStatus.StatusID, + ID: id.NewULID(), + FilterID: filter.ID, + StatusID: *request.StatusID, } - filterStatusesByID[filterStatus.ID] = filterStatus - } - // Replace the filter's keywords list with our updated version. - filter.Statuses = nil - for _, filterStatus := range filterStatusesByID { + // Append new filter status to filter and list of IDs. filter.Statuses = append(filter.Statuses, filterStatus) + filter.StatusIDs = append(filter.StatusIDs, filterStatus.ID) + + // Append database insert to funcs for later processing by caller. + deferred.create = append(deferred.create, func() gtserror.WithCode { + if err := p.state.DB.PutFilterStatus(ctx, filterStatus); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate status" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error inserting filter status: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) + } + + return deferred, nil +} + +// deferredQs stores selection of +// deferred database queries. +type deferredQs struct { + create []func() gtserror.WithCode + update []func() gtserror.WithCode + delete []func() gtserror.WithCode +} + +// performTx performs the passed deferredQs functions, +// prioritising create / update operations before deletes. +func performTxs(queries ...deferredQs) gtserror.WithCode { + + // Perform create / update + // operations before anything. + for _, q := range queries { + for _, create := range q.create { + if errWithCode := create(); errWithCode != nil { + return errWithCode + } + } + for _, update := range q.update { + if errWithCode := update(); errWithCode != nil { + return errWithCode + } + } + } + + // Perform deletes last. + for _, q := range queries { + for _, delete := range q.delete { + if errWithCode := delete(); errWithCode != nil { + return errWithCode + } + } } - return deleteFilterStatusIDs, nil + return nil } diff --git a/internal/processing/followrequest_test.go b/internal/processing/followrequest_test.go index db0419522..d5b7fdad2 100644 --- a/internal/processing/followrequest_test.go +++ b/internal/processing/followrequest_test.go @@ -18,17 +18,16 @@ package processing_test import ( - "context" "encoding/json" "fmt" "io" "testing" "time" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" ) // TODO: move this to the "internal/processing/account" pkg @@ -53,11 +52,11 @@ func (suite *FollowRequestTestSuite) TestFollowRequestAccept() { TargetAccountID: requestingAccount.ID, } - err := suite.db.Put(context.Background(), fr) + err := suite.db.Put(suite.T().Context(), fr) suite.NoError(err) relationship, errWithCode := suite.processor.Account().FollowRequestAccept( - context.Background(), + suite.T().Context(), requestingAccount, targetAccount.ID, ) @@ -139,11 +138,11 @@ func (suite *FollowRequestTestSuite) TestFollowRequestReject() { TargetAccountID: requestingAccount.ID, } - err := suite.db.Put(context.Background(), fr) + err := suite.db.Put(suite.T().Context(), fr) suite.NoError(err) relationship, errWithCode := suite.processor.Account().FollowRequestReject( - context.Background(), + suite.T().Context(), requestingAccount, targetAccount.ID, ) diff --git a/internal/processing/instance.go b/internal/processing/instance.go index 2f4c40416..e1a3785e9 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -19,19 +19,22 @@ package processing import ( "context" + "errors" "fmt" - "sort" - - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/text" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/internal/validate" + "slices" + "strings" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "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/log" + "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/util/xslices" + "code.superseriousbusiness.org/gotosocial/internal/validate" ) func (p *Processor) InstanceGetV1(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) { @@ -62,70 +65,126 @@ func (p *Processor) InstanceGetV2(ctx context.Context) (*apimodel.InstanceV2, gt return ai, nil } -func (p *Processor) InstancePeersGet(ctx context.Context, includeSuspended bool, includeOpen bool, flat bool) (interface{}, gtserror.WithCode) { - domains := []*apimodel.Domain{} +func (p *Processor) InstancePeersGet( + ctx context.Context, + includeBlocked bool, + includeAllowed bool, + includeOpen bool, + flatten bool, + includeSeverity bool, +) (any, gtserror.WithCode) { + var ( + domainPerms []gtsmodel.DomainPermission + apiDomains []*apimodel.Domain + ) + + if includeBlocked { + blocks, err := p.state.DB.GetDomainBlocks(ctx) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain blocks: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } - if includeOpen { - instances, err := p.state.DB.GetInstancePeers(ctx, false) - if err != nil && err != db.ErrNoEntries { - err = fmt.Errorf("error selecting instance peers: %s", err) + for _, block := range blocks { + domainPerms = append(domainPerms, block) + } + + } else if includeAllowed { + allows, err := p.state.DB.GetDomainAllows(ctx) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain allows: %w", err) return nil, gtserror.NewErrorInternalError(err) } - for _, i := range instances { - // Domain may be in Punycode, - // de-punify it just in case. - d, err := util.DePunify(i.Domain) - if err != nil { - log.Errorf(ctx, "couldn't depunify domain %s: %s", i.Domain, err) - continue - } + for _, allow := range allows { + domainPerms = append(domainPerms, allow) + } + } - domains = append(domains, &apimodel.Domain{Domain: d}) + for _, domainPerm := range domainPerms { + // Domain may be in Punycode, + // de-punify it just in case. + domain := domainPerm.GetDomain() + depunied, err := util.DePunify(domain) + if err != nil { + log.Errorf(ctx, "couldn't depunify domain %s: %v", domain, err) + continue } + + if util.PtrOrZero(domainPerm.GetObfuscate()) { + // Obfuscate the de-punified version. + depunied = obfuscate(depunied) + } + + apiDomain := &apimodel.Domain{ + Domain: depunied, + Comment: util.Ptr(domainPerm.GetPublicComment()), + } + + if domainPerm.GetType() == gtsmodel.DomainPermissionBlock { + const severity = "suspend" + apiDomain.Severity = severity + suspendedAt := domainPerm.GetCreatedAt() + apiDomain.SuspendedAt = util.FormatISO8601(suspendedAt) + } + + apiDomains = append(apiDomains, apiDomain) } - if includeSuspended { - domainBlocks := []*gtsmodel.DomainBlock{} - if err := p.state.DB.GetAll(ctx, &domainBlocks); err != nil && err != db.ErrNoEntries { + if includeOpen { + instances, err := p.state.DB.GetInstancePeers(ctx, false) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = gtserror.Newf("db error getting instance peers: %w", err) return nil, gtserror.NewErrorInternalError(err) } - for _, domainBlock := range domainBlocks { + for _, instance := range instances { // Domain may be in Punycode, // de-punify it just in case. - d, err := util.DePunify(domainBlock.Domain) + domain := instance.Domain + depunied, err := util.DePunify(domain) if err != nil { - log.Errorf(ctx, "couldn't depunify domain %s: %s", domainBlock.Domain, err) + log.Errorf(ctx, "couldn't depunify domain %s: %v", domain, err) continue } - if *domainBlock.Obfuscate { - // Obfuscate the de-punified version. - d = obfuscate(d) - } - - domains = append(domains, &apimodel.Domain{ - Domain: d, - SuspendedAt: util.FormatISO8601(domainBlock.CreatedAt), - PublicComment: domainBlock.PublicComment, - }) + apiDomains = append( + apiDomains, + &apimodel.Domain{ + Domain: depunied, + }, + ) } } - sort.Slice(domains, func(i, j int) bool { - return domains[i].Domain < domains[j].Domain - }) - - if flat { - flattened := []string{} - for _, d := range domains { - flattened = append(flattened, d.Domain) - } - return flattened, nil + // Sort a-z. + slices.SortFunc( + apiDomains, + func(a, b *apimodel.Domain) int { + return strings.Compare(a.Domain, b.Domain) + }, + ) + + // Deduplicate. + apiDomains = xslices.DeduplicateFunc( + apiDomains, + func(v *apimodel.Domain) string { + return v.Domain + }, + ) + + if flatten { + // Return just the domains. + return xslices.Gather( + []string{}, + apiDomains, + func(v *apimodel.Domain) string { + return v.Domain + }, + ), nil } - return domains, nil + return apiDomains, nil } func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRule, gtserror.WithCode) { @@ -165,7 +224,7 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe } // Don't allow html in site title. - instance.Title = text.SanitizeToPlaintext(title) + instance.Title = text.StripHTMLFromText(title) columns = append(columns, "title") } @@ -235,7 +294,7 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe return nil, gtserror.NewErrorBadRequest(err, err.Error()) } - instance.CustomCSS = text.SanitizeToPlaintext(customCSS) + instance.CustomCSS = text.StripHTMLFromText(customCSS) columns = append(columns, []string{"custom_css"}...) } diff --git a/internal/processing/interactionrequests/accept.go b/internal/processing/interactionrequests/accept.go index ad86e50d1..7efd1f373 100644 --- a/internal/processing/interactionrequests/accept.go +++ b/internal/processing/interactionrequests/accept.go @@ -21,13 +21,13 @@ import ( "context" "time" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // Accept accepts an interaction request with the given ID, @@ -65,14 +65,16 @@ func (p *Processor) Accept( defer unlock() // Mark the request as accepted - // and generate a URI for it. + // and generate URIs for it. req.AcceptedAt = time.Now() - req.URI = uris.GenerateURIForAccept(acct.Username, req.ID) + req.ResponseURI = uris.GenerateURIForAccept(acct.Username, req.ID) + req.AuthorizationURI = uris.GenerateURIForAuthorization(acct.Username, req.ID) if err := p.state.DB.UpdateInteractionRequest( ctx, req, "accepted_at", - "uri", + "response_uri", + "authorization_uri", ); err != nil { err := gtserror.Newf("db error updating interaction request: %w", err) return nil, gtserror.NewErrorInternalError(err) @@ -132,7 +134,7 @@ func (p *Processor) acceptLike( // Update the Like. req.Like.PendingApproval = util.Ptr(false) req.Like.PreApproved = false - req.Like.ApprovedByURI = req.URI + req.Like.ApprovedByURI = req.AuthorizationURI if err := p.state.DB.UpdateStatusFave( ctx, req.Like, @@ -173,7 +175,7 @@ func (p *Processor) acceptReply( // Update the Reply. req.Reply.PendingApproval = util.Ptr(false) req.Reply.PreApproved = false - req.Reply.ApprovedByURI = req.URI + req.Reply.ApprovedByURI = req.AuthorizationURI if err := p.state.DB.UpdateStatus( ctx, req.Reply, @@ -214,7 +216,7 @@ func (p *Processor) acceptAnnounce( // Update the Announce. req.Announce.PendingApproval = util.Ptr(false) req.Announce.PreApproved = false - req.Announce.ApprovedByURI = req.URI + req.Announce.ApprovedByURI = req.AuthorizationURI if err := p.state.DB.UpdateStatus( ctx, req.Announce, diff --git a/internal/processing/interactionrequests/accept_test.go b/internal/processing/interactionrequests/accept_test.go index 75fb1e512..cb4212c24 100644 --- a/internal/processing/interactionrequests/accept_test.go +++ b/internal/processing/interactionrequests/accept_test.go @@ -18,13 +18,12 @@ package interactionrequests_test import ( - "context" "testing" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/processing/interactionrequests" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" - "github.com/superseriousbusiness/gotosocial/testrig" ) type AcceptTestSuite struct { @@ -36,7 +35,7 @@ func (suite *AcceptTestSuite) TestAccept() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() state = testStructs.State acct = suite.testAccounts["local_account_2"] intReq = suite.testInteractionRequests["admin_account_reply_turtle"] @@ -68,7 +67,7 @@ func (suite *AcceptTestSuite) TestAccept() { suite.FailNow(err.Error()) } suite.False(*dbStatus.PendingApproval) - suite.Equal(dbReq.URI, dbStatus.ApprovedByURI) + suite.Equal(dbReq.AuthorizationURI, dbStatus.ApprovedByURI) // Wait for a notification // for interacting status. diff --git a/internal/processing/interactionrequests/get.go b/internal/processing/interactionrequests/get.go index 8f8a7f35d..397730855 100644 --- a/internal/processing/interactionrequests/get.go +++ b/internal/processing/interactionrequests/get.go @@ -23,13 +23,13 @@ import ( "net/url" "strconv" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/paging" + 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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) // GetPage returns a page of interaction requests targeting diff --git a/internal/processing/interactionrequests/interactionrequests.go b/internal/processing/interactionrequests/interactionrequests.go index d56636233..435bbd42f 100644 --- a/internal/processing/interactionrequests/interactionrequests.go +++ b/internal/processing/interactionrequests/interactionrequests.go @@ -18,9 +18,9 @@ package interactionrequests import ( - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Processor wraps functionality for getting, diff --git a/internal/processing/interactionrequests/interactionrequests_test.go b/internal/processing/interactionrequests/interactionrequests_test.go index ce2aa351a..6459036fc 100644 --- a/internal/processing/interactionrequests/interactionrequests_test.go +++ b/internal/processing/interactionrequests/interactionrequests_test.go @@ -18,9 +18,9 @@ package interactionrequests_test import ( + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" ) const ( diff --git a/internal/processing/interactionrequests/reject.go b/internal/processing/interactionrequests/reject.go index dcf07a03f..4db52e260 100644 --- a/internal/processing/interactionrequests/reject.go +++ b/internal/processing/interactionrequests/reject.go @@ -21,12 +21,12 @@ import ( "context" "time" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/ap" + 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/messages" + "code.superseriousbusiness.org/gotosocial/internal/uris" ) // Reject rejects an interaction request with the given ID, @@ -66,12 +66,12 @@ func (p *Processor) Reject( // Mark the request as rejected // and generate a URI for it. req.RejectedAt = time.Now() - req.URI = uris.GenerateURIForReject(acct.Username, req.ID) + req.ResponseURI = uris.GenerateURIForReject(acct.Username, req.ID) if err := p.state.DB.UpdateInteractionRequest( ctx, req, "rejected_at", - "uri", + "response_uri", ); err != nil { err := gtserror.Newf("db error updating interaction request: %w", err) return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/processing/interactionrequests/reject_test.go b/internal/processing/interactionrequests/reject_test.go index 6e4aac691..76e31b491 100644 --- a/internal/processing/interactionrequests/reject_test.go +++ b/internal/processing/interactionrequests/reject_test.go @@ -18,15 +18,14 @@ package interactionrequests_test import ( - "context" "errors" "testing" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/processing/interactionrequests" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" - "github.com/superseriousbusiness/gotosocial/testrig" ) type RejectTestSuite struct { @@ -38,7 +37,7 @@ func (suite *RejectTestSuite) TestReject() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() state = testStructs.State acct = suite.testAccounts["local_account_2"] intReq = suite.testInteractionRequests["admin_account_reply_turtle"] diff --git a/internal/processing/list/create.go b/internal/processing/list/create.go index dacd7909f..43bec321a 100644 --- a/internal/processing/list/create.go +++ b/internal/processing/list/create.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/id" + 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" ) // Create creates one a new list for the given account, using the provided parameters. diff --git a/internal/processing/list/delete.go b/internal/processing/list/delete.go index 327ac9d16..8039487dd 100644 --- a/internal/processing/list/delete.go +++ b/internal/processing/list/delete.go @@ -20,9 +20,9 @@ package list import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // Delete deletes one list for the given account. diff --git a/internal/processing/list/get.go b/internal/processing/list/get.go index b98678eef..aefa01027 100644 --- a/internal/processing/list/get.go +++ b/internal/processing/list/get.go @@ -21,13 +21,13 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/paging" + 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" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) // Get returns the api model of one list with the given ID. diff --git a/internal/processing/list/list.go b/internal/processing/list/list.go index 0003816fb..453bf3a57 100644 --- a/internal/processing/list/list.go +++ b/internal/processing/list/list.go @@ -18,8 +18,8 @@ package list import ( - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/list/update.go b/internal/processing/list/update.go index 408c334de..c64686344 100644 --- a/internal/processing/list/update.go +++ b/internal/processing/list/update.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/gtscontext" - "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/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // Update updates one list for the given account, using the provided parameters. diff --git a/internal/processing/list/updateentries.go b/internal/processing/list/updateentries.go index c15248f39..7cf6a3cd3 100644 --- a/internal/processing/list/updateentries.go +++ b/internal/processing/list/updateentries.go @@ -22,12 +22,12 @@ import ( "errors" "fmt" - "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/util" + "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/util" ) // AddToList adds targetAccountIDs to the given list, if valid. diff --git a/internal/processing/list/util.go b/internal/processing/list/util.go index 74d148704..faed5479b 100644 --- a/internal/processing/list/util.go +++ b/internal/processing/list/util.go @@ -22,10 +22,10 @@ import ( "errors" "fmt" - 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" ) // getList is a shortcut to get one list from the database and diff --git a/internal/processing/markers/get.go b/internal/processing/markers/get.go index 38e8b53dc..19c8feedc 100644 --- a/internal/processing/markers/get.go +++ b/internal/processing/markers/get.go @@ -22,11 +22,11 @@ import ( "errors" "fmt" - 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/typeutils" + 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/typeutils" ) // Get returns an API model for the markers of the requested timelines. diff --git a/internal/processing/markers/markers.go b/internal/processing/markers/markers.go index 8817ed1a7..18624d44c 100644 --- a/internal/processing/markers/markers.go +++ b/internal/processing/markers/markers.go @@ -18,8 +18,8 @@ package markers import ( - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/markers/update.go b/internal/processing/markers/update.go index 22fe65faf..21c1bc169 100644 --- a/internal/processing/markers/update.go +++ b/internal/processing/markers/update.go @@ -22,10 +22,10 @@ import ( "errors" "fmt" - 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" ) // Update updates the given markers and returns an API model for them. diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index 5ea630618..aaccf4bde 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -23,13 +23,14 @@ import ( "fmt" "io" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" "codeberg.org/gruf/go-iotools" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" ) // Create creates a new media attachment belonging to the given account, using the request form. @@ -89,11 +90,6 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, errWithCode } - apiAttachment, err := p.converter.AttachmentToAPIAttachment(ctx, attachment) - if err != nil { - err := fmt.Errorf("error parsing media attachment to frontend type: %s", err) - return nil, gtserror.NewErrorInternalError(err) - } - - return &apiAttachment, nil + a := typeutils.AttachmentToAPIAttachment(attachment) + return &a, nil } diff --git a/internal/processing/media/delete.go b/internal/processing/media/delete.go index 32650fb2c..afb5af1f3 100644 --- a/internal/processing/media/delete.go +++ b/internal/processing/media/delete.go @@ -23,9 +23,9 @@ import ( "fmt" "strings" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/storage" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/storage" ) // Delete deletes the media attachment with the given ID, including all files pertaining to that attachment. diff --git a/internal/processing/media/getemoji.go b/internal/processing/media/getemoji.go index 06712756a..d80886979 100644 --- a/internal/processing/media/getemoji.go +++ b/internal/processing/media/getemoji.go @@ -21,10 +21,10 @@ import ( "context" "fmt" - 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/log" + 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" ) // GetCustomEmojis returns a list of all useable local custom emojis stored on this instance. diff --git a/internal/processing/media/getemoji_test.go b/internal/processing/media/getemoji_test.go index eeb1e5020..0e2c2d0cc 100644 --- a/internal/processing/media/getemoji_test.go +++ b/internal/processing/media/getemoji_test.go @@ -18,7 +18,6 @@ package media_test import ( - "context" "testing" "github.com/stretchr/testify/suite" @@ -29,7 +28,7 @@ type GetEmojiTestSuite struct { } func (suite *GetEmojiTestSuite) TestGetCustomEmojis() { - emojis, err := suite.mediaProcessor.GetCustomEmojis(context.Background()) + emojis, err := suite.mediaProcessor.GetCustomEmojis(suite.T().Context()) suite.NoError(err) suite.Equal(1, len(emojis)) diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go index 11d8f7eb5..0aeac04b3 100644 --- a/internal/processing/media/getfile.go +++ b/internal/processing/media/getfile.go @@ -21,18 +21,21 @@ import ( "context" "errors" "fmt" + "io" + "net/http" "net/url" "strings" "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/media" - "github.com/superseriousbusiness/gotosocial/internal/regexes" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/uris" + 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/media" + "code.superseriousbusiness.org/gotosocial/internal/regexes" + "code.superseriousbusiness.org/gotosocial/internal/storage" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // GetFile retrieves a file from storage and streams it back @@ -162,47 +165,94 @@ func (p *Processor) getAttachmentContent( requestUser = requester.Username } - // Ensure that stored media is cached. - // (this handles local media / recaches). - attach, err = p.federator.RefreshMedia( - ctx, - requestUser, - attach, - media.AdditionalMediaInfo{}, - false, - ) - if err != nil { - err := gtserror.Newf("error recaching media: %w", err) - return nil, gtserror.NewErrorNotFound(err) - } - - // Start preparing API content model. - apiContent := &apimodel.Content{} - - // Retrieve appropriate - // size file from storage. + // Start preparing API content model and other + // values depending on requested media size. + var content apimodel.Content + var mediaPath string switch sizeStr { + // Original media size. case media.SizeOriginal: - apiContent.ContentType = attach.File.ContentType - apiContent.ContentLength = int64(attach.File.FileSize) - return p.getContent(ctx, - attach.File.Path, - apiContent, - ) + content.ContentType = attach.File.ContentType + content.ContentLength = int64(attach.File.FileSize) + mediaPath = attach.File.Path + // Thumbnail media size. case media.SizeSmall: - apiContent.ContentType = attach.Thumbnail.ContentType - apiContent.ContentLength = int64(attach.Thumbnail.FileSize) - return p.getContent(ctx, - attach.Thumbnail.Path, - apiContent, - ) + content.ContentType = attach.Thumbnail.ContentType + content.ContentLength = int64(attach.Thumbnail.FileSize) + mediaPath = attach.Thumbnail.Path default: - const text = "invalid media attachment size" - return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + const text = "invalid media size" + return nil, gtserror.NewErrorBadRequest( + errors.New(text), + text, + ) + } + + // Attachment file + // stream from storage. + var rc io.ReadCloser + + // Check media is meant + // to be cached locally. + if *attach.Cached { + + // Check storage for media at determined path. + rc, err = p.state.Storage.GetStream(ctx, mediaPath) + if err != nil && !storage.IsNotFound(err) { + err := gtserror.Newf("storage error getting media %s: %w", attach.URL, err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + if rc == nil { + // This is local media without + // a cached attachment, unfulfillable! + if attach.IsLocal() { + return nil, gtserror.NewfWithCode(http.StatusNotFound, + "local media file not found: %s", attach.URL) + } + + // Whether the cached flag was set or + // not, we know it isn't in storage. + attach.Cached = util.Ptr(false) + + // Attempt to recache this remote media. + attach, err = p.federator.RefreshMedia(ctx, + requestUser, + attach, + media.AdditionalMediaInfo{}, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching media %s: %w", attach.URL, err) + return nil, gtserror.NewErrorNotFound(err) + } + + // Check storage for media at determined path. + rc, err = p.state.Storage.GetStream(ctx, mediaPath) + if err != nil && !storage.IsNotFound(err) { + err := gtserror.Newf("storage error getting media %s: %w", attach.URL, err) + return nil, gtserror.NewErrorInternalError(err) + } else if rc == nil { + return nil, gtserror.NewfWithCode(http.StatusNotFound, + "remote media file not found: %s", attach.URL) + } + } + + // If running on S3 storage with proxying disabled, + // just fetch a pre-signed URL instead of the content. + if url := p.state.Storage.URL(ctx, mediaPath); url != nil { + _ = rc.Close() // close storage stream + content.URL = url + return &content, nil } + + // Return with stream. + content.Content = rc + return &content, nil } func (p *Processor) getEmojiContent( @@ -242,82 +292,92 @@ func (p *Processor) getEmojiContent( return nil, gtserror.NewErrorNotFound(errors.New(text), text) } - // Ensure that stored emoji is cached. - // (this handles local emoji / recaches). - emoji, err = p.federator.RecacheEmoji( - ctx, - emoji, - ) - if err != nil { - err := gtserror.Newf("error recaching emoji: %w", err) - return nil, gtserror.NewErrorNotFound(err) - } - - // Start preparing API content model. - apiContent := &apimodel.Content{} - - // Retrieve appropriate - // size file from storage. + // Start preparing API content model and other + // values depending on requested media size. + var content apimodel.Content + var emojiPath string switch sizeStr { + // Original emoji image. case media.SizeOriginal: - apiContent.ContentType = emoji.ImageContentType - apiContent.ContentLength = int64(emoji.ImageFileSize) - return p.getContent(ctx, - emoji.ImagePath, - apiContent, - ) + content.ContentType = emoji.ImageContentType + content.ContentLength = int64(emoji.ImageFileSize) + emojiPath = emoji.ImagePath + // Static emoji image. case media.SizeStatic: - apiContent.ContentType = emoji.ImageStaticContentType - apiContent.ContentLength = int64(emoji.ImageStaticFileSize) - return p.getContent(ctx, - emoji.ImageStaticPath, - apiContent, - ) + content.ContentType = emoji.ImageStaticContentType + content.ContentLength = int64(emoji.ImageStaticFileSize) + emojiPath = emoji.ImageStaticPath default: - const text = "invalid media attachment size" - return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + const text = "invalid emoji size" + return nil, gtserror.NewErrorBadRequest( + errors.New(text), + text, + ) } -} -// getContent performs the final file fetching of -// stored content at path in storage. This is -// populated in the apimodel.Content{} and returned. -// (note: this also handles un-proxied S3 storage). -func (p *Processor) getContent( - ctx context.Context, - path string, - content *apimodel.Content, -) ( - *apimodel.Content, - gtserror.WithCode, -) { - // If running on S3 storage with proxying disabled then - // just fetch pre-signed URL instead of the content. - if url := p.state.Storage.URL(ctx, path); url != nil { - content.URL = url - return content, nil - } + // Emoji image file + // stream from storage. + var rc io.ReadCloser - // Fetch file stream for the stored media at path. - rc, err := p.state.Storage.GetStream(ctx, path) - if err != nil && !storage.IsNotFound(err) { - err := gtserror.Newf("error getting file %s from storage: %w", path, err) - return nil, gtserror.NewErrorInternalError(err) + // Check emoji is meant + // to be cached locally. + if *emoji.Cached { + + // Check storage for emoji at determined image path. + rc, err = p.state.Storage.GetStream(ctx, emojiPath) + if err != nil && !storage.IsNotFound(err) { + err := gtserror.Newf("storage error getting emoji %s: %w", emoji.URI, err) + return nil, gtserror.NewErrorInternalError(err) + } } - // Ensure found. if rc == nil { - err := gtserror.Newf("file not found at %s", path) - const text = "file not found" - return nil, gtserror.NewErrorNotFound(err, text) + // This is a local emoji without + // a cached image, unfulfillable! + if emoji.IsLocal() { + return nil, gtserror.NewfWithCode(http.StatusNotFound, + "local emoji image not found: %s", emoji.URI) + } + + // Whether the cached flag was set or + // not, we know it isn't in storage. + emoji.Cached = util.Ptr(false) + + // Attempt to recache this remote emoji. + emoji, err = p.federator.RecacheEmoji(ctx, + emoji, + false, + ) + if err != nil { + err := gtserror.Newf("error recaching emoji %s: %w", emoji.URI, err) + return nil, gtserror.NewErrorNotFound(err) + } + + // Check storage for emoji at determined image path. + rc, err = p.state.Storage.GetStream(ctx, emojiPath) + if err != nil && !storage.IsNotFound(err) { + err := gtserror.Newf("storage error getting emoji %s after recache: %w", emoji.URI, err) + return nil, gtserror.NewErrorInternalError(err) + } else if rc == nil { + return nil, gtserror.NewfWithCode(http.StatusNotFound, + "remote emoji image not found: %s", emoji.URI) + } + } + + // If running on S3 storage with proxying disabled, + // just fetch a pre-signed URL instead of the content. + if url := p.state.Storage.URL(ctx, emojiPath); url != nil { + _ = rc.Close() // close storage stream + content.URL = url + return &content, nil } // Return with stream. content.Content = rc - return content, nil + return &content, nil } // handles serving Content for "unknown" file diff --git a/internal/processing/media/getfile_test.go b/internal/processing/media/getfile_test.go index 34f5d99a2..9c2ffd589 100644 --- a/internal/processing/media/getfile_test.go +++ b/internal/processing/media/getfile_test.go @@ -18,17 +18,16 @@ package media_test import ( - "context" "io" "path" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/testrig" ) type GetFileTestSuite struct { @@ -36,7 +35,7 @@ type GetFileTestSuite struct { } func (suite *GetFileTestSuite) TestGetRemoteFileCached() { - ctx := context.Background() + ctx := suite.T().Context() testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"] fileName := path.Base(testAttachment.File.Path) @@ -64,7 +63,7 @@ func (suite *GetFileTestSuite) TestGetRemoteFileCached() { } func (suite *GetFileTestSuite) TestGetRemoteFileUncached() { - ctx := context.Background() + ctx := suite.T().Context() // uncache the file from local testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"] @@ -116,7 +115,7 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncached() { } func (suite *GetFileTestSuite) TestGetRemoteFileUncachedInterrupted() { - ctx := context.Background() + ctx := suite.T().Context() // uncache the file from local testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"] @@ -163,7 +162,7 @@ func (suite *GetFileTestSuite) TestGetRemoteFileUncachedInterrupted() { } func (suite *GetFileTestSuite) TestGetRemoteFileThumbnailUncached() { - ctx := context.Background() + ctx := suite.T().Context() testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"] // fetch the existing thumbnail bytes from storage first diff --git a/internal/processing/media/getmedia.go b/internal/processing/media/getmedia.go index 8f5b9d740..22e05cab3 100644 --- a/internal/processing/media/getmedia.go +++ b/internal/processing/media/getmedia.go @@ -22,10 +22,11 @@ import ( "errors" "fmt" - 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" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, mediaAttachmentID string) (*apimodel.Attachment, gtserror.WithCode) { @@ -42,10 +43,6 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, mediaAtt return nil, gtserror.NewErrorNotFound(errors.New("attachment not owned by requesting account")) } - a, err := p.converter.AttachmentToAPIAttachment(ctx, attachment) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) - } - + a := typeutils.AttachmentToAPIAttachment(attachment) return &a, nil } diff --git a/internal/processing/media/media.go b/internal/processing/media/media.go index 76ed68f5a..4967e4d11 100644 --- a/internal/processing/media/media.go +++ b/internal/processing/media/media.go @@ -18,12 +18,12 @@ package media import ( - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/transport" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/transport" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/media/media_test.go b/internal/processing/media/media_test.go index 2930733c4..01506cc6f 100644 --- a/internal/processing/media/media_test.go +++ b/internal/processing/media/media_test.go @@ -18,19 +18,21 @@ package media_test import ( + "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/db" + "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/processing/common" + mediaprocessing "code.superseriousbusiness.org/gotosocial/internal/processing/media" + "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/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - mediaprocessing "github.com/superseriousbusiness/gotosocial/internal/processing/media" - "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 MediaStandardTestSuite struct { @@ -45,7 +47,6 @@ type MediaStandardTestSuite 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 @@ -59,7 +60,6 @@ type MediaStandardTestSuite struct { func (suite *MediaStandardTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() @@ -84,8 +84,10 @@ func (suite *MediaStandardTestSuite) SetupTest() { suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) federator := testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) - filter := visibility.NewFilter(&suite.state) - common := common.New(&suite.state, suite.mediaManager, suite.tc, federator, filter) + visFilter := visibility.NewFilter(&suite.state) + muteFilter := mutes.NewFilter(&suite.state) + statusFilter := status.NewFilter(&suite.state) + common := common.New(&suite.state, suite.mediaManager, suite.tc, federator, visFilter, muteFilter, statusFilter) suite.mediaProcessor = mediaprocessing.New(&common, &suite.state, suite.tc, federator, suite.mediaManager, suite.transportController) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/processing/media/profile.go b/internal/processing/media/profile.go index 5c266e372..38c2893e1 100644 --- a/internal/processing/media/profile.go +++ b/internal/processing/media/profile.go @@ -21,9 +21,9 @@ import ( "context" "fmt" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // DeleteAvatar deletes the account's avatar, if one exists, and returns the updated account. diff --git a/internal/processing/media/unattach.go b/internal/processing/media/unattach.go index ddf2dda20..55d793647 100644 --- a/internal/processing/media/unattach.go +++ b/internal/processing/media/unattach.go @@ -22,10 +22,11 @@ import ( "errors" "fmt" - 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" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Unattach unattaches the media attachment with the given ID from any statuses it was attached to, making it available @@ -49,10 +50,6 @@ func (p *Processor) Unattach(ctx context.Context, account *gtsmodel.Account, med return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error updating attachment: %s", err)) } - a, err := p.converter.AttachmentToAPIAttachment(ctx, attachment) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) - } - + a := typeutils.AttachmentToAPIAttachment(attachment) return &a, nil } diff --git a/internal/processing/media/unattach_test.go b/internal/processing/media/unattach_test.go index 02d2c7077..582e24b9a 100644 --- a/internal/processing/media/unattach_test.go +++ b/internal/processing/media/unattach_test.go @@ -18,7 +18,6 @@ package media_test import ( - "context" "testing" "github.com/stretchr/testify/suite" @@ -29,7 +28,7 @@ type UnattachTestSuite struct { } func (suite *UnattachTestSuite) TestUnattachMedia() { - ctx := context.Background() + ctx := suite.T().Context() testAttachment := suite.testAttachments["admin_account_status_1_attachment_1"] testAccount := suite.testAccounts["admin_account"] diff --git a/internal/processing/media/update.go b/internal/processing/media/update.go index c8592395f..cccc27534 100644 --- a/internal/processing/media/update.go +++ b/internal/processing/media/update.go @@ -22,13 +22,14 @@ import ( "errors" "fmt" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/text" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "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/text" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Update updates a media attachment with the given id, using the provided form parameters. @@ -77,17 +78,13 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media return nil, gtserror.NewErrorInternalError(fmt.Errorf("database error updating media: %s", err)) } - a, err := p.converter.AttachmentToAPIAttachment(ctx, attachment) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) - } - + a := typeutils.AttachmentToAPIAttachment(attachment) return &a, nil } // processDescription will sanitize and valid description against server configuration. func processDescription(description string) (string, gtserror.WithCode) { - description = text.SanitizeToPlaintext(description) + description = text.StripHTMLFromText(description) chars := len([]rune(description)) if min := config.GetMediaDescriptionMinChars(); chars < min { diff --git a/internal/processing/oauth.go b/internal/processing/oauth.go index ae511e949..573c0d53a 100644 --- a/internal/processing/oauth.go +++ b/internal/processing/oauth.go @@ -18,10 +18,11 @@ package processing import ( + "context" "net/http" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/oauth2/v4" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/oauth2/v4" ) func (p *Processor) OAuthHandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) gtserror.WithCode { @@ -38,3 +39,17 @@ func (p *Processor) OAuthValidateBearerToken(r *http.Request) (oauth2.TokenInfo, // todo: some kind of metrics stuff here return p.oauthServer.ValidationBearerToken(r) } + +func (p *Processor) OAuthRevokeAccessToken( + ctx context.Context, + clientID string, + clientSecret string, + accessToken string, +) gtserror.WithCode { + return p.oauthServer.RevokeAccessToken( + ctx, + clientID, + clientSecret, + accessToken, + ) +} diff --git a/internal/processing/parsemention.go b/internal/processing/parsemention.go index 035f057b8..7a75cb9bc 100644 --- a/internal/processing/parsemention.go +++ b/internal/processing/parsemention.go @@ -19,15 +19,17 @@ package processing import ( "context" + "errors" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // GetParseMentionFunc returns a new ParseMentionFunc using the provided state and federator. @@ -100,7 +102,29 @@ func GetParseMentionFunc(state *state.State, federator *federation.Federator) gt } } - // Return mention with useful populated fields, + // Check if the mention was + // in the database already. + if statusID != "" { + mention, err := state.DB.GetMentionByTargetAcctStatus(ctx, targetAcct.ID, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, fmt.Errorf( + "db error checking for existing mention: %w", + err, + ) + } + + if mention != nil { + // We had it, return this rather + // than creating a new one. + mention.NameString = namestring + mention.OriginAccountURI = originAcct.URI + mention.TargetAccountURI = targetAcct.URI + mention.TargetAccountURL = targetAcct.URL + return mention, nil + } + } + + // Return new mention with useful populated fields, // but *don't* store it in the database; that's // up to the calling function to do, if they want. return >smodel.Mention{ @@ -114,6 +138,10 @@ func GetParseMentionFunc(state *state.State, federator *federation.Federator) gt TargetAccountURL: targetAcct.URL, TargetAccount: targetAcct, NameString: namestring, + + // Mention wasn't + // stored in the db. + IsNew: true, }, nil } } diff --git a/internal/processing/parsemention_test.go b/internal/processing/parsemention_test.go index f97133969..2eeedc2ea 100644 --- a/internal/processing/parsemention_test.go +++ b/internal/processing/parsemention_test.go @@ -18,14 +18,13 @@ package processing_test import ( - "context" "errors" "testing" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/processing" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/processing" ) type ParseMentionTestSuite struct { @@ -34,7 +33,7 @@ type ParseMentionTestSuite struct { func (suite *ParseMentionTestSuite) TestParseMentionFunc() { var ( - ctx = context.Background() + ctx = suite.T().Context() parseMention = processing.GetParseMentionFunc(&suite.state, suite.federator) originAcctID = suite.testAccounts["local_account_1"].ID statusID = id.NewULID() diff --git a/internal/processing/polls/expiry.go b/internal/processing/polls/expiry.go index d02a05f0d..5ce893111 100644 --- a/internal/processing/polls/expiry.go +++ b/internal/processing/polls/expiry.go @@ -21,12 +21,12 @@ import ( "context" "time" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "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" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "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" ) func (p *Processor) ScheduleAll(ctx context.Context) error { diff --git a/internal/processing/polls/get.go b/internal/processing/polls/get.go index 42fecbd43..fa4e235f7 100644 --- a/internal/processing/polls/get.go +++ b/internal/processing/polls/get.go @@ -20,9 +20,9 @@ package polls import ( "context" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) func (p *Processor) PollGet(ctx context.Context, requester *gtsmodel.Account, pollID string) (*apimodel.Poll, gtserror.WithCode) { diff --git a/internal/processing/polls/poll.go b/internal/processing/polls/poll.go index fe8fc71c5..c51bb0e96 100644 --- a/internal/processing/polls/poll.go +++ b/internal/processing/polls/poll.go @@ -20,12 +20,12 @@ package polls 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/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "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/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/polls/poll_test.go b/internal/processing/polls/poll_test.go index bf6ae4aad..2fd46c5ee 100644 --- a/internal/processing/polls/poll_test.go +++ b/internal/processing/polls/poll_test.go @@ -23,24 +23,27 @@ import ( "net/http" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/processing/polls" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/processing/polls" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" ) type PollTestSuite struct { suite.Suite - state state.State - filter *visibility.Filter - polls polls.Processor + state state.State + visFilter *visibility.Filter + muteFilter *mutes.Filter + polls polls.Processor testAccounts map[string]*gtsmodel.Account testPolls map[string]*gtsmodel.Poll @@ -56,8 +59,10 @@ func (suite *PollTestSuite) SetupTest() { controller := testrig.NewTestTransportController(&suite.state, nil) mediaMgr := media.NewManager(&suite.state) federator := testrig.NewTestFederator(&suite.state, controller, mediaMgr) - suite.filter = visibility.NewFilter(&suite.state) - common := common.New(&suite.state, mediaMgr, converter, federator, suite.filter) + suite.visFilter = visibility.NewFilter(&suite.state) + suite.muteFilter = mutes.NewFilter(&suite.state) + statusFilter := status.NewFilter(&suite.state) + common := common.New(&suite.state, mediaMgr, converter, federator, suite.visFilter, suite.muteFilter, statusFilter) suite.polls = polls.New(&common, &suite.state, converter) } @@ -68,7 +73,7 @@ func (suite *PollTestSuite) TearDownTest() { func (suite *PollTestSuite) TestPollGet() { // Create a new context for this test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Perform test for all requester + poll combos. @@ -88,7 +93,7 @@ func (suite *PollTestSuite) testPollGet(ctx context.Context, requester *gtsmodel var check func(*apimodel.Poll, gtserror.WithCode) bool switch { - case !pollIsVisible(suite.filter, ctx, requester, poll): + case !pollIsVisible(suite.visFilter, ctx, requester, poll): // Poll should not be visible to requester, this should // return an error code 404 (to prevent info leak). check = func(poll *apimodel.Poll, err gtserror.WithCode) bool { @@ -111,7 +116,7 @@ func (suite *PollTestSuite) testPollGet(ctx context.Context, requester *gtsmodel func (suite *PollTestSuite) TestPollVote() { // Create a new context for this test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // randomChoices generates random vote choices in poll. @@ -188,7 +193,7 @@ func (suite *PollTestSuite) testPollVote(ctx context.Context, requester *gtsmode return poll == nil && err.Code() == http.StatusUnprocessableEntity } - case !pollIsVisible(suite.filter, ctx, requester, poll): + case !pollIsVisible(suite.visFilter, ctx, requester, poll): // Poll should not be visible to requester, this should // return an error code 404 (to prevent info leak). check = func(poll *apimodel.Poll, err gtserror.WithCode) bool { diff --git a/internal/processing/polls/vote.go b/internal/processing/polls/vote.go index 6585793cd..14777d9df 100644 --- a/internal/processing/polls/vote.go +++ b/internal/processing/polls/vote.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/id" - "github.com/superseriousbusiness/gotosocial/internal/messages" + "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/messages" ) func (p *Processor) PollVote(ctx context.Context, requester *gtsmodel.Account, pollID string, choices []int) (*apimodel.Poll, gtserror.WithCode) { diff --git a/internal/processing/preferences.go b/internal/processing/preferences.go index fb445ec5b..ebcb0e6b6 100644 --- a/internal/processing/preferences.go +++ b/internal/processing/preferences.go @@ -20,9 +20,9 @@ package processing import ( "context" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) func (p *Processor) PreferencesGet(ctx context.Context, accountID string) (*apimodel.Preferences, gtserror.WithCode) { diff --git a/internal/processing/preferences_test.go b/internal/processing/preferences_test.go index be88b5edf..daaaef5c6 100644 --- a/internal/processing/preferences_test.go +++ b/internal/processing/preferences_test.go @@ -18,12 +18,11 @@ package processing_test import ( - "context" "testing" + "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) type PreferencesTestSuite struct { @@ -31,7 +30,7 @@ type PreferencesTestSuite struct { } func (suite *PreferencesTestSuite) TestPreferencesGet() { - ctx := context.Background() + ctx := suite.T().Context() tests := []struct { act *gtsmodel.Account prefs *model.Preferences diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 0bba23089..a5cea5da4 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -18,41 +18,45 @@ package processing import ( - "github.com/superseriousbusiness/gotosocial/internal/cleaner" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - mm "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing/account" - "github.com/superseriousbusiness/gotosocial/internal/processing/admin" - "github.com/superseriousbusiness/gotosocial/internal/processing/advancedmigrations" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/processing/conversations" - "github.com/superseriousbusiness/gotosocial/internal/processing/fedi" - filtersv1 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v1" - filtersv2 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v2" - "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" - "github.com/superseriousbusiness/gotosocial/internal/processing/list" - "github.com/superseriousbusiness/gotosocial/internal/processing/markers" - "github.com/superseriousbusiness/gotosocial/internal/processing/media" - "github.com/superseriousbusiness/gotosocial/internal/processing/polls" - "github.com/superseriousbusiness/gotosocial/internal/processing/push" - "github.com/superseriousbusiness/gotosocial/internal/processing/report" - "github.com/superseriousbusiness/gotosocial/internal/processing/search" - "github.com/superseriousbusiness/gotosocial/internal/processing/status" - "github.com/superseriousbusiness/gotosocial/internal/processing/stream" - "github.com/superseriousbusiness/gotosocial/internal/processing/tags" - "github.com/superseriousbusiness/gotosocial/internal/processing/timeline" - "github.com/superseriousbusiness/gotosocial/internal/processing/user" - "github.com/superseriousbusiness/gotosocial/internal/processing/workers" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/subscriptions" - "github.com/superseriousbusiness/gotosocial/internal/text" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/webpush" + "code.superseriousbusiness.org/gotosocial/internal/cleaner" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" + statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" + "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + mm "code.superseriousbusiness.org/gotosocial/internal/media" + "code.superseriousbusiness.org/gotosocial/internal/oauth" + "code.superseriousbusiness.org/gotosocial/internal/processing/account" + "code.superseriousbusiness.org/gotosocial/internal/processing/admin" + "code.superseriousbusiness.org/gotosocial/internal/processing/advancedmigrations" + "code.superseriousbusiness.org/gotosocial/internal/processing/application" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/processing/conversations" + "code.superseriousbusiness.org/gotosocial/internal/processing/fedi" + filterCommon "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" + filtersv1 "code.superseriousbusiness.org/gotosocial/internal/processing/filters/v1" + filtersv2 "code.superseriousbusiness.org/gotosocial/internal/processing/filters/v2" + "code.superseriousbusiness.org/gotosocial/internal/processing/interactionrequests" + "code.superseriousbusiness.org/gotosocial/internal/processing/list" + "code.superseriousbusiness.org/gotosocial/internal/processing/markers" + "code.superseriousbusiness.org/gotosocial/internal/processing/media" + "code.superseriousbusiness.org/gotosocial/internal/processing/polls" + "code.superseriousbusiness.org/gotosocial/internal/processing/push" + "code.superseriousbusiness.org/gotosocial/internal/processing/report" + "code.superseriousbusiness.org/gotosocial/internal/processing/search" + "code.superseriousbusiness.org/gotosocial/internal/processing/status" + "code.superseriousbusiness.org/gotosocial/internal/processing/stream" + "code.superseriousbusiness.org/gotosocial/internal/processing/tags" + "code.superseriousbusiness.org/gotosocial/internal/processing/timeline" + "code.superseriousbusiness.org/gotosocial/internal/processing/user" + "code.superseriousbusiness.org/gotosocial/internal/processing/workers" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/subscriptions" + "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/webpush" ) // Processor groups together processing functions and @@ -81,6 +85,7 @@ type Processor struct { account account.Processor admin admin.Processor advancedmigrations advancedmigrations.Processor + application application.Processor conversations conversations.Processor fedi fedi.Processor filtersv1 filtersv1.Processor @@ -113,6 +118,10 @@ func (p *Processor) AdvancedMigrations() *advancedmigrations.Processor { return &p.advancedmigrations } +func (p *Processor) Application() *application.Processor { + return &p.application +} + func (p *Processor) Conversations() *conversations.Processor { return &p.conversations } @@ -185,7 +194,8 @@ func (p *Processor) Workers() *workers.Processor { return &p.workers } -// NewProcessor returns a new Processor. +// NewProcessor returns +// a new Processor. func NewProcessor( cleaner *cleaner.Cleaner, subscriptions *subscriptions.Subscriptions, @@ -197,7 +207,9 @@ func NewProcessor( emailSender email.Sender, webPushSender webpush.Sender, visFilter *visibility.Filter, + muteFilter *mutes.Filter, intFilter *interaction.Filter, + statusFilter *statusfilter.Filter, ) *Processor { parseMentionFunc := GetParseMentionFunc(state, federator) processor := &Processor{ @@ -212,19 +224,21 @@ func NewProcessor( // // Start with sub processors that will // be required by the workers processor. - common := common.New(state, mediaManager, converter, federator, visFilter) - processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, parseMentionFunc) + common := common.New(state, mediaManager, converter, federator, visFilter, muteFilter, statusFilter) + processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, statusFilter, parseMentionFunc) processor.media = media.New(&common, state, converter, federator, mediaManager, federator.TransportController()) processor.stream = stream.New(state, oauthServer) + filterCommon := filterCommon.New(state, &processor.stream) // Instantiate the rest of the sub // processors + pin them to this struct. - processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, parseMentionFunc) + processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, statusFilter, parseMentionFunc) processor.admin = admin.New(&common, state, cleaner, subscriptions, federator, converter, mediaManager, federator.TransportController(), emailSender) - processor.conversations = conversations.New(state, converter, visFilter) + processor.application = application.New(state, converter) + processor.conversations = conversations.New(state, converter, visFilter, muteFilter, statusFilter) processor.fedi = fedi.New(state, &common, converter, federator, visFilter) - processor.filtersv1 = filtersv1.New(state, converter, &processor.stream) - processor.filtersv2 = filtersv2.New(state, converter, &processor.stream) + processor.filtersv1 = filtersv1.New(state, converter, filterCommon) + processor.filtersv2 = filtersv2.New(state, converter, filterCommon) processor.interactionRequests = interactionrequests.New(&common, state, converter) processor.list = list.New(state, converter) processor.markers = markers.New(state, converter) @@ -232,7 +246,7 @@ func NewProcessor( processor.push = push.New(state, converter) processor.report = report.New(state, converter) processor.tags = tags.New(state, converter) - processor.timeline = timeline.New(state, converter, visFilter) + processor.timeline = timeline.New(state, converter, visFilter, muteFilter, statusFilter) processor.search = search.New(state, federator, converter, visFilter) processor.status = status.New(state, &common, &processor.polls, &processor.interactionRequests, federator, converter, visFilter, intFilter, parseMentionFunc) processor.user = user.New(state, converter, oauthServer, emailSender) @@ -249,6 +263,8 @@ func NewProcessor( federator, converter, visFilter, + muteFilter, + statusFilter, emailSender, webPushSender, &processor.account, diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index 84ab9ef48..3c564b929 100644 --- a/internal/processing/processor_test.go +++ b/internal/processing/processor_test.go @@ -20,25 +20,28 @@ package processing_test import ( "context" + "code.superseriousbusiness.org/gotosocial/internal/admin" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/cleaner" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "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/oauth" + "code.superseriousbusiness.org/gotosocial/internal/processing" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/storage" + "code.superseriousbusiness.org/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/subscriptions" + "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/cleaner" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/internal/subscriptions" - "github.com/superseriousbusiness/gotosocial/internal/transport" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" ) type ProcessingStandardTestSuite struct { @@ -57,7 +60,6 @@ type ProcessingStandardTestSuite 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 @@ -66,7 +68,7 @@ type ProcessingStandardTestSuite struct { testStatuses map[string]*gtsmodel.Status testTags map[string]*gtsmodel.Tag testMentions map[string]*gtsmodel.Mention - testAutheds map[string]*oauth.Auth + testAutheds map[string]*apiutil.Auth testBlocks map[string]*gtsmodel.Block testActivities map[string]testrig.ActivityWithSignature testLists map[string]*gtsmodel.List @@ -76,7 +78,6 @@ type ProcessingStandardTestSuite struct { func (suite *ProcessingStandardTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() @@ -85,7 +86,7 @@ func (suite *ProcessingStandardTestSuite) SetupSuite() { suite.testStatuses = testrig.NewTestStatuses() suite.testTags = testrig.NewTestTags() suite.testMentions = testrig.NewTestMentions() - suite.testAutheds = map[string]*oauth.Auth{ + suite.testAutheds = map[string]*apiutil.Auth{ "local_account_1": { Application: suite.testApplications["local_account_1"], User: suite.testUsers["local_account_1"], @@ -110,12 +111,6 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { suite.state.Storage = suite.storage suite.typeconverter = typeutils.NewConverter(&suite.state) - testrig.StartTimelines( - &suite.state, - visibility.NewFilter(&suite.state), - suite.typeconverter, - ) - suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media") suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople() suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses() @@ -123,7 +118,7 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { suite.transportController = testrig.NewTestTransportController(&suite.state, suite.httpClient) suite.mediaManager = testrig.NewTestMediaManager(&suite.state) suite.federator = testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.oauthServer = testrig.NewTestOauthServer(&suite.state) suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) suite.processor = processing.NewProcessor( @@ -137,7 +132,9 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { suite.emailSender, testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), interaction.NewFilter(&suite.state), + status.NewFilter(&suite.state), ) testrig.StartWorkers(&suite.state, suite.processor.Workers()) diff --git a/internal/processing/push/create.go b/internal/processing/push/create.go index dc15ccf12..3beeab6cc 100644 --- a/internal/processing/push/create.go +++ b/internal/processing/push/create.go @@ -20,11 +20,11 @@ package push 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" - "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/id" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // CreateOrReplace creates a Web Push subscription for the given access token, diff --git a/internal/processing/push/delete.go b/internal/processing/push/delete.go index 6f5c61444..e5369f56a 100644 --- a/internal/processing/push/delete.go +++ b/internal/processing/push/delete.go @@ -20,7 +20,7 @@ package push import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" ) // Delete deletes the Web Push subscription for the given access token, if there is one. diff --git a/internal/processing/push/get.go b/internal/processing/push/get.go index 542f08862..df0e80dc3 100644 --- a/internal/processing/push/get.go +++ b/internal/processing/push/get.go @@ -21,9 +21,9 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" ) // Get returns the Web Push subscription for the given access token. diff --git a/internal/processing/push/push.go b/internal/processing/push/push.go index f46280386..24a2254ac 100644 --- a/internal/processing/push/push.go +++ b/internal/processing/push/push.go @@ -20,11 +20,11 @@ package push 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/state" - "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/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/push/update.go b/internal/processing/push/update.go index 94529455a..bbd9e3943 100644 --- a/internal/processing/push/update.go +++ b/internal/processing/push/update.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/typeutils" + 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/typeutils" ) // Update updates the Web Push subscription for the given access token. diff --git a/internal/processing/report/create.go b/internal/processing/report/create.go index dd31a8798..734670e17 100644 --- a/internal/processing/report/create.go +++ b/internal/processing/report/create.go @@ -22,14 +22,14 @@ 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/messages" - "github.com/superseriousbusiness/gotosocial/internal/uris" + "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/messages" + "code.superseriousbusiness.org/gotosocial/internal/uris" ) // Create creates one user report / flag, using the provided form parameters. diff --git a/internal/processing/report/get.go b/internal/processing/report/get.go index 2e3c1b2dc..136f09470 100644 --- a/internal/processing/report/get.go +++ b/internal/processing/report/get.go @@ -24,12 +24,12 @@ import ( "net/url" "strconv" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "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" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) // Get returns the user view of a moderation report, with the given id. diff --git a/internal/processing/report/report.go b/internal/processing/report/report.go index c871172bb..cdc2dc3e0 100644 --- a/internal/processing/report/report.go +++ b/internal/processing/report/report.go @@ -18,8 +18,8 @@ package report import ( - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/search/accounts.go b/internal/processing/search/accounts.go index 7201d0688..af84abc31 100644 --- a/internal/processing/search/accounts.go +++ b/internal/processing/search/accounts.go @@ -22,14 +22,14 @@ import ( "errors" "strings" - "codeberg.org/gruf/go-kv" - 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/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/util" + "codeberg.org/gruf/go-kv/v2" ) // Accounts does a partial search for accounts that diff --git a/internal/processing/search/get.go b/internal/processing/search/get.go index d1462cf53..64aefd23c 100644 --- a/internal/processing/search/get.go +++ b/internal/processing/search/get.go @@ -25,16 +25,16 @@ import ( "net/url" "strings" - "codeberg.org/gruf/go-kv" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "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/text" - "github.com/superseriousbusiness/gotosocial/internal/util" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/config" + "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/text" + "code.superseriousbusiness.org/gotosocial/internal/util" + "codeberg.org/gruf/go-kv/v2" ) const ( @@ -490,7 +490,7 @@ func (p *Processor) byURI( if includeAccounts(queryType) { // Check if URI points to an account. - foundAccount, err := p.accountByURI(ctx, requestingAccount, uri, resolve) + foundAccounts, err := p.accountsByURI(ctx, requestingAccount, uri, resolve) if err != nil { // Check for semi-expected error types. // On one of these, we can continue. @@ -508,7 +508,9 @@ func (p *Processor) byURI( } else { // Hit! Return early since it's extremely unlikely // a status and an account will have the same URL. - appendAccount(foundAccount) + for _, foundAccount := range foundAccounts { + appendAccount(foundAccount) + } return nil } } @@ -522,7 +524,7 @@ func (p *Processor) byURI( switch { case gtserror.IsUnretrievable(err), gtserror.IsWrongType(err), - gtserror.NotPermitted(err): + gtserror.IsNotPermitted(err): log.Debugf(ctx, "semi-expected error type looking up %s as status: %v", uri, err, @@ -544,35 +546,42 @@ func (p *Processor) byURI( return nil } -// accountByURI looks for one account with the given URI. +// accountsByURI looks for one account with the given URI/ID, +// then if nothing is found, multiple accounts with the given URL. +// // If resolve is false, it will only look in the database. // If resolve is true, it will try to resolve the account // from remote using the URI, if necessary. // // Will return either a hit, ErrNotRetrievable, ErrWrongType, // or a real error that the caller should handle. -func (p *Processor) accountByURI( +func (p *Processor) accountsByURI( ctx context.Context, requestingAccount *gtsmodel.Account, uri *url.URL, resolve bool, -) (*gtsmodel.Account, error) { +) ([]*gtsmodel.Account, error) { if resolve { // We're allowed to resolve, leave the // rest up to the dereferencer functions. + // + // Allow dereferencing by URL and not just URI; + // there are many cases where someone might + // paste a URL into the search bar. account, _, err := p.federator.GetAccountByURI( gtscontext.SetFastFail(ctx), requestingAccount.Username, uri, + true, ) - return account, err + return []*gtsmodel.Account{account}, err } // We're not allowed to resolve; search database only. uriStr := uri.String() // stringify uri just once - // Search by ActivityPub URI. + // Search for single acct by ActivityPub URI. account, err := p.state.DB.GetAccountByURI(ctx, uriStr) if err != nil && !errors.Is(err, db.ErrNoEntries) { err = gtserror.Newf("error checking database for account using URI %s: %w", uriStr, err) @@ -581,22 +590,22 @@ func (p *Processor) accountByURI( if account != nil { // We got a hit! No need to continue. - return account, nil + return []*gtsmodel.Account{account}, nil } - // No hit yet. Fallback to try by URL. - account, err = p.state.DB.GetAccountByURL(ctx, uriStr) + // No hit yet. Fallback to look for any accounts with URL. + accounts, err := p.state.DB.GetAccountsByURL(ctx, uriStr) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("error checking database for account using URL %s: %w", uriStr, err) + err = gtserror.Newf("error checking database for accounts using URL %s: %w", uriStr, err) return nil, err } - if account != nil { - // We got a hit! No need to continue. - return account, nil + if len(accounts) != 0 { + // We got hits! No need to continue. + return accounts, nil } - err = fmt.Errorf("account %s could not be retrieved locally and we cannot resolve", uriStr) + err = fmt.Errorf("account(s) %s could not be retrieved locally and we cannot resolve", uriStr) return nil, gtserror.SetUnretrievable(err) } diff --git a/internal/processing/search/lookup.go b/internal/processing/search/lookup.go index f5c131841..39fddad77 100644 --- a/internal/processing/search/lookup.go +++ b/internal/processing/search/lookup.go @@ -23,12 +23,12 @@ import ( "fmt" "strings" - "codeberg.org/gruf/go-kv" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/util" + 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" + "code.superseriousbusiness.org/gotosocial/internal/util" + "codeberg.org/gruf/go-kv/v2" ) // Lookup does a quick, non-resolving search for accounts that diff --git a/internal/processing/search/search.go b/internal/processing/search/search.go index 18008647c..0543b4db4 100644 --- a/internal/processing/search/search.go +++ b/internal/processing/search/search.go @@ -18,10 +18,10 @@ package search import ( - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go index e2947ea16..7d52204c3 100644 --- a/internal/processing/search/util.go +++ b/internal/processing/search/util.go @@ -20,11 +20,11 @@ package search import ( "context" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - 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" + 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" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // return true if given queryType should include accounts. @@ -114,7 +114,7 @@ func (p *Processor) packageStatuses( continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount) if err != nil { log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err) continue @@ -129,42 +129,22 @@ func (p *Processor) packageStatuses( // packageHashtags is a util function that just // converts the given hashtags into an apimodel // hashtag slice, or errors appropriately. -func (p *Processor) packageHashtags( - ctx context.Context, - requestingAccount *gtsmodel.Account, - tags []*gtsmodel.Tag, - v1 bool, -) ([]any, gtserror.WithCode) { - apiTags := make([]any, 0, len(tags)) - - var rangeF func(*gtsmodel.Tag) +func packageHashtags(tags []*gtsmodel.Tag, v1 bool) []any { + apiTags := make([]any, len(tags)) + if len(apiTags) != len(tags) { + panic(gtserror.New("bound check elimination")) + } if v1 { - // If API version 1, just provide slice of tag names. - rangeF = func(tag *gtsmodel.Tag) { - apiTags = append(apiTags, tag.Name) + for i, tag := range tags { + apiTags[i] = tag.Name } } else { - // If API not version 1, provide slice of full tags. - rangeF = func(tag *gtsmodel.Tag) { - apiTag, err := p.converter.TagToAPITag(ctx, tag, true, nil) - if err != nil { - log.Debugf( - ctx, - "skipping tag %s because it couldn't be converted to its api representation: %s", - tag.Name, err, - ) - return - } - - apiTags = append(apiTags, &apiTag) + for i, tag := range tags { + apiTag := typeutils.TagToAPITag(tag, true, nil) + apiTags[i] = apiTag } } - - for _, tag := range tags { - rangeF(tag) - } - - return apiTags, nil + return apiTags } // packageSearchResult wraps up the given accounts @@ -198,10 +178,7 @@ func (p *Processor) packageSearchResult( return nil, errWithCode } - apiTags, errWithCode := p.packageHashtags(ctx, requestingAccount, tags, v1) - if errWithCode != nil { - return nil, errWithCode - } + apiTags := packageHashtags(tags, v1) return &apimodel.SearchResult{ Accounts: apiAccounts, diff --git a/internal/processing/status/bookmark.go b/internal/processing/status/bookmark.go index ae61696a8..7996a9518 100644 --- a/internal/processing/status/bookmark.go +++ b/internal/processing/status/bookmark.go @@ -21,12 +21,12 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" + 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" ) // BookmarkCreate adds a bookmark for the requestingAccount, targeting the given status (no-op if bookmark already exists). diff --git a/internal/processing/status/bookmark_test.go b/internal/processing/status/bookmark_test.go index f5ea60fe6..ad441ca0c 100644 --- a/internal/processing/status/bookmark_test.go +++ b/internal/processing/status/bookmark_test.go @@ -18,7 +18,6 @@ package status_test import ( - "context" "testing" "github.com/stretchr/testify/suite" @@ -29,7 +28,7 @@ type StatusBookmarkTestSuite struct { } func (suite *StatusBookmarkTestSuite) TestBookmark() { - ctx := context.Background() + ctx := suite.T().Context() // bookmark a status bookmarkingAccount1 := suite.testAccounts["local_account_1"] @@ -43,7 +42,7 @@ func (suite *StatusBookmarkTestSuite) TestBookmark() { } func (suite *StatusBookmarkTestSuite) TestUnbookmark() { - ctx := context.Background() + ctx := suite.T().Context() // bookmark a status bookmarkingAccount1 := suite.testAccounts["local_account_1"] diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go index 0e09a8e7b..4a97706ab 100644 --- a/internal/processing/status/boost.go +++ b/internal/processing/status/boost.go @@ -22,13 +22,13 @@ 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/messages" - "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/messages" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // BoostCreate processes the boost/reblog of target @@ -96,7 +96,7 @@ func (p *Processor) BoostCreate( // Derive pendingApproval status. var pendingApproval bool switch { - case policyResult.WithApproval(): + case policyResult.ManualApproval(): // We're allowed to do // this pending approval. pendingApproval = true @@ -117,7 +117,7 @@ func (p *Processor) BoostCreate( boost.PreApproved = true } - case policyResult.Permitted(): + case policyResult.AutomaticApproval(): // We're permitted to do this // based on another kind of match. pendingApproval = false @@ -130,15 +130,6 @@ func (p *Processor) BoostCreate( return nil, gtserror.NewErrorInternalError(err) } - // Process side effects asynchronously. - p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ActivityAnnounce, - APActivityType: ap.ActivityCreate, - GTSModel: boost, - Origin: requester, - Target: target.Account, - }) - // If the boost target status replies to a status // that we own, and has a pending interaction // request, use the boost as an implicit accept. @@ -156,6 +147,16 @@ func (p *Processor) BoostCreate( target.PendingApproval = util.Ptr(false) } + // Queue remaining boost side effects + // (send out boost, update timeline, etc). + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityCreate, + GTSModel: boost, + Origin: requester, + Target: target.Account, + }) + return p.c.GetAPIStatus(ctx, requester, boost) } diff --git a/internal/processing/status/boost_test.go b/internal/processing/status/boost_test.go index 0249e2040..96971c05e 100644 --- a/internal/processing/status/boost_test.go +++ b/internal/processing/status/boost_test.go @@ -18,7 +18,6 @@ package status_test import ( - "context" "testing" "github.com/stretchr/testify/suite" @@ -29,7 +28,7 @@ type StatusBoostTestSuite struct { } func (suite *StatusBoostTestSuite) TestBoostOfBoost() { - ctx := context.Background() + ctx := suite.T().Context() // first boost a status, no big deal boostingAccount1 := suite.testAccounts["local_account_1"] diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go index 3f2b7b6cb..ca17ab80e 100644 --- a/internal/processing/status/common.go +++ b/internal/processing/status/common.go @@ -23,15 +23,16 @@ import ( "fmt" "time" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "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/text" - "github.com/superseriousbusiness/gotosocial/internal/util/xslices" - "github.com/superseriousbusiness/gotosocial/internal/validate" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "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/id" + "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util/xslices" + "code.superseriousbusiness.org/gotosocial/internal/validate" ) // validateStatusContent will validate the common @@ -106,11 +107,39 @@ type statusContent struct { Tags []*gtsmodel.Tag } +// Returns the final content type to use when creating or editing a status. +func processContentType( + requestContentType apimodel.StatusContentType, + existingStatus *gtsmodel.Status, + accountDefaultContentType string, +) gtsmodel.StatusContentType { + switch { + // Content type set in the request, return the new value. + case requestContentType != "": + return typeutils.APIContentTypeToContentType(requestContentType) + + // No content type in the request, return the existing + // status's current content type if we know of one. + case existingStatus != nil && existingStatus.ContentType != 0: + return existingStatus.ContentType + + // We aren't editing an existing status, or if we are + // it's an old one that doesn't have a saved content + // type. Use the user's default content type setting. + case accountDefaultContentType != "": + return typeutils.APIContentTypeToContentType(apimodel.StatusContentType(accountDefaultContentType)) + + // uhh.. Fall back to global default. + default: + return gtsmodel.StatusContentTypeDefault + } +} + func (p *Processor) processContent( ctx context.Context, author *gtsmodel.Account, statusID string, - contentType string, + contentType gtsmodel.StatusContentType, content string, contentWarning string, language string, @@ -142,25 +171,25 @@ func (p *Processor) processContent( ) } - // format is the currently set text formatting - // function, according to the provided content-type. - var format text.FormatFunc - - if contentType == "" { - // If content type wasn't specified, use - // the author's preferred content-type. - contentType = author.Settings.StatusContentType - } + var ( + // format is the currently set text formatting + // function, according to the provided content-type. + format text.FormatFunc + // formatCW is like format, but for content warning. + formatCW text.FormatFunc + ) switch contentType { // Format status according to text/plain. - case "", string(apimodel.StatusContentTypePlain): + case gtsmodel.StatusContentTypePlain: format = p.formatter.FromPlain + formatCW = p.formatter.FromPlainBasic // Format status according to text/markdown. - case string(apimodel.StatusContentTypeMarkdown): + case gtsmodel.StatusContentTypeMarkdown: format = p.formatter.FromMarkdown + formatCW = p.formatter.FromMarkdownBasic // Unknown. default: @@ -192,26 +221,23 @@ func (p *Processor) processContent( status.Emojis = contentRes.Emojis status.Tags = contentRes.Tags - // From here-on-out just use emoji-only - // plain-text formatting as the FormatFunc. - format = p.formatter.FromPlainEmojiOnly - // Sanitize content warning and format. - warning := text.SanitizeToPlaintext(contentWarning) - warningRes := formatInput(format, warning) + cwRes := formatInput(formatCW, contentWarning) // Gather results of the formatted. - status.ContentWarning = warningRes.HTML - status.Emojis = append(status.Emojis, warningRes.Emojis...) + status.ContentWarning = cwRes.HTML + status.Emojis = append(status.Emojis, cwRes.Emojis...) if poll != nil { // Pre-allocate slice of poll options of expected length. status.PollOptions = make([]string, len(poll.Options)) for i, option := range poll.Options { - // Sanitize each poll option and format. - option = text.SanitizeToPlaintext(option) - optionRes := formatInput(format, option) + // Strip each poll option and format. + // + // For polls just use basic formatting. + option = text.StripHTMLFromText(option) + optionRes := formatInput(p.formatter.FromPlainBasic, option) // Gather results of the formatted. status.PollOptions[i] = optionRes.HTML @@ -256,6 +282,7 @@ func (p *Processor) processMedia( authorID string, statusID string, mediaIDs []string, + scheduledStatusID *string, ) ( []*gtsmodel.MediaAttachment, gtserror.WithCode, @@ -289,7 +316,7 @@ func (p *Processor) processMedia( // Check media isn't already attached to another status. if (media.StatusID != "" && media.StatusID != statusID) || - (media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) { + (media.ScheduledStatusID != "" && (media.ScheduledStatusID != statusID && (scheduledStatusID == nil || media.ScheduledStatusID != *scheduledStatusID))) { text := fmt.Sprintf("media already attached to status: %s", id) return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } diff --git a/internal/processing/status/context.go b/internal/processing/status/context.go index 47806a64b..f153b2e3a 100644 --- a/internal/processing/status/context.go +++ b/internal/processing/status/context.go @@ -23,11 +23,9 @@ import ( "slices" "strings" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // internalThreadContext is like @@ -277,40 +275,8 @@ func (p *Processor) ContextGet( requester *gtsmodel.Account, targetStatusID string, ) (*apimodel.ThreadContext, gtserror.WithCode) { - // Retrieve filters as they affect - // what should be shown to requester. - filters, err := p.state.DB.GetFiltersForAccountID( - ctx, // Populate filters. - requester.ID, - ) - if err != nil { - err = gtserror.Newf( - "couldn't retrieve filters for account %s: %w", - requester.ID, err, - ) - return nil, gtserror.NewErrorInternalError(err) - } - - // Retrieve mutes as they affect - // what should be shown to requester. - mutes, err := p.state.DB.GetAccountMutes( - // No need to populate mutes, - // IDs are enough here. - gtscontext.SetBarebones(ctx), - requester.ID, - nil, // No paging - get all. - ) - if err != nil { - err = gtserror.Newf( - "couldn't retrieve mutes for account %s: %w", - requester.ID, err, - ) - return nil, gtserror.NewErrorInternalError(err) - } - // Retrieve the full thread context. - threadContext, errWithCode := p.contextGet( - ctx, + threadContext, errWithCode := p.contextGet(ctx, requester, targetStatusID, ) @@ -324,18 +290,14 @@ func (p *Processor) ContextGet( apiContext.Ancestors = p.c.GetVisibleAPIStatuses(ctx, requester, threadContext.ancestors, - statusfilter.FilterContextThread, - filters, - mutes, + gtsmodel.FilterContextThread, ) // Convert and filter the thread context descendants apiContext.Descendants = p.c.GetVisibleAPIStatuses(ctx, requester, threadContext.descendants, - statusfilter.FilterContextThread, - filters, - mutes, + gtsmodel.FilterContextThread, ) return &apiContext, nil @@ -352,8 +314,8 @@ func (p *Processor) WebContextGet( targetStatusID string, ) (*apimodel.WebThreadContext, gtserror.WithCode) { // Retrieve the internal thread context. - iCtx, errWithCode := p.contextGet( - ctx, + iCtx, errWithCode := p.contextGet(ctx, + nil, // No authed requester. targetStatusID, ) diff --git a/internal/processing/status/context_test.go b/internal/processing/status/context_test.go index aba58e776..5d7e40b71 100644 --- a/internal/processing/status/context_test.go +++ b/internal/processing/status/context_test.go @@ -20,9 +20,9 @@ package status_test import ( "testing" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/processing/status" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/processing/status" ) type topoSortTestSuite struct { diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 727c12084..57338708c 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -22,19 +22,19 @@ import ( "errors" "time" - "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/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/log" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "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/config" + "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/log" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // Create processes the given form to create a new status, returning the api model representation of that status if it's OK. @@ -44,10 +44,8 @@ func (p *Processor) Create( requester *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.StatusCreateRequest, -) ( - *apimodel.Status, - gtserror.WithCode, -) { + scheduledStatusID *string, +) (any, gtserror.WithCode) { // Validate incoming form status content. if errWithCode := validateStatusContent( form.Status, @@ -66,11 +64,14 @@ func (p *Processor) Create( // Generate new ID for status. statusID := id.NewULID() + // Process incoming content type. + contentType := processContentType(form.ContentType, nil, requester.Settings.StatusContentType) + // Process incoming status content fields. content, errWithCode := p.processContent(ctx, requester, statusID, - string(form.ContentType), + contentType, form.Status, form.SpoilerText, form.Language, @@ -80,16 +81,6 @@ func (p *Processor) Create( return nil, errWithCode } - // Process incoming status attachments. - media, errWithCode := p.processMedia(ctx, - requester.ID, - statusID, - form.MediaIDs, - ) - if errWithCode != nil { - return nil, errWithCode - } - // Generate necessary URIs for username, to build status URIs. accountURIs := uris.GenerateURIsForAccount(requester.Username) @@ -102,16 +93,27 @@ func (p *Processor) Create( // Handle backfilled/scheduled statuses. backfill := false - if form.ScheduledAt != nil { - scheduledAt := *form.ScheduledAt - - // Statuses may only be scheduled - // a minimum time into the future. - if now.Before(scheduledAt) { - const errText = "scheduled statuses are not yet supported" - return nil, gtserror.NewErrorNotImplemented(gtserror.New(errText), errText) + + switch { + case form.ScheduledAt == nil: + // No scheduling/backfilling + break + case form.ScheduledAt.Sub(now) >= 5*time.Minute: + // Statuses may only be scheduled a minimum time into the future. + scheduledStatus, errWithCode := p.processScheduledStatus(ctx, statusID, form, requester, application) + + if errWithCode != nil { + return nil, errWithCode } + return scheduledStatus, nil + + case now.Before(*form.ScheduledAt): + // Invalid future scheduled status + const errText = "scheduled_at must be at least 5 minutes in the future" + return nil, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText) + + default: // If not scheduled into the future, this status is being backfilled. if !config.GetInstanceAllowBackdatingStatuses() { const errText = "backdating statuses has been disabled on this instance" @@ -124,7 +126,7 @@ func (p *Processor) Create( // this would also cause issues with time.Time.IsZero() checks // that normally signify an absent optional time, // but this check covers both cases. - if scheduledAt.Compare(time.UnixMilli(0)) <= 0 { + if form.ScheduledAt.Compare(time.UnixMilli(0)) <= 0 { const errText = "statuses can't be backdated to or before the UNIX epoch" return nil, gtserror.NewErrorNotAcceptable(gtserror.New(errText), errText) } @@ -135,7 +137,7 @@ func (p *Processor) Create( backfill = true // Update to backfill date. - createdAt = scheduledAt + createdAt = *form.ScheduledAt // Generate an appropriate, (and unique!), ID for the creation time. if statusID, err = p.backfilledStatusID(ctx, createdAt); err != nil { @@ -143,6 +145,17 @@ func (p *Processor) Create( } } + // Process incoming status attachments. + media, errWithCode := p.processMedia(ctx, + requester.ID, + statusID, + form.MediaIDs, + scheduledStatusID, + ) + if errWithCode != nil { + return nil, errWithCode + } + status := >smodel.Status{ ID: statusID, URI: accountURIs.StatusesURI + "/" + statusID, @@ -163,6 +176,7 @@ func (p *Processor) Create( Content: content.Content, ContentWarning: content.ContentWarning, Text: form.Status, // raw + ContentType: contentType, // Set gathered mentions. MentionIDs: content.MentionIDs, @@ -185,6 +199,13 @@ func (p *Processor) Create( PendingApproval: util.Ptr(false), } + // Only store ContentWarningText if the parsed + // result is different from the given SpoilerText, + // otherwise skip to avoid duplicating db columns. + if content.ContentWarning != form.SpoilerText { + status.ContentWarningText = form.SpoilerText + } + if backfill { // Ensure backfilled status contains no // mentions to anyone other than author. @@ -206,14 +227,11 @@ func (p *Processor) Create( return nil, errWithCode } - if errWithCode := p.processThreadID(ctx, status); errWithCode != nil { + // Process the incoming created status visibility. + if errWithCode := processVisibility(form, requester.Settings.Privacy, status); errWithCode != nil { return nil, errWithCode } - if err := p.processVisibility(ctx, form, requester.Settings.Privacy, status); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - // Process policy AFTER visibility as it relies // on status.Visibility and form.Visibility being set. if errWithCode := processInteractionPolicy(form, requester.Settings, status); errWithCode != nil { @@ -267,21 +285,6 @@ func (p *Processor) Create( } } - var model any = status - if backfill { - // We specifically wrap backfilled statuses in - // a different type to signal to worker process. - model = >smodel.BackfillStatus{Status: status} - } - - // Send it to the client API worker for async side-effects. - p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ObjectNote, - APActivityType: ap.ActivityCreate, - GTSModel: model, - Origin: requester, - }) - // If the new status replies to a status that // replies to us, use our reply as an implicit // accept of any pending interaction. @@ -299,6 +302,22 @@ func (p *Processor) Create( status.InReplyTo.PendingApproval = util.Ptr(false) } + var model any = status + if backfill { + // We specifically wrap backfilled statuses in + // a different type to signal to worker process. + model = >smodel.BackfillStatus{Status: status} + } + + // Queue remaining create side effects + // (send out status, update timeline, etc). + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: model, + Origin: requester, + }) + return p.c.GetAPIStatus(ctx, requester, status) } @@ -396,7 +415,7 @@ func (p *Processor) processInReplyTo( // Derive pendingApproval status. var pendingApproval bool switch { - case policyResult.WithApproval(): + case policyResult.ManualApproval(): // We're allowed to do // this pending approval. pendingApproval = true @@ -417,7 +436,7 @@ func (p *Processor) processInReplyTo( status.PreApproved = true } - case policyResult.Permitted(): + case policyResult.AutomaticApproval(): // We're permitted to do this // based on another kind of match. pendingApproval = false @@ -434,68 +453,36 @@ func (p *Processor) processInReplyTo( return nil } -func (p *Processor) processThreadID(ctx context.Context, status *gtsmodel.Status) gtserror.WithCode { - // Status takes the thread ID of - // whatever it replies to, if set. - // - // Might not be set if status is local - // and replies to a remote status that - // doesn't have a thread ID yet. - // - // If so, we can just thread from this - // status onwards instead, since this - // is where the relevant part of the - // thread starts, from the perspective - // of our instance at least. - if status.InReplyTo != nil && - status.InReplyTo.ThreadID != "" { - // Just inherit threadID from parent. - status.ThreadID = status.InReplyTo.ThreadID - return nil - } - - // Mark new thread (or threaded - // subsection) starting from here. - threadID := id.NewULID() - if err := p.state.DB.PutThread( - ctx, - >smodel.Thread{ - ID: threadID, - }, - ); err != nil { - err := gtserror.Newf("error inserting new thread in db: %w", err) - return gtserror.NewErrorInternalError(err) - } - - // Future replies to this status - // (if any) will inherit this thread ID. - status.ThreadID = threadID - - return nil -} - -func (p *Processor) processVisibility( - ctx context.Context, +func processVisibility( form *apimodel.StatusCreateRequest, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status, -) error { +) gtserror.WithCode { switch { // Visibility set on form, use that. case form.Visibility != "": - status.Visibility = typeutils.APIVisToVis(form.Visibility) + visibility := typeutils.APIVisToVis(form.Visibility) + + if visibility == 0 { + const errText = "invalid visibility" + err := gtserror.New(errText) + errWithCode := gtserror.NewErrorUnprocessableEntity(err, err.Error()) + return errWithCode + } + + status.Visibility = visibility // Fall back to account default, set // this back on the form for later use. case accountDefaultVis != 0: status.Visibility = accountDefaultVis - form.Visibility = p.converter.VisToAPIVis(ctx, accountDefaultVis) + form.Visibility = typeutils.VisToAPIVis(accountDefaultVis) // What? Fall back to global default, set // this back on the form for later use. default: status.Visibility = gtsmodel.VisibilityDefault - form.Visibility = p.converter.VisToAPIVis(ctx, gtsmodel.VisibilityDefault) + form.Visibility = typeutils.VisToAPIVis(gtsmodel.VisibilityDefault) } // Set federated according to "local_only" field, @@ -569,3 +556,103 @@ func processInteractionPolicy( // setting it explicitly to save space. return nil } + +func (p *Processor) processScheduledStatus( + ctx context.Context, + statusID string, + form *apimodel.StatusCreateRequest, + requester *gtsmodel.Account, + application *gtsmodel.Application, +) (*apimodel.ScheduledStatus, gtserror.WithCode) { + // Validate scheduled status against server configuration + // (max scheduled statuses limit). + if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, form.ScheduledAt, nil); errWithCode != nil { + return nil, errWithCode + } + + media, errWithCode := p.processMedia(ctx, + requester.ID, + statusID, + form.MediaIDs, + nil, + ) + if errWithCode != nil { + return nil, errWithCode + } + status := >smodel.ScheduledStatus{ + ID: statusID, + Account: requester, + AccountID: requester.ID, + Application: application, + ApplicationID: application.ID, + ScheduledAt: *form.ScheduledAt, + Text: form.Status, + MediaIDs: form.MediaIDs, + MediaAttachments: media, + Sensitive: &form.Sensitive, + SpoilerText: form.SpoilerText, + InReplyToID: form.InReplyToID, + Language: form.Language, + LocalOnly: form.LocalOnly, + ContentType: string(form.ContentType), + } + + if form.Poll != nil { + status.Poll = gtsmodel.ScheduledStatusPoll{ + Options: form.Poll.Options, + ExpiresIn: form.Poll.ExpiresIn, + Multiple: &form.Poll.Multiple, + HideTotals: &form.Poll.HideTotals, + } + } + + accountDefaultVisibility := requester.Settings.Privacy + + switch { + case form.Visibility != "": + status.Visibility = typeutils.APIVisToVis(form.Visibility) + + case accountDefaultVisibility != 0: + status.Visibility = accountDefaultVisibility + form.Visibility = typeutils.VisToAPIVis(accountDefaultVisibility) + + default: + status.Visibility = gtsmodel.VisibilityDefault + form.Visibility = typeutils.VisToAPIVis(gtsmodel.VisibilityDefault) + } + + if form.InteractionPolicy != nil { + interactionPolicy, err := typeutils.APIInteractionPolicyToInteractionPolicy(form.InteractionPolicy, form.Visibility) + + if err != nil { + err := gtserror.Newf("error converting interaction policy: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + status.InteractionPolicy = interactionPolicy + } + + // Insert this newly prepared status into the database. + if err := p.state.DB.PutScheduledStatus(ctx, status); err != nil { + err := gtserror.Newf("error inserting status in db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Schedule the newly inserted status for publishing. + if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil { + err := gtserror.Newf("error scheduling status publish: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, + status, + ) + + if err != nil { + err := gtserror.Newf("error converting: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiScheduledStatus, nil +} diff --git a/internal/processing/status/create_test.go b/internal/processing/status/create_test.go index 16cefcebf..646a26978 100644 --- a/internal/processing/status/create_test.go +++ b/internal/processing/status/create_test.go @@ -18,15 +18,15 @@ package status_test import ( - "context" + "net/http" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/util" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/util" ) type StatusCreateTestSuite struct { @@ -34,7 +34,7 @@ type StatusCreateTestSuite struct { } func (suite *StatusCreateTestSuite) TestProcessContentWarningWithQuotationMarks() { - ctx := context.Background() + ctx := suite.T().Context() creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] @@ -53,34 +53,8 @@ func (suite *StatusCreateTestSuite) TestProcessContentWarningWithQuotationMarks( ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) - suite.NoError(err) - suite.NotNil(apiStatus) - - suite.Equal("\"test\"", apiStatus.SpoilerText) -} - -func (suite *StatusCreateTestSuite) TestProcessContentWarningWithHTMLEscapedQuotationMarks() { - ctx := context.Background() - - creatingAccount := suite.testAccounts["local_account_1"] - creatingApplication := suite.testApplications["application_1"] - - statusCreateForm := &apimodel.StatusCreateRequest{ - Status: "poopoo peepee", - MediaIDs: []string{}, - Poll: nil, - InReplyToID: "", - Sensitive: false, - SpoilerText: ""test"", // the html-escaped quotation marks should appear as normal quotation marks in the finished text - Visibility: apimodel.VisibilityPublic, - LocalOnly: util.Ptr(false), - ScheduledAt: nil, - Language: "en", - ContentType: apimodel.StatusContentTypePlain, - } - - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -88,7 +62,7 @@ func (suite *StatusCreateTestSuite) TestProcessContentWarningWithHTMLEscapedQuot } func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithUnderscoreEmoji() { - ctx := context.Background() + ctx := suite.T().Context() // update the shortcode of the rainbow emoji to surround it in underscores if err := suite.db.UpdateWhere(ctx, []db.Where{{Key: "shortcode", Value: "rainbow"}}, "shortcode", "_rainbow_", >smodel.Emoji{}); err != nil { @@ -111,7 +85,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithUnderscoreEmoji ContentType: apimodel.StatusContentTypeMarkdown, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -120,7 +95,7 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithUnderscoreEmoji } func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithSpoilerTextEmoji() { - ctx := context.Background() + ctx := suite.T().Context() creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] @@ -138,7 +113,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithSpoilerTextEmoj ContentType: apimodel.StatusContentTypeMarkdown, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -148,7 +124,7 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithSpoilerTextEmoj } func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() { - ctx := context.Background() + ctx := suite.T().Context() config.SetMediaDescriptionMinChars(100) @@ -169,13 +145,13 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() { ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) suite.EqualError(err, "media description less than min chars (100)") suite.Nil(apiStatus) } func (suite *StatusCreateTestSuite) TestProcessLanguageWithScriptPart() { - ctx := context.Background() + ctx := suite.T().Context() creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] @@ -194,7 +170,8 @@ func (suite *StatusCreateTestSuite) TestProcessLanguageWithScriptPart() { ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -202,7 +179,7 @@ func (suite *StatusCreateTestSuite) TestProcessLanguageWithScriptPart() { } func (suite *StatusCreateTestSuite) TestProcessReplyToUnthreadedRemoteStatus() { - ctx := context.Background() + ctx := suite.T().Context() creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] @@ -224,7 +201,8 @@ func (suite *StatusCreateTestSuite) TestProcessReplyToUnthreadedRemoteStatus() { ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -238,6 +216,62 @@ func (suite *StatusCreateTestSuite) TestProcessReplyToUnthreadedRemoteStatus() { suite.NotEmpty(dbStatus.ThreadID) } +func (suite *StatusCreateTestSuite) TestProcessNoContentTypeUsesDefault() { + ctx := suite.T().Context() + creatingAccount := suite.testAccounts["local_account_1"] + creatingApplication := suite.testApplications["application_1"] + + statusCreateForm := &apimodel.StatusCreateRequest{ + Status: "poopoo peepee", + SpoilerText: "", + MediaIDs: []string{}, + Poll: nil, + InReplyToID: "", + Sensitive: false, + Visibility: apimodel.VisibilityPublic, + LocalOnly: util.Ptr(false), + ScheduledAt: nil, + Language: "en", + ContentType: "", + } + + apiStatusAny, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) + suite.NoError(errWithCode) + suite.NotNil(apiStatus) + + suite.Equal("<p>poopoo peepee</p>", apiStatus.Content) + + // the test accounts don't have settings, so we're comparing to + // the global default value instead of the requester's default + suite.Equal(apimodel.StatusContentTypeDefault, apiStatus.ContentType) +} + +func (suite *StatusCreateTestSuite) TestProcessInvalidVisibility() { + ctx := suite.T().Context() + creatingAccount := suite.testAccounts["local_account_1"] + creatingApplication := suite.testApplications["application_1"] + + statusCreateForm := &apimodel.StatusCreateRequest{ + Status: "my tests content is boring", + SpoilerText: "", + MediaIDs: []string{}, + Poll: nil, + InReplyToID: "", + Sensitive: false, + Visibility: "local", + LocalOnly: util.Ptr(false), + ScheduledAt: nil, + Language: "en", + ContentType: apimodel.StatusContentTypePlain, + } + + apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + suite.Nil(apiStatus) + suite.Equal(http.StatusUnprocessableEntity, errWithCode.Code()) + suite.Equal("Unprocessable Entity: processVisibility: invalid visibility", errWithCode.Safe()) +} + func TestStatusCreateTestSuite(t *testing.T) { suite.Run(t, new(StatusCreateTestSuite)) } diff --git a/internal/processing/status/delete.go b/internal/processing/status/delete.go index 700909f44..4a3c32f4a 100644 --- a/internal/processing/status/delete.go +++ b/internal/processing/status/delete.go @@ -22,11 +22,11 @@ import ( "errors" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/ap" + 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/messages" ) // Delete processes the delete of a given status, returning the deleted status if the delete goes through. @@ -50,6 +50,13 @@ func (p *Processor) Delete(ctx context.Context, requestingAccount *gtsmodel.Acco return nil, errWithCode } + // Replace content warning with raw + // version if it's available, to make + // delete + redraft work nicer. + if targetStatus.ContentWarningText != "" { + apiStatus.SpoilerText = targetStatus.ContentWarningText + } + // Process delete side effects. p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ APObjectType: ap.ObjectNote, diff --git a/internal/processing/status/edit.go b/internal/processing/status/edit.go index 95665074e..2a2321604 100644 --- a/internal/processing/status/edit.go +++ b/internal/processing/status/edit.go @@ -24,16 +24,16 @@ import ( "slices" "time" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "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/util/xslices" + "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/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/util/xslices" ) // Edit ... @@ -84,11 +84,18 @@ func (p *Processor) Edit( return nil, errWithCode } + // Process incoming content type. + contentType := processContentType( + form.ContentType, + status, + requester.Settings.StatusContentType, + ) + // Process incoming status edit content fields. content, errWithCode := p.processContent(ctx, requester, statusID, - string(form.ContentType), + contentType, form.Status, form.SpoilerText, form.Language, @@ -103,6 +110,7 @@ func (p *Processor) Edit( requester.ID, statusID, form.MediaIDs, + nil, ) if errWithCode != nil { return nil, errWithCode @@ -256,6 +264,7 @@ func (p *Processor) Edit( edit.Content = status.Content edit.ContentWarning = status.ContentWarning edit.Text = status.Text + edit.ContentType = status.ContentType edit.Language = status.Language edit.Sensitive = status.Sensitive edit.StatusID = status.ID @@ -297,13 +306,21 @@ func (p *Processor) Edit( // update the other necessary status fields. status.Content = content.Content status.ContentWarning = content.ContentWarning - status.Text = form.Status + status.Text = form.Status // raw + status.ContentType = contentType status.Language = content.Language status.Sensitive = &form.Sensitive status.AttachmentIDs = form.MediaIDs status.Attachments = media status.EditedAt = now + // Only store ContentWarningText if the parsed + // result is different from the given SpoilerText, + // otherwise skip to avoid duplicating db columns. + if content.ContentWarning != form.SpoilerText { + status.ContentWarningText = form.SpoilerText + } + if poll != nil { // Set relevent fields for latest with poll. status.ActivityStreamsType = ap.ActivityQuestion @@ -358,13 +375,13 @@ func (p *Processor) HistoryGet(ctx context.Context, requester *gtsmodel.Account, return nil, gtserror.NewErrorInternalError(err) } - edits, err := p.converter.StatusToAPIEdits(ctx, target) + editHistory, err := p.converter.StatusToEditHistory(ctx, target) if err != nil { err := gtserror.Newf("error converting status edits: %w", err) return nil, gtserror.NewErrorInternalError(err) } - return edits, nil + return editHistory, nil } func (p *Processor) processMediaEdits( diff --git a/internal/processing/status/edit_test.go b/internal/processing/status/edit_test.go index 36ebf2765..87fb73f67 100644 --- a/internal/processing/status/edit_test.go +++ b/internal/processing/status/edit_test.go @@ -23,11 +23,12 @@ import ( "testing" "time" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/util/xslices" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/internal/util/xslices" ) type StatusEditTestSuite struct { @@ -36,7 +37,7 @@ type StatusEditTestSuite struct { func (suite *StatusEditTestSuite) TestSimpleEdit() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. @@ -90,6 +91,142 @@ func (suite *StatusEditTestSuite) TestSimpleEdit() { previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] suite.Equal(status.Content, previousEdit.Content) suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentType, previousEdit.ContentType) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt) +} + +func (suite *StatusEditTestSuite) TestEditChangeContentType() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(suite.T().Context()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get requester's existing plain text status to perform an edit on. + status := suite.testStatuses["local_account_1_status_6"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare edit with a Markdown body. + form := &apimodel.StatusEditRequest{ + Status: "ooh the status is *fancy* now!", + ContentType: apimodel.StatusContentTypeMarkdown, + SpoilerText: "shhhhh", + Sensitive: true, + Language: "fr", // hoh hoh hoh + MediaIDs: nil, + MediaAttributes: nil, + Poll: nil, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NotNil(apiStatus) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.Equal(form.ContentType, apiStatus.ContentType) + suite.Equal(form.SpoilerText, apiStatus.SpoilerText) + suite.Equal(form.Sensitive, apiStatus.Sensitive) + suite.Equal(form.Language, *apiStatus.Language) + suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data. + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(typeutils.APIContentTypeToContentType(form.ContentType), latestStatus.ContentType) + suite.Equal(form.SpoilerText, latestStatus.ContentWarning) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt()) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentType, previousEdit.ContentType) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt) +} + +func (suite *StatusEditTestSuite) TestEditOnStatusWithNoContentType() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(suite.T().Context()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get requester's existing status, which has no + // stored content type, to perform an edit on. + status := suite.testStatuses["local_account_1_status_2"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare edit without setting a new content type. + form := &apimodel.StatusEditRequest{ + Status: "how will this text be parsed? it is a mystery", + SpoilerText: "shhhhh", + Sensitive: true, + Language: "fr", // hoh hoh hoh + MediaIDs: nil, + MediaAttributes: nil, + Poll: nil, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NotNil(apiStatus) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt) + + // Check response against requester's default content type setting + // (the test accounts don't actually have settings on them, so + // instead we check that the global default content type is used) + suite.Equal(apimodel.StatusContentTypeDefault, apiStatus.ContentType) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt()) + + // Check latest status against requester's default content + // type (again, actually just checking for the global default) + suite.Equal(gtsmodel.StatusContentTypeDefault, latestStatus.ContentType) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentType, previousEdit.ContentType) suite.Equal(status.ContentWarning, previousEdit.ContentWarning) suite.Equal(*status.Sensitive, *previousEdit.Sensitive) suite.Equal(status.Language, previousEdit.Language) @@ -98,7 +235,7 @@ func (suite *StatusEditTestSuite) TestSimpleEdit() { func (suite *StatusEditTestSuite) TestEditAddPoll() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. @@ -176,7 +313,7 @@ func (suite *StatusEditTestSuite) TestEditAddPoll() { func (suite *StatusEditTestSuite) TestEditAddPollNoExpiry() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. @@ -254,7 +391,7 @@ func (suite *StatusEditTestSuite) TestEditAddPollNoExpiry() { func (suite *StatusEditTestSuite) TestEditMediaDescription() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. @@ -350,7 +487,7 @@ func (suite *StatusEditTestSuite) TestEditMediaDescription() { func (suite *StatusEditTestSuite) TestEditAddMedia() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. @@ -425,7 +562,7 @@ func (suite *StatusEditTestSuite) TestEditAddMedia() { func (suite *StatusEditTestSuite) TestEditRemoveMedia() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. @@ -491,7 +628,7 @@ func (suite *StatusEditTestSuite) TestEditRemoveMedia() { func (suite *StatusEditTestSuite) TestEditOthersStatus1() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. @@ -516,7 +653,7 @@ func (suite *StatusEditTestSuite) TestEditOthersStatus1() { func (suite *StatusEditTestSuite) TestEditOthersStatus2() { // Create cancellable context to use for test. - ctx, cncl := context.WithCancel(context.Background()) + ctx, cncl := context.WithCancel(suite.T().Context()) defer cncl() // Get a local account to use as test requester. diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go index defc59af0..983f12b6a 100644 --- a/internal/processing/status/fave.go +++ b/internal/processing/status/fave.go @@ -22,16 +22,16 @@ 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/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/uris" + "code.superseriousbusiness.org/gotosocial/internal/util" ) func (p *Processor) getFaveableStatus( @@ -65,7 +65,7 @@ func (p *Processor) getFaveableStatus( fave, err := p.state.DB.GetStatusFave(ctx, requester.ID, target.ID) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = fmt.Errorf("getFaveTarget: error checking existing fave: %w", err) + err = gtserror.Newf("error checking existing fave: %w", err) return nil, nil, gtserror.NewErrorInternalError(err) } @@ -112,7 +112,7 @@ func (p *Processor) FaveCreate( ) switch { - case policyResult.WithApproval(): + case policyResult.ManualApproval(): // We're allowed to do // this pending approval. pendingApproval = true @@ -133,7 +133,7 @@ func (p *Processor) FaveCreate( preApproved = true } - case policyResult.Permitted(): + case policyResult.AutomaticApproval(): // We're permitted to do this // based on another kind of match. pendingApproval = false @@ -160,15 +160,6 @@ func (p *Processor) FaveCreate( return nil, gtserror.NewErrorInternalError(err) } - // Process new status fave side effects. - p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ - APObjectType: ap.ActivityLike, - APActivityType: ap.ActivityCreate, - GTSModel: gtsFave, - Origin: requester, - Target: status.Account, - }) - // If the fave target status replies to a status // that we own, and has a pending interaction // request, use the fave as an implicit accept. @@ -186,6 +177,16 @@ func (p *Processor) FaveCreate( status.PendingApproval = util.Ptr(false) } + // Queue remaining fave side effects + // (send out fave, update timeline, etc). + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityCreate, + GTSModel: gtsFave, + Origin: requester, + Target: status.Account, + }) + return p.c.GetAPIStatus(ctx, requester, status) } diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 812f01683..edcaa07fa 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -21,9 +21,10 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Get gets the given status, taking account of privacy settings and blocks etc. @@ -52,9 +53,21 @@ func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account, "target status not found", ) } + + // Try to use unparsed content + // warning text if available, + // fall back to parsed cw html. + var spoilerText string + if status.ContentWarningText != "" { + spoilerText = status.ContentWarningText + } else { + spoilerText = status.ContentWarning + } + return &apimodel.StatusSource{ ID: status.ID, Text: status.Text, - SpoilerText: status.ContentWarning, + SpoilerText: spoilerText, + ContentType: typeutils.ContentTypeToAPIContentType(status.ContentType), }, nil } diff --git a/internal/processing/status/mute.go b/internal/processing/status/mute.go index 8888b59d4..638b7cfb2 100644 --- a/internal/processing/status/mute.go +++ b/internal/processing/status/mute.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/id" + 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" ) // getMuteableStatus fetches targetStatusID status and diff --git a/internal/processing/status/pin.go b/internal/processing/status/pin.go index 3f8435e52..dad11d6b2 100644 --- a/internal/processing/status/pin.go +++ b/internal/processing/status/pin.go @@ -23,9 +23,9 @@ import ( "fmt" "time" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) const allowedPinnedCount = 10 diff --git a/internal/processing/status/scheduledstatus.go b/internal/processing/status/scheduledstatus.go new file mode 100644 index 000000000..d0ec6898c --- /dev/null +++ b/internal/processing/status/scheduledstatus.go @@ -0,0 +1,357 @@ +// 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 status + +import ( + "context" + "errors" + "time" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/config" + "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/paging" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" +) + +// ScheduledStatusesGetPage returns a page of scheduled statuses authored +// by the requester. +func (p *Processor) ScheduledStatusesGetPage( + ctx context.Context, + requester *gtsmodel.Account, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + scheduledStatuses, err := p.state.DB.GetScheduledStatusesForAcct( + ctx, + requester.ID, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled statuses: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(scheduledStatuses) + if count == 0 { + return paging.EmptyResponse(), nil + } + + var ( + // Get the lowest and highest + // ID values, used for paging. + lo = scheduledStatuses[count-1].ID + hi = scheduledStatuses[0].ID + + // Best-guess items length. + items = make([]interface{}, 0, count) + ) + + for _, scheduledStatus := range scheduledStatuses { + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, scheduledStatus, + ) + if err != nil { + log.Errorf(ctx, "error converting scheduled status to api scheduled status: %v", err) + continue + } + + // Append scheduledStatus to return items. + items = append(items, apiScheduledStatus) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/scheduled_statuses", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} + +// ScheduledStatusesGetOne returns one scheduled +// status with the given ID. +func (p *Processor) ScheduledStatusesGetOne( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) (*apimodel.ScheduledStatus, gtserror.WithCode) { + scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if scheduledStatus == nil { + err := gtserror.New("scheduled status not found") + return nil, gtserror.NewErrorNotFound(err) + } + + if scheduledStatus.AccountID != requester.ID { + err := gtserror.Newf( + "scheduled status %s is not authored by account %s", + scheduledStatus.ID, requester.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, scheduledStatus, + ) + if err != nil { + err := gtserror.Newf("error converting scheduled status to api scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiScheduledStatus, nil +} + +func (p *Processor) ScheduledStatusesScheduleAll(ctx context.Context) error { + // Fetch all pending statuses from the database (barebones models are enough). + statuses, err := p.state.DB.GetAllScheduledStatuses(gtscontext.SetBarebones(ctx)) + if err != nil { + return gtserror.Newf("error getting scheduled statuses from db: %w", err) + } + + var errs gtserror.MultiError + + for _, status := range statuses { + // Schedule publication of each of the statuses and catch any errors. + if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil { + errs.Append(err) + } + } + + return errs.Combine() +} + +func (p *Processor) ScheduledStatusesSchedulePublication(ctx context.Context, statusID string) gtserror.WithCode { + status, err := p.state.DB.GetScheduledStatusByID(ctx, statusID) + + if err != nil { + return gtserror.NewErrorNotFound(gtserror.Newf("failed to get scheduled status %s", statusID)) + } + + // Add the given status to the scheduler. + ok := p.state.Workers.Scheduler.AddOnce( + status.ID, + status.ScheduledAt, + p.onPublish(status.ID), + ) + + if !ok { + // Failed to add the status to the scheduler, either it was + // starting / stopping or there already exists a task for status. + return gtserror.NewErrorInternalError(gtserror.Newf("failed adding status %s to scheduler", status.ID)) + } + + atStr := status.ScheduledAt.Local().Format("Jan _2 2006 15:04:05") + log.Infof(ctx, "scheduled status publication for %s at '%s'", status.ID, atStr) + return nil +} + +// onPublish returns a callback function to be used by the scheduler on the scheduled date. +func (p *Processor) onPublish(statusID string) func(context.Context, time.Time) { + return func(ctx context.Context, now time.Time) { + // Get the latest version of status from database. + status, err := p.state.DB.GetScheduledStatusByID(ctx, statusID) + if err != nil { + log.Errorf(ctx, "error getting status %s from db: %v", statusID, err) + return + } + + request := &apimodel.StatusCreateRequest{ + Status: status.Text, + MediaIDs: status.MediaIDs, + Poll: nil, + InReplyToID: status.InReplyToID, + Sensitive: *status.Sensitive, + SpoilerText: status.SpoilerText, + Visibility: typeutils.VisToAPIVis(status.Visibility), + Language: status.Language, + } + + if status.Poll.Options != nil && len(status.Poll.Options) > 1 { + request.Poll = &apimodel.PollRequest{ + Options: status.Poll.Options, + ExpiresIn: status.Poll.ExpiresIn, + Multiple: *status.Poll.Multiple, + HideTotals: *status.Poll.HideTotals, + } + } + + _, errWithCode := p.Create(ctx, status.Account, status.Application, request, &statusID) + + if errWithCode != nil { + log.Errorf(ctx, "could not publish scheduled status: %v", errWithCode.Unwrap()) + return + } + + err = p.state.DB.DeleteScheduledStatusByID(ctx, statusID) + + if err != nil { + log.Error(ctx, err) + } + } +} + +// Update scheduled status schedule data +func (p *Processor) ScheduledStatusesUpdate( + ctx context.Context, + requester *gtsmodel.Account, + id string, + scheduledAt *time.Time, +) (*apimodel.ScheduledStatus, gtserror.WithCode) { + scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if scheduledStatus == nil { + err := gtserror.New("scheduled status not found") + return nil, gtserror.NewErrorNotFound(err) + } + + if scheduledStatus.AccountID != requester.ID { + err := gtserror.Newf( + "scheduled status %s is not authored by account %s", + scheduledStatus.ID, requester.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, scheduledAt, &scheduledStatus.ScheduledAt); errWithCode != nil { + return nil, errWithCode + } + + scheduledStatus.ScheduledAt = *scheduledAt + err = p.state.DB.UpdateScheduledStatusScheduledDate(ctx, scheduledStatus, scheduledAt) + + if err != nil { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + ok := p.state.Workers.Scheduler.Cancel(id) + + if !ok { + err := gtserror.Newf("failed to cancel scheduled status") + return nil, gtserror.NewErrorInternalError(err) + } + + err = p.ScheduledStatusesSchedulePublication(ctx, id) + + if err != nil { + err := gtserror.Newf("error scheduling status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, scheduledStatus, + ) + if err != nil { + err := gtserror.Newf("error converting scheduled status to api req: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiScheduledStatus, nil +} + +// Cancel a scheduled status +func (p *Processor) ScheduledStatusesDelete(ctx context.Context, requester *gtsmodel.Account, id string) gtserror.WithCode { + scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if scheduledStatus == nil { + err := gtserror.New("scheduled status not found") + return gtserror.NewErrorNotFound(err) + } + + if scheduledStatus.AccountID != requester.ID { + err := gtserror.Newf( + "scheduled status %s is not authored by account %s", + scheduledStatus.ID, requester.ID, + ) + return gtserror.NewErrorNotFound(err) + } + + ok := p.state.Workers.Scheduler.Cancel(id) + + if !ok { + err := gtserror.Newf("failed to cancel scheduled status") + return gtserror.NewErrorInternalError(err) + } + + err = p.state.DB.DeleteScheduledStatusByID(ctx, id) + + if err != nil { + err := gtserror.Newf("db error deleting scheduled status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + return nil +} + +func (p *Processor) validateScheduledStatusLimits(ctx context.Context, acctID string, scheduledAt *time.Time, prevScheduledAt *time.Time) gtserror.WithCode { + // Skip check when the scheduled status already exists and the day stays the same + if prevScheduledAt != nil { + y1, m1, d1 := scheduledAt.Date() + y2, m2, d2 := prevScheduledAt.Date() + + if y1 == y2 && m1 == m2 && d1 == d2 { + return nil + } + } + + scheduledDaily, err := p.state.DB.GetScheduledStatusesCountForAcct(ctx, acctID, scheduledAt) + + if err != nil { + err := gtserror.Newf("error getting scheduled statuses count for day: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if max := config.GetScheduledStatusesMaxDaily(); scheduledDaily >= max { + err := gtserror.Newf("scheduled statuses count for day is at the limit (%d)", max) + return gtserror.NewErrorUnprocessableEntity(err) + } + + // Skip total check when editing an existing scheduled status + if prevScheduledAt != nil { + return nil + } + + scheduledTotal, err := p.state.DB.GetScheduledStatusesCountForAcct(ctx, acctID, nil) + + if err != nil { + err := gtserror.Newf("error getting total scheduled statuses count: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if max := config.GetScheduledStatusesMaxTotal(); scheduledTotal >= max { + err := gtserror.Newf("total scheduled statuses count is at the limit (%d)", max) + return gtserror.NewErrorUnprocessableEntity(err) + } + + return nil +} diff --git a/internal/processing/status/scheduledstatus_test.go b/internal/processing/status/scheduledstatus_test.go new file mode 100644 index 000000000..d53b1ec70 --- /dev/null +++ b/internal/processing/status/scheduledstatus_test.go @@ -0,0 +1,69 @@ +// 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 status_test + +import ( + "context" + "testing" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/testrig" + "github.com/stretchr/testify/suite" +) + +type ScheduledStatusTestSuite struct { + StatusStandardTestSuite +} + +func (suite *ScheduledStatusTestSuite) TestUpdate() { + ctx := suite.T().Context() + + account1 := suite.testAccounts["local_account_1"] + scheduledStatus1 := suite.testScheduledStatuses["scheduled_status_1"] + newScheduledAt := testrig.TimeMustParse("2080-07-02T21:37:00+02:00") + + suite.state.Workers.Scheduler.AddOnce(scheduledStatus1.ID, scheduledStatus1.ScheduledAt, func(ctx context.Context, t time.Time) {}) + + // update scheduled status publication date + scheduledStatus2, err := suite.status.ScheduledStatusesUpdate(ctx, account1, scheduledStatus1.ID, util.Ptr(newScheduledAt)) + suite.NoError(err) + suite.NotNil(scheduledStatus2) + suite.Equal(scheduledStatus2.ScheduledAt, util.FormatISO8601(newScheduledAt)) + // should be rescheduled + suite.Equal(suite.state.Workers.Scheduler.Cancel(scheduledStatus1.ID), true) +} + +func (suite *ScheduledStatusTestSuite) TestDelete() { + ctx := suite.T().Context() + + account1 := suite.testAccounts["local_account_1"] + scheduledStatus1 := suite.testScheduledStatuses["scheduled_status_1"] + + suite.state.Workers.Scheduler.AddOnce(scheduledStatus1.ID, scheduledStatus1.ScheduledAt, func(ctx context.Context, t time.Time) {}) + + // delete scheduled status + err := suite.status.ScheduledStatusesDelete(ctx, account1, scheduledStatus1.ID) + suite.NoError(err) + // should be already cancelled + suite.Equal(suite.state.Workers.Scheduler.Cancel(scheduledStatus1.ID), false) +} + +func TestScheduledStatusTestSuite(t *testing.T) { + suite.Run(t, new(ScheduledStatusTestSuite)) +} diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go index 26dfd0d7a..b911c03d7 100644 --- a/internal/processing/status/status.go +++ b/internal/processing/status/status.go @@ -18,16 +18,16 @@ package status import ( - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" - "github.com/superseriousbusiness/gotosocial/internal/processing/polls" - "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/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/processing/interactionrequests" + "code.superseriousbusiness.org/gotosocial/internal/processing/polls" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index 74aef7188..18d20d67c 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -18,24 +18,26 @@ package status_test import ( + "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" + statusfilter "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" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/processing/interactionrequests" + "code.superseriousbusiness.org/gotosocial/internal/processing/polls" + "code.superseriousbusiness.org/gotosocial/internal/processing/status" + "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/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" - "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" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" - "github.com/superseriousbusiness/gotosocial/internal/processing/polls" - "github.com/superseriousbusiness/gotosocial/internal/processing/status" - "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 StatusStandardTestSuite struct { @@ -49,15 +51,15 @@ type StatusStandardTestSuite struct { federator *federation.Federator // 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 - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - testTags map[string]*gtsmodel.Tag - testMentions map[string]*gtsmodel.Mention + testTokens map[string]*gtsmodel.Token + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testTags map[string]*gtsmodel.Tag + testMentions map[string]*gtsmodel.Mention + testScheduledStatuses map[string]*gtsmodel.ScheduledStatus // module being tested status status.Processor @@ -65,7 +67,6 @@ type StatusStandardTestSuite struct { func (suite *StatusStandardTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() @@ -73,6 +74,7 @@ func (suite *StatusStandardTestSuite) SetupSuite() { suite.testStatuses = testrig.NewTestStatuses() suite.testTags = testrig.NewTestTags() suite.testMentions = testrig.NewTestMentions() + suite.testScheduledStatuses = testrig.NewTestScheduledStatuses() } func (suite *StatusStandardTestSuite) SetupTest() { @@ -94,14 +96,11 @@ func (suite *StatusStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, suite.tc, suite.mediaManager) visFilter := visibility.NewFilter(&suite.state) + muteFilter := mutes.NewFilter(&suite.state) intFilter := interaction.NewFilter(&suite.state) - testrig.StartTimelines( - &suite.state, - visFilter, - suite.typeConverter, - ) + statusFilter := statusfilter.NewFilter(&suite.state) - common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter) + common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter, muteFilter, statusFilter) polls := polls.New(&common, &suite.state, suite.typeConverter) intReqs := interactionrequests.New(&common, &suite.state, suite.typeConverter) diff --git a/internal/processing/status/util.go b/internal/processing/status/util.go index 99cff7c56..fcaf37d82 100644 --- a/internal/processing/status/util.go +++ b/internal/processing/status/util.go @@ -21,10 +21,10 @@ import ( "context" "errors" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/util" ) func (p *Processor) implicitlyAccept( diff --git a/internal/processing/stream/authorize.go b/internal/processing/stream/authorize.go index 0baea29f1..44463d5c6 100644 --- a/internal/processing/stream/authorize.go +++ b/internal/processing/stream/authorize.go @@ -19,11 +19,15 @@ package stream import ( "context" + "errors" "fmt" + "slices" + "strings" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // Authorize returns an oauth2 token info in response to an access token query from the streaming API @@ -58,5 +62,22 @@ func (p *Processor) Authorize(ctx context.Context, accessToken string) (*gtsmode return nil, gtserror.NewErrorInternalError(err) } + // Ensure read scope. + // + // TODO: make this more granular + // depending on stream type. + hasScopes := strings.Split(ti.GetScope(), " ") + scopeOK := slices.ContainsFunc( + hasScopes, + func(hasScope string) bool { + return apiutil.Scope(hasScope).Permits(apiutil.ScopeRead) + }, + ) + + if !scopeOK { + const errText = "token has insufficient scope permission" + return nil, gtserror.NewErrorForbidden(errors.New(errText), errText) + } + return acct, nil } diff --git a/internal/processing/stream/authorize_test.go b/internal/processing/stream/authorize_test.go index cb91d5b30..7124888d9 100644 --- a/internal/processing/stream/authorize_test.go +++ b/internal/processing/stream/authorize_test.go @@ -18,11 +18,10 @@ package stream_test import ( - "context" "testing" + "code.superseriousbusiness.org/gotosocial/internal/db" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/db" ) type AuthorizeTestSuite struct { @@ -30,15 +29,15 @@ type AuthorizeTestSuite struct { } func (suite *AuthorizeTestSuite) TestAuthorize() { - account1, err := suite.streamProcessor.Authorize(context.Background(), suite.testTokens["local_account_1"].Access) + account1, err := suite.streamProcessor.Authorize(suite.T().Context(), suite.testTokens["local_account_1"].Access) suite.NoError(err) suite.Equal(suite.testAccounts["local_account_1"].ID, account1.ID) - account2, err := suite.streamProcessor.Authorize(context.Background(), suite.testTokens["local_account_2"].Access) + account2, err := suite.streamProcessor.Authorize(suite.T().Context(), suite.testTokens["local_account_2"].Access) suite.NoError(err) suite.Equal(suite.testAccounts["local_account_2"].ID, account2.ID) - noAccount, err := suite.streamProcessor.Authorize(context.Background(), "aaaaaaaaaaaaaaaaaaaaa!!") + noAccount, err := suite.streamProcessor.Authorize(suite.T().Context(), "aaaaaaaaaaaaaaaaaaaaa!!") suite.EqualError(err, "could not load access token: "+db.ErrNoEntries.Error()) suite.Nil(noAccount) } diff --git a/internal/processing/stream/conversation.go b/internal/processing/stream/conversation.go index a0236c459..b5394ee17 100644 --- a/internal/processing/stream/conversation.go +++ b/internal/processing/stream/conversation.go @@ -21,10 +21,10 @@ import ( "context" "encoding/json" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/stream" "codeberg.org/gruf/go-byteutil" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/stream" ) // Conversation streams the given conversation to any open, appropriate streams belonging to the given account. diff --git a/internal/processing/stream/delete.go b/internal/processing/stream/delete.go index 1c61b98d3..fc763f910 100644 --- a/internal/processing/stream/delete.go +++ b/internal/processing/stream/delete.go @@ -20,7 +20,7 @@ package stream import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/stream" ) // Delete streams the delete of the given statusID to *ALL* open streams. diff --git a/internal/processing/stream/filterschanged.go b/internal/processing/stream/filterschanged.go index b98506b9f..dff22c64b 100644 --- a/internal/processing/stream/filterschanged.go +++ b/internal/processing/stream/filterschanged.go @@ -20,8 +20,8 @@ package stream import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/stream" ) // FiltersChanged streams a filters changed event to any open, appropriate streams belonging to the given account. diff --git a/internal/processing/stream/notification.go b/internal/processing/stream/notification.go index a16da11e6..ef3acdc79 100644 --- a/internal/processing/stream/notification.go +++ b/internal/processing/stream/notification.go @@ -21,11 +21,11 @@ import ( "context" "encoding/json" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/stream" "codeberg.org/gruf/go-byteutil" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/stream" ) // Notify streams the given notification to any open, appropriate streams belonging to the given account. diff --git a/internal/processing/stream/notification_test.go b/internal/processing/stream/notification_test.go index 2ede28079..70fc45397 100644 --- a/internal/processing/stream/notification_test.go +++ b/internal/processing/stream/notification_test.go @@ -19,13 +19,12 @@ package stream_test import ( "bytes" - "context" "encoding/json" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) type NotificationTestSuite struct { @@ -35,11 +34,11 @@ type NotificationTestSuite struct { func (suite *NotificationTestSuite) TestStreamNotification() { account := suite.testAccounts["local_account_1"] - openStream, errWithCode := suite.streamProcessor.Open(context.Background(), account, "user") + openStream, errWithCode := suite.streamProcessor.Open(suite.T().Context(), account, "user") suite.NoError(errWithCode) followAccount := suite.testAccounts["remote_account_1"] - followAccountAPIModel, err := typeutils.NewConverter(&suite.state).AccountToAPIAccountPublic(context.Background(), followAccount) + followAccountAPIModel, err := typeutils.NewConverter(&suite.state).AccountToAPIAccountPublic(suite.T().Context(), followAccount) suite.NoError(err) notification := &apimodel.Notification{ @@ -49,9 +48,9 @@ func (suite *NotificationTestSuite) TestStreamNotification() { Account: followAccountAPIModel, } - suite.streamProcessor.Notify(context.Background(), account, notification) + suite.streamProcessor.Notify(suite.T().Context(), account, notification) - msg, ok := openStream.Recv(context.Background()) + msg, ok := openStream.Recv(suite.T().Context()) suite.True(ok) dst := new(bytes.Buffer) diff --git a/internal/processing/stream/open.go b/internal/processing/stream/open.go index 2f2bbd4a3..899e26896 100644 --- a/internal/processing/stream/open.go +++ b/internal/processing/stream/open.go @@ -20,11 +20,11 @@ package stream import ( "context" - "codeberg.org/gruf/go-kv" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/stream" + "codeberg.org/gruf/go-kv/v2" ) // Open returns a new Stream for the given account, which will contain a channel for passing messages back to the caller. diff --git a/internal/processing/stream/open_test.go b/internal/processing/stream/open_test.go index 21ef69154..cc08a17a7 100644 --- a/internal/processing/stream/open_test.go +++ b/internal/processing/stream/open_test.go @@ -18,7 +18,6 @@ package stream_test import ( - "context" "testing" "github.com/stretchr/testify/suite" @@ -31,7 +30,7 @@ type OpenStreamTestSuite struct { func (suite *OpenStreamTestSuite) TestOpenStream() { account := suite.testAccounts["local_account_1"] - _, errWithCode := suite.streamProcessor.Open(context.Background(), account, "user") + _, errWithCode := suite.streamProcessor.Open(suite.T().Context(), account, "user") suite.NoError(errWithCode) } diff --git a/internal/processing/stream/statusupdate.go b/internal/processing/stream/statusupdate.go index bd4658873..2f1ca598b 100644 --- a/internal/processing/stream/statusupdate.go +++ b/internal/processing/stream/statusupdate.go @@ -21,11 +21,11 @@ import ( "context" "encoding/json" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/stream" "codeberg.org/gruf/go-byteutil" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/stream" ) // StatusUpdate streams the given edited status to any open, appropriate streams belonging to the given account. diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go index 180538c60..a82348c31 100644 --- a/internal/processing/stream/statusupdate_test.go +++ b/internal/processing/stream/statusupdate_test.go @@ -19,14 +19,12 @@ package stream_test import ( "bytes" - "context" "encoding/json" "testing" + "code.superseriousbusiness.org/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" "github.com/stretchr/testify/suite" - statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) type StatusUpdateTestSuite struct { @@ -36,16 +34,16 @@ type StatusUpdateTestSuite struct { func (suite *StatusUpdateTestSuite) TestStreamNotification() { account := suite.testAccounts["local_account_1"] - openStream, errWithCode := suite.streamProcessor.Open(context.Background(), account, "user") + openStream, errWithCode := suite.streamProcessor.Open(suite.T().Context(), account, "user") suite.NoError(errWithCode) editedStatus := suite.testStatuses["remote_account_1_status_1"] - apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, statusfilter.FilterContextNotifications, nil, nil) + apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(suite.T().Context(), editedStatus, account) suite.NoError(err) - suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome) + suite.streamProcessor.StatusUpdate(suite.T().Context(), account, apiStatus, stream.TimelineHome) - msg, ok := openStream.Recv(context.Background()) + msg, ok := openStream.Recv(suite.T().Context()) suite.True(ok) dst := new(bytes.Buffer) @@ -71,7 +69,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { "muted": false, "bookmarked": false, "pinned": false, - "content": "dark souls status bot: \"thoughts of dog\"", + "content": "\u003cp\u003edark souls status bot: \"thoughts of dog\"\u003c/p\u003e", "reblog": null, "account": { "id": "01F8MH5ZK5VRH73AKHQM6Y9VNX", @@ -135,6 +133,11 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { "poll": null, "interaction_policy": { "can_favourite": { + "automatic_approval": [ + "public", + "me" + ], + "manual_approval": [], "always": [ "public", "me" @@ -142,6 +145,11 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { "with_approval": [] }, "can_reply": { + "automatic_approval": [ + "public", + "me" + ], + "manual_approval": [], "always": [ "public", "me" @@ -149,6 +157,11 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { "with_approval": [] }, "can_reblog": { + "automatic_approval": [ + "public", + "me" + ], + "manual_approval": [], "always": [ "public", "me" diff --git a/internal/processing/stream/stream.go b/internal/processing/stream/stream.go index 0b7285b58..19a82a8a2 100644 --- a/internal/processing/stream/stream.go +++ b/internal/processing/stream/stream.go @@ -18,9 +18,9 @@ package stream import ( - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/oauth" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/stream" ) type Processor struct { diff --git a/internal/processing/stream/stream_test.go b/internal/processing/stream/stream_test.go index 96ea65b0f..47c85af1f 100644 --- a/internal/processing/stream/stream_test.go +++ b/internal/processing/stream/stream_test.go @@ -18,14 +18,14 @@ package stream_test import ( + "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/oauth" + "code.superseriousbusiness.org/gotosocial/internal/processing/stream" + "code.superseriousbusiness.org/gotosocial/internal/state" + "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/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing/stream" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/testrig" ) type StreamTestSuite struct { @@ -52,7 +52,7 @@ func (suite *StreamTestSuite) SetupTest() { suite.db = testrig.NewTestDB(&suite.state) suite.state.DB = suite.db suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.oauthServer = testrig.NewTestOauthServer(&suite.state) suite.streamProcessor = stream.New(&suite.state, suite.oauthServer) testrig.StandardDBSetup(suite.db, suite.testAccounts) diff --git a/internal/processing/stream/update.go b/internal/processing/stream/update.go index a84763d51..f2b064c28 100644 --- a/internal/processing/stream/update.go +++ b/internal/processing/stream/update.go @@ -21,11 +21,11 @@ import ( "context" "encoding/json" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/stream" "codeberg.org/gruf/go-byteutil" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/stream" ) // Update streams the given update to any open, appropriate streams belonging to the given account. diff --git a/internal/processing/tags/follow.go b/internal/processing/tags/follow.go index f840f4bb7..879a1d9e8 100644 --- a/internal/processing/tags/follow.go +++ b/internal/processing/tags/follow.go @@ -21,11 +21,13 @@ 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/id" + 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/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // Follow follows the tag with the given name as the given account. @@ -63,5 +65,6 @@ func (p *Processor) Follow( ) } - return p.apiTag(ctx, tag, true) + apiTag := typeutils.TagToAPITag(tag, true, util.Ptr(true)) + return &apiTag, nil } diff --git a/internal/processing/tags/followed.go b/internal/processing/tags/followed.go index b9c450653..958960623 100644 --- a/internal/processing/tags/followed.go +++ b/internal/processing/tags/followed.go @@ -21,12 +21,12 @@ 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/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/paging" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // Followed gets the user's list of followed tags. @@ -53,14 +53,10 @@ func (p *Processor) Followed( lo := tags[count-1].ID hi := tags[0].ID - items := make([]interface{}, 0, count) following := util.Ptr(true) + items := make([]interface{}, 0, count) for _, tag := range tags { - apiTag, err := p.converter.TagToAPITag(ctx, tag, true, following) - if err != nil { - log.Errorf(ctx, "error converting tag %s to API representation: %v", tag.ID, err) - continue - } + apiTag := typeutils.TagToAPITag(tag, true, following) items = append(items, apiTag) } diff --git a/internal/processing/tags/followedtags.go b/internal/processing/tags/followedtags.go index c9093a6c6..c78e0cc23 100644 --- a/internal/processing/tags/followedtags.go +++ b/internal/processing/tags/followedtags.go @@ -18,13 +18,8 @@ package tags 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/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { @@ -38,16 +33,3 @@ func New(state *state.State, converter *typeutils.Converter) Processor { converter: converter, } } - -// apiTag is a shortcut to return the API version of the given tag, -// or return an appropriate error if conversion fails. -func (p *Processor) apiTag(ctx context.Context, tag *gtsmodel.Tag, following bool) (*apimodel.Tag, gtserror.WithCode) { - apiTag, err := p.converter.TagToAPITag(ctx, tag, true, &following) - if err != nil { - return nil, gtserror.NewErrorInternalError( - gtserror.Newf("error converting tag %s to API representation: %w", tag.Name, err), - ) - } - - return &apiTag, nil -} diff --git a/internal/processing/tags/get.go b/internal/processing/tags/get.go index c8fa66137..6c515ee1a 100644 --- a/internal/processing/tags/get.go +++ b/internal/processing/tags/get.go @@ -21,10 +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" + 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/typeutils" ) // Get gets the tag with the given name, including whether it's followed by the given account. @@ -53,5 +54,6 @@ func (p *Processor) Get( ) } - return p.apiTag(ctx, tag, following) + apiTag := typeutils.TagToAPITag(tag, true, &following) + return &apiTag, nil } diff --git a/internal/processing/tags/unfollow.go b/internal/processing/tags/unfollow.go index fb844cd9f..3d15d68c2 100644 --- a/internal/processing/tags/unfollow.go +++ b/internal/processing/tags/unfollow.go @@ -21,10 +21,12 @@ 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" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // Unfollow unfollows the tag with the given name as the given account. @@ -54,5 +56,6 @@ func (p *Processor) Unfollow( ) } - return p.apiTag(ctx, tag, false) + apiTag := typeutils.TagToAPITag(tag, true, util.Ptr(false)) + return &apiTag, nil } diff --git a/internal/processing/timeline/common.go b/internal/processing/timeline/common.go deleted file mode 100644 index 6d29d81d6..000000000 --- a/internal/processing/timeline/common.go +++ /dev/null @@ -1,71 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. - -package timeline - -import ( - "context" - - "github.com/superseriousbusiness/gotosocial/internal/timeline" -) - -// SkipInsert returns a function that satisifes SkipInsertFunction. -func SkipInsert() timeline.SkipInsertFunction { - // Gap to allow between a status or boost of status, - // and reinsertion of a new boost of that status. - // This is useful to avoid a heavily boosted status - // showing up way too often in a user's timeline. - const boostReinsertionDepth = 50 - - return func( - ctx context.Context, - newItemID string, - newItemAccountID string, - newItemBoostOfID string, - newItemBoostOfAccountID string, - nextItemID string, - nextItemAccountID string, - nextItemBoostOfID string, - nextItemBoostOfAccountID string, - depth int, - ) (bool, error) { - if newItemID == nextItemID { - // Don't insert duplicates. - return true, nil - } - - if newItemBoostOfID != "" { - if newItemBoostOfID == nextItemBoostOfID && - depth < boostReinsertionDepth { - // Don't insert boosts of items - // we've seen boosted recently. - return true, nil - } - - if newItemBoostOfID == nextItemID && - depth < boostReinsertionDepth { - // Don't insert boosts of items when - // we've seen the original recently. - return true, nil - } - } - - // Proceed with insertion - // (that's what she said!). - return false, nil - } -} diff --git a/internal/processing/timeline/faved.go b/internal/processing/timeline/faved.go index bb7f03fff..9218af9c8 100644 --- a/internal/processing/timeline/faved.go +++ b/internal/processing/timeline/faved.go @@ -22,16 +22,16 @@ 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/log" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" + 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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/util" ) -func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) { +// FavedTimelineGet ... +func (p *Processor) FavedTimelineGet(ctx context.Context, authed *apiutil.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) { statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit) if err != nil && !errors.Is(err, db.ErrNoEntries) { err = fmt.Errorf("FavedTimelineGet: db error getting statuses: %w", err) @@ -55,7 +55,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go index 215000933..3089f52fc 100644 --- a/internal/processing/timeline/home.go +++ b/internal/processing/timeline/home.go @@ -19,132 +19,97 @@ package timeline 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/filter/usermute" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/timeline" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -// HomeTimelineGrab returns a function that satisfies GrabFunction for home timelines. -func HomeTimelineGrab(state *state.State) timeline.GrabFunction { - return func(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) { - statuses, err := state.DB.GetHomeTimeline(ctx, accountID, maxID, sinceID, minID, limit, false) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("error getting statuses from db: %w", err) - return nil, false, err - } - - count := len(statuses) - if count == 0 { - // We just don't have enough statuses - // left in the db so return stop = true. - return nil, true, nil - } - - items := make([]timeline.Timelineable, count) - for i, s := range statuses { - items[i] = s - } - - return items, false, nil - } -} - -// HomeTimelineFilter returns a function that satisfies FilterFunction for home timelines. -func HomeTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction { - return func(ctx context.Context, accountID string, item timeline.Timelineable) (shouldIndex bool, err error) { - status, ok := item.(*gtsmodel.Status) - if !ok { - err = gtserror.New("could not convert item to *gtsmodel.Status") - return false, err - } - - requestingAccount, err := state.DB.GetAccountByID(ctx, accountID) - if err != nil { - err = gtserror.Newf("error getting account with id %s: %w", accountID, err) - return false, err - } + "net/url" - timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status) - if err != nil { - err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, accountID, err) - return false, err - } - - return timelineable, nil - } -} - -// HomeTimelineStatusPrepare returns a function that satisfies PrepareFunction for home timelines. -func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction { - return func(ctx context.Context, accountID string, itemID string) (timeline.Preparable, error) { - status, err := state.DB.GetStatusByID(ctx, itemID) - if err != nil { - err = gtserror.Newf("error getting status with id %s: %w", itemID, err) - return nil, err - } - - requestingAccount, err := state.DB.GetAccountByID(ctx, accountID) - if err != nil { - err = gtserror.Newf("error getting account with id %s: %w", accountID, err) - return nil, err - } - - filters, err := 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, err - } + 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" + "code.superseriousbusiness.org/gotosocial/internal/paging" +) - mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err) - return nil, err +// HomeTimelineGet gets a pageable timeline of statuses +// in the home timeline of the requesting account. +func (p *Processor) HomeTimelineGet( + ctx context.Context, + requester *gtsmodel.Account, + page *paging.Page, + local bool, +) ( + *apimodel.PageableResponse, + gtserror.WithCode, +) { + + var pageQuery url.Values + var postFilter func(*gtsmodel.Status) bool + if local { + // Set local = true query. + pageQuery = localOnlyTrue + postFilter = func(s *gtsmodel.Status) bool { + return !*s.Local } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes) + } else { + // Set local = false query. + pageQuery = localOnlyFalse + postFilter = nil } -} - -func (p *Processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) { - statuses, err := p.state.Timelines.Home.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("error getting statuses: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - count := len(statuses) - if count == 0 { - return util.EmptyPageableResponse(), nil - } - - var ( - items = make([]interface{}, count) - nextMaxIDValue = statuses[count-1].GetID() - prevMinIDValue = statuses[0].GetID() + return p.getStatusTimeline(ctx, + + // Auth'd + // account. + requester, + + // Keyed-by-account-ID, home timeline cache. + p.state.Caches.Timelines.Home.MustGet(requester.ID), + + // Current + // page. + page, + + // Home timeline endpoint. + "/api/v1/timelines/home", + + // Set local-only timeline + // page query flag, (this map + // later gets copied before + // any further usage). + pageQuery, + + // Status filter context. + gtsmodel.FilterContextHome, + + // Database load function. + func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { + return p.state.DB.GetHomeTimeline(ctx, requester.ID, pg) + }, + + // Filtering function, + // i.e. filter before caching. + func(s *gtsmodel.Status) bool { + + // Check the visibility of passed status to requesting user. + ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true + } + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true + } + + return false + }, + + // Post filtering funtion, + // i.e. filter after caching. + postFilter, ) - - for i := range statuses { - items[i] = statuses[i] - } - - return util.PackagePageableResponse(util.PageableResponseParams{ - Items: items, - Path: "/api/v1/timelines/home", - NextMaxIDValue: nextMaxIDValue, - PrevMinIDValue: prevMinIDValue, - Limit: limit, - }) } diff --git a/internal/processing/timeline/home_test.go b/internal/processing/timeline/home_test.go index c73c209a3..2d0c912f8 100644 --- a/internal/processing/timeline/home_test.go +++ b/internal/processing/timeline/home_test.go @@ -18,53 +18,28 @@ package timeline_test import ( - "context" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/paging" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - tlprocessor "github.com/superseriousbusiness/gotosocial/internal/processing/timeline" - "github.com/superseriousbusiness/gotosocial/internal/timeline" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" ) type HomeTestSuite struct { TimelineStandardTestSuite } -func (suite *HomeTestSuite) SetupTest() { - suite.TimelineStandardTestSuite.SetupTest() - - suite.state.Timelines.Home = timeline.NewManager( - tlprocessor.HomeTimelineGrab(&suite.state), - tlprocessor.HomeTimelineFilter(&suite.state, visibility.NewFilter(&suite.state)), - tlprocessor.HomeTimelineStatusPrepare(&suite.state, typeutils.NewConverter(&suite.state)), - tlprocessor.SkipInsert(), - ) - if err := suite.state.Timelines.Home.Start(); err != nil { - suite.FailNow(err.Error()) - } -} - func (suite *HomeTestSuite) TearDownTest() { - if err := suite.state.Timelines.Home.Stop(); err != nil { - suite.FailNow(err.Error()) - } - suite.TimelineStandardTestSuite.TearDownTest() } // A timeline containing a status hidden due to filtering should return other statuses with no error. func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() { var ( - ctx = context.Background() + ctx = suite.T().Context() requester = suite.testAccounts["local_account_1"] - authed = &oauth.Auth{Account: requester} maxID = "" sinceID = "" minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus @@ -73,35 +48,32 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() { filteredStatus = suite.testStatuses["admin_account_status_2"] filteredStatusFound = false filterID = id.NewULID() - filter = >smodel.Filter{ + filterStatusID = id.NewULID() + filterStatus = >smodel.FilterStatus{ + ID: filterStatusID, + FilterID: filterID, + StatusID: filteredStatus.ID, + } + filter = >smodel.Filter{ ID: filterID, AccountID: requester.ID, Title: "timeline filtering test", Action: gtsmodel.FilterActionHide, - Statuses: []*gtsmodel.FilterStatus{ - { - ID: id.NewULID(), - AccountID: requester.ID, - FilterID: filterID, - StatusID: filteredStatus.ID, - }, - }, - ContextHome: util.Ptr(true), - ContextNotifications: util.Ptr(false), - ContextPublic: util.Ptr(false), - ContextThread: util.Ptr(false), - ContextAccount: util.Ptr(false), + Statuses: []*gtsmodel.FilterStatus{filterStatus}, + StatusIDs: []string{filterStatusID}, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome), } ) // Fetch the timeline to make sure the status we're going to filter is in that section of it. resp, errWithCode := suite.timeline.HomeTimelineGet( ctx, - authed, - maxID, - sinceID, - minID, - limit, + requester, + &paging.Page{ + Min: paging.EitherMinID(minID, sinceID), + Max: paging.MaxID(maxID), + Limit: limit, + }, local, ) suite.NoError(errWithCode) @@ -114,8 +86,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() { if !filteredStatusFound { suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline") } - // Prune the timeline to drop cached prepared statuses, a side effect of this precondition check. - if _, err := suite.state.Timelines.Home.Prune(ctx, requester.ID, 0, 0); err != nil { + + // Clear the timeline to drop all cached statuses. + suite.state.Caches.Timelines.Home.Clear(requester.ID) + + // Create the filter status associated with the main filter. + if err := suite.db.PutFilterStatus(ctx, filterStatus); err != nil { suite.FailNow(err.Error()) } @@ -127,11 +103,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() { // Fetch the timeline again with the filter in place. resp, errWithCode = suite.timeline.HomeTimelineGet( ctx, - authed, - maxID, - sinceID, - minID, - limit, + requester, + &paging.Page{ + Min: paging.EitherMinID(minID, sinceID), + Max: paging.MaxID(maxID), + Limit: limit, + }, local, ) diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go index a7f5e9d71..265cd5ca2 100644 --- a/internal/processing/timeline/list.go +++ b/internal/processing/timeline/list.go @@ -21,156 +21,106 @@ 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/filter/usermute" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/timeline" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "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/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) -// ListTimelineGrab returns a function that satisfies GrabFunction for list timelines. -func ListTimelineGrab(state *state.State) timeline.GrabFunction { - return func(ctx context.Context, listID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) { - statuses, err := state.DB.GetListTimeline(ctx, listID, maxID, sinceID, minID, limit) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("error getting statuses from db: %w", err) - return nil, false, err - } - - count := len(statuses) - if count == 0 { - // We just don't have enough statuses - // left in the db so return stop = true. - return nil, true, nil - } - - items := make([]timeline.Timelineable, count) - for i, s := range statuses { - items[i] = s - } - - return items, false, nil - } -} - -// ListTimelineFilter returns a function that satisfies FilterFunction for list timelines. -func ListTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction { - return func(ctx context.Context, listID string, item timeline.Timelineable) (shouldIndex bool, err error) { - status, ok := item.(*gtsmodel.Status) - if !ok { - err = gtserror.New("could not convert item to *gtsmodel.Status") - return false, err - } - - list, err := state.DB.GetListByID(ctx, listID) - if err != nil { - err = gtserror.Newf("error getting list with id %s: %w", listID, err) - return false, err - } - - requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID) - if err != nil { - err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err) - return false, err - } - - timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status) - if err != nil { - err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, list.AccountID, err) - return false, err - } - - return timelineable, nil - } -} - -// ListTimelineStatusPrepare returns a function that satisfies PrepareFunction for list timelines. -func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction { - return func(ctx context.Context, listID string, itemID string) (timeline.Preparable, error) { - status, err := state.DB.GetStatusByID(ctx, itemID) - if err != nil { - err = gtserror.Newf("error getting status with id %s: %w", itemID, err) - return nil, err - } - - list, err := state.DB.GetListByID(ctx, listID) - if err != nil { - err = gtserror.Newf("error getting list with id %s: %w", listID, err) - return nil, err - } - - requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID) - if err != nil { - err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err) - return nil, err - } - - filters, err := 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, err - } - - mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err) - return nil, err - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes) - } -} - -func (p *Processor) ListTimelineGet(ctx context.Context, authed *oauth.Auth, listID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) { - // Ensure list exists + is owned by this account. - list, err := p.state.DB.GetListByID(ctx, listID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - - if list.AccountID != authed.Account.ID { - err = gtserror.Newf("list with id %s does not belong to account %s", list.ID, authed.Account.ID) - return nil, gtserror.NewErrorNotFound(err) - } - - statuses, err := p.state.Timelines.List.GetTimeline(ctx, listID, maxID, sinceID, minID, limit, false) +// ListTimelineGet gets a pageable timeline of statuses +// in the list timeline of ID by the requesting account. +func (p *Processor) ListTimelineGet( + ctx context.Context, + requester *gtsmodel.Account, + listID string, + page *paging.Page, +) ( + *apimodel.PageableResponse, + gtserror.WithCode, +) { + // Fetch the requested list with ID. + list, err := p.state.DB.GetListByID( + gtscontext.SetBarebones(ctx), + listID, + ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("error getting statuses: %w", err) return nil, gtserror.NewErrorInternalError(err) } - count := len(statuses) - if count == 0 { - return util.EmptyPageableResponse(), nil + // Check exists. + if list == nil { + const text = "list not found" + return nil, gtserror.NewErrorNotFound( + errors.New(text), + text, + ) } - var ( - items = make([]interface{}, count) - nextMaxIDValue = statuses[count-1].GetID() - prevMinIDValue = statuses[0].GetID() - ) - - for i := range statuses { - items[i] = statuses[i] + // Check list owned by auth'd account. + if list.AccountID != requester.ID { + err := gtserror.New("list does not belong to account") + return nil, gtserror.NewErrorNotFound(err) } - return util.PackagePageableResponse(util.PageableResponseParams{ - Items: items, - Path: "/api/v1/timelines/list/" + listID, - NextMaxIDValue: nextMaxIDValue, - PrevMinIDValue: prevMinIDValue, - Limit: limit, - }) + // Fetch status timeline for list. + return p.getStatusTimeline(ctx, + + // Auth'd + // account. + requester, + + // Keyed-by-list-ID, list timeline cache. + p.state.Caches.Timelines.List.MustGet(listID), + + // Current + // page. + page, + + // List timeline ID's endpoint. + "/api/v1/timelines/list/"+listID, + + // No page + // query. + nil, + + // Status filter context. + gtsmodel.FilterContextHome, + + // Database load function. + func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { + return p.state.DB.GetListTimeline(ctx, listID, pg) + }, + + // Filtering function, + // i.e. filter before caching. + func(s *gtsmodel.Status) bool { + + // Check the visibility of passed status to requesting user. + ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true + } + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true + } + + return false + }, + + // Post filtering funtion, + // i.e. filter after caching. + nil, + ) } diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go index 09636e7eb..7b98970c2 100644 --- a/internal/processing/timeline/notification.go +++ b/internal/processing/timeline/notification.go @@ -21,31 +21,29 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" - "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/oauth" - "github.com/superseriousbusiness/gotosocial/internal/paging" - "github.com/superseriousbusiness/gotosocial/internal/util" + 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/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" ) +// NotificationsGet ... func (p *Processor) NotificationsGet( ctx context.Context, - authed *oauth.Auth, + requester *gtsmodel.Account, page *paging.Page, types []gtsmodel.NotificationType, excludeTypes []gtsmodel.NotificationType, ) (*apimodel.PageableResponse, gtserror.WithCode) { - notifs, err := p.state.DB.GetAccountNotifications( - ctx, - authed.Account.ID, + notifs, err := p.state.DB.GetAccountNotifications(ctx, + requester.ID, page, types, excludeTypes, @@ -60,19 +58,6 @@ func (p *Processor) NotificationsGet( return util.EmptyPageableResponse(), nil } - filters, err := p.state.DB.GetFiltersForAccountID(ctx, authed.Account.ID) - if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", authed.Account.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), authed.Account.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", authed.Account.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - var ( items = make([]interface{}, 0, count) @@ -83,7 +68,7 @@ func (p *Processor) NotificationsGet( ) for _, n := range notifs { - visible, err := p.notifVisible(ctx, n, authed.Account) + visible, err := p.notifVisible(ctx, n, requester) if err != nil { log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %v", n.ID, err) continue @@ -93,14 +78,66 @@ func (p *Processor) NotificationsGet( continue } - item, err := p.converter.NotificationToAPINotification(ctx, n, filters, compiledMutes) + // Check whether notification origin account is muted. + muted, err := p.muteFilter.AccountNotificationsMuted(ctx, + requester, + n.OriginAccount, + ) if err != nil { - if !errors.Is(err, status.ErrHideStatus) { - log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err) + log.Errorf(ctx, "error checking account mute: %v", err) + continue + } + + if muted { + continue + } + + var filtered []apimodel.FilterResult + + if n.Status != nil { + var hide bool + + // Check whether notification status is muted by requester. + muted, err = p.muteFilter.StatusNotificationsMuted(ctx, + requester, + n.Status, + ) + if err != nil { + log.Errorf(ctx, "error checking status mute: %v", err) + continue } + + if muted { + continue + } + + // Check whether notification status is filtered by requester in notifs. + filtered, hide, err = p.statusFilter.StatusFilterResultsInContext(ctx, + requester, + n.Status, + gtsmodel.FilterContextNotifications, + ) + if err != nil { + log.Errorf(ctx, "error checking status filtering: %v", err) + continue + } + + if hide { + continue + } + } + + item, err := p.converter.NotificationToAPINotification(ctx, n) + if err != nil { continue } + if item.Status != nil { + // Set filter results on status, + // in case any were set above. + item.Status.Filtered = filtered + } + items = append(items, item) } @@ -124,47 +161,38 @@ func (p *Processor) NotificationsGet( func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Account, targetNotifID string) (*apimodel.Notification, gtserror.WithCode) { notif, err := p.state.DB.GetNotificationByID(ctx, targetNotifID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - - // Real error. + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting from db: %w", err) return nil, gtserror.NewErrorInternalError(err) } - if notifTargetAccountID := notif.TargetAccountID; notifTargetAccountID != account.ID { - err = fmt.Errorf("account %s does not have permission to view notification belong to account %s", account.ID, notifTargetAccountID) - return nil, gtserror.NewErrorNotFound(err) + if notif == nil { + const text = "notification not found" + return nil, gtserror.NewErrorNotFound( + errors.New(text), + text, + ) } - filters, err := p.state.DB.GetFiltersForAccountID(ctx, account.ID) - if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", account.ID, err) - return nil, gtserror.NewErrorInternalError(err) + if notif.TargetAccountID != account.ID { + err := gtserror.New("requester does not match notification target") + return nil, gtserror.NewErrorNotFound(err) } - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), account.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", account.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) + // NOTE: we specifically don't do any filtering + // or mute checking for a notification directly + // fetched by ID. only from timelines etc. - apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes) + apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif) if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - - // Real error. - return nil, gtserror.NewErrorInternalError(err) + err := gtserror.Newf("error converting to api model: %w", err) + return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err) } return apiNotif, nil } -func (p *Processor) NotificationsClear(ctx context.Context, authed *oauth.Auth) gtserror.WithCode { +func (p *Processor) NotificationsClear(ctx context.Context, authed *apiutil.Auth) gtserror.WithCode { // Delete all notifications of all types that target the authorized account. if err := p.state.DB.DeleteNotifications(ctx, nil, authed.Account.ID, ""); err != nil && !errors.Is(err, db.ErrNoEntries) { return gtserror.NewErrorInternalError(err) diff --git a/internal/processing/timeline/public.go b/internal/processing/timeline/public.go index dc00688e3..d724bfaa1 100644 --- a/internal/processing/timeline/public.go +++ b/internal/processing/timeline/public.go @@ -19,152 +19,166 @@ package timeline import ( "context" - "errors" - "strconv" - - 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/filter/usermute" - "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/util" + + 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" + "code.superseriousbusiness.org/gotosocial/internal/paging" ) +// PublicTimelineGet gets a pageable timeline of public statuses +// for the given requesting account. It ensures that each status +// in timeline is visible to the account before returning it. +// +// The local argument limits this to local-only statuses. func (p *Processor) PublicTimelineGet( ctx context.Context, requester *gtsmodel.Account, - maxID string, - sinceID string, - minID string, - limit int, + page *paging.Page, local bool, -) (*apimodel.PageableResponse, gtserror.WithCode) { - const maxAttempts = 3 - var ( - nextMaxIDValue string - prevMinIDValue string - items = make([]any, 0, limit) - ) - - var filters []*gtsmodel.Filter - var compiledMutes *usermute.CompiledUserMuteList - if requester != nil { - var err error - filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID) - if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requester.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requester.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - compiledMutes = usermute.NewCompiledUserMuteList(mutes) +) ( + *apimodel.PageableResponse, + gtserror.WithCode, +) { + if local { + return p.localTimelineGet(ctx, requester, page) } + return p.publicTimelineGet(ctx, requester, page) +} + +func (p *Processor) publicTimelineGet( + ctx context.Context, + requester *gtsmodel.Account, + page *paging.Page, +) ( + *apimodel.PageableResponse, + gtserror.WithCode, +) { + return p.getStatusTimeline(ctx, + + // Auth acconut, + // can be nil. + requester, + + // No cache. + nil, + + // Current + // page. + page, + + // Public timeline endpoint. + "/api/v1/timelines/public", + + // Set local-only timeline + // page query flag, (this map + // later gets copied before + // any further usage). + localOnlyFalse, + + // Status filter context. + gtsmodel.FilterContextPublic, + + // Database load function. + func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { + return p.state.DB.GetPublicTimeline(ctx, pg) + }, - // Try a few times to select appropriate public - // statuses from the db, paging up or down to - // reattempt if nothing suitable is found. -outer: - for attempts := 1; ; attempts++ { - // Select slightly more than the limit to try to avoid situations where - // we filter out all the entries, and have to make another db call. - // It's cheaper to select more in 1 query than it is to do multiple queries. - statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit+5, local) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("db error getting statuses: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - count := len(statuses) - if count == 0 { - // Nothing relevant (left) in the db. - return util.EmptyPageableResponse(), nil - } - - // Page up from first status in slice - // (ie., one with the highest ID). - prevMinIDValue = statuses[0].ID - - inner: - for _, s := range statuses { - // Push back the next page down ID to - // this status, regardless of whether - // we end up filtering it out or not. - nextMaxIDValue = s.ID - - timelineable, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s) + // Pre-filtering function, + // i.e. filter before caching. + func(s *gtsmodel.Status) bool { + + // Check the visibility of passed status to requesting user. + ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s) if err != nil { - log.Errorf(ctx, "error checking status visibility: %v", err) - continue inner + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true } - if !timelineable { - continue inner + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters, compiledMutes) - if errors.Is(err, statusfilter.ErrHideStatus) { - continue - } + return false + }, + + // Post filtering funtion, + // i.e. filter after caching. + nil, + ) +} + +func (p *Processor) localTimelineGet( + ctx context.Context, + requester *gtsmodel.Account, + page *paging.Page, +) ( + *apimodel.PageableResponse, + gtserror.WithCode, +) { + return p.getStatusTimeline(ctx, + + // Auth acconut, + // can be nil. + requester, + + // No cache. + nil, + + // Current + // page. + page, + + // Public timeline endpoint. + "/api/v1/timelines/public", + + // Set local-only timeline + // page query flag, (this map + // later gets copied before + // any further usage). + localOnlyTrue, + + // Status filter context. + gtsmodel.FilterContextPublic, + + // Database load function. + func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { + return p.state.DB.GetLocalTimeline(ctx, pg) + }, + + // Filtering function, + // i.e. filter before caching. + func(s *gtsmodel.Status) bool { + + // Check the visibility of passed status to requesting user. + ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s) if err != nil { - log.Errorf(ctx, "error converting to api status: %v", err) - continue inner + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + } else if !ok { + return true } - // Looks good, add this. - items = append(items, apiStatus) - - // We called the db with a little - // more than the desired limit. - // - // Ensure we don't return more - // than the caller asked for. - if len(items) == limit { - break outer + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + } else if muted { + return true } - } - - if len(items) != 0 { - // We've got some items left after - // filtering, happily break + return. - break - } - - if attempts >= maxAttempts { - // We reached our attempts limit. - // Be nice + warn about it. - log.Warn(ctx, "reached max attempts to find items in public timeline") - break - } - - // We filtered out all items before we - // found anything we could return, but - // we still have attempts left to try - // fetching again. Set paging params - // and allow loop to continue. - if minID != "" { - // Paging up. - minID = prevMinIDValue - } else { - // Paging down. - maxID = nextMaxIDValue - } - } - return util.PackagePageableResponse(util.PageableResponseParams{ - Items: items, - Path: "/api/v1/timelines/public", - NextMaxIDValue: nextMaxIDValue, - PrevMinIDValue: prevMinIDValue, - Limit: limit, - ExtraQueryParams: []string{ - "local=" + strconv.FormatBool(local), + return false }, - }) + + // Post filtering funtion, + // i.e. filter after caching. + nil, + ) } diff --git a/internal/processing/timeline/public_test.go b/internal/processing/timeline/public_test.go index ab8e33429..341df999e 100644 --- a/internal/processing/timeline/public_test.go +++ b/internal/processing/timeline/public_test.go @@ -18,14 +18,13 @@ package timeline_test import ( - "context" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/paging" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/util" ) type PublicTestSuite struct { @@ -34,7 +33,7 @@ type PublicTestSuite struct { func (suite *PublicTestSuite) TestPublicTimelineGet() { var ( - ctx = context.Background() + ctx = suite.T().Context() requester = suite.testAccounts["local_account_1"] maxID = "" sinceID = "" @@ -46,10 +45,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGet() { resp, errWithCode := suite.timeline.PublicTimelineGet( ctx, requester, - maxID, - sinceID, - minID, - limit, + &paging.Page{ + Min: paging.EitherMinID(minID, sinceID), + Max: paging.MaxID(maxID), + Limit: limit, + }, local, ) @@ -64,10 +64,10 @@ func (suite *PublicTestSuite) TestPublicTimelineGet() { func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() { var ( - ctx = context.Background() + ctx = suite.T().Context() requester = suite.testAccounts["local_account_1"] // Select 1 *just above* a status we know should - // not be in the public timeline -- a public + // not be in the public timeline -- an unlisted // reply to one of admin's statuses. maxID = "01HE7XJ1CG84TBKH5V9XKBVGF6" sinceID = "" @@ -79,10 +79,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() { resp, errWithCode := suite.timeline.PublicTimelineGet( ctx, requester, - maxID, - sinceID, - minID, - limit, + &paging.Page{ + Min: paging.EitherMinID(minID, sinceID), + Max: paging.MaxID(maxID), + Limit: limit, + }, local, ) @@ -90,15 +91,15 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() { // some other statuses were filtered out. suite.NoError(errWithCode) suite.Len(resp.Items, 1) - suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false>; rel="prev"`, resp.LinkHeader) - suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false`, resp.NextLink) - suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false`, resp.PrevLink) + suite.Equal("<http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV>; rel=\"next\", <http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01F8MHCP5P2NWYQ416SBA0XSEV>; rel=\"prev\"", resp.LinkHeader) + suite.Equal("http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV", resp.NextLink) + suite.Equal("http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01F8MHCP5P2NWYQ416SBA0XSEV", resp.PrevLink) } // A timeline containing a status hidden due to filtering should return other statuses with no error. func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { var ( - ctx = context.Background() + ctx = suite.T().Context() requester = suite.testAccounts["local_account_1"] maxID = "" sinceID = "" @@ -108,24 +109,20 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { filteredStatus = suite.testStatuses["admin_account_status_2"] filteredStatusFound = false filterID = id.NewULID() - filter = >smodel.Filter{ + filterStatusID = id.NewULID() + filterStatus = >smodel.FilterStatus{ + ID: filterStatusID, + FilterID: filterID, + StatusID: filteredStatus.ID, + } + filter = >smodel.Filter{ ID: filterID, AccountID: requester.ID, Title: "timeline filtering test", Action: gtsmodel.FilterActionHide, - Statuses: []*gtsmodel.FilterStatus{ - { - ID: id.NewULID(), - AccountID: requester.ID, - FilterID: filterID, - StatusID: filteredStatus.ID, - }, - }, - ContextHome: util.Ptr(false), - ContextNotifications: util.Ptr(false), - ContextPublic: util.Ptr(true), - ContextThread: util.Ptr(false), - ContextAccount: util.Ptr(false), + Statuses: []*gtsmodel.FilterStatus{filterStatus}, + StatusIDs: []string{filterStatusID}, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextPublic), } ) @@ -133,10 +130,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { resp, errWithCode := suite.timeline.PublicTimelineGet( ctx, requester, - maxID, - sinceID, - minID, - limit, + &paging.Page{ + Min: paging.EitherMinID(minID, sinceID), + Max: paging.MaxID(maxID), + Limit: limit, + }, local, ) suite.NoError(errWithCode) @@ -149,8 +147,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { if !filteredStatusFound { suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline") } - // The public timeline has no prepared status cache and doesn't need to be pruned, - // as in the home timeline version of this test. + + // Create the filter status associated with the main filter. + if err := suite.db.PutFilterStatus(ctx, filterStatus); err != nil { + suite.FailNow(err.Error()) + } // Create a filter to hide one status on the timeline. if err := suite.db.PutFilter(ctx, filter); err != nil { @@ -161,10 +162,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { resp, errWithCode = suite.timeline.PublicTimelineGet( ctx, requester, - maxID, - sinceID, - minID, - limit, + &paging.Page{ + Min: paging.EitherMinID(minID, sinceID), + Max: paging.MaxID(maxID), + Limit: limit, + }, local, ) diff --git a/internal/processing/timeline/tag.go b/internal/processing/timeline/tag.go index 811d0bb33..995f9f8cc 100644 --- a/internal/processing/timeline/tag.go +++ b/internal/processing/timeline/tag.go @@ -20,18 +20,15 @@ package timeline import ( "context" "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/filter/usermute" - "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/text" - "github.com/superseriousbusiness/gotosocial/internal/util" + "net/http" + + 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/text" ) // TagTimelineGet gets a pageable timeline for the given @@ -40,37 +37,90 @@ import ( // to requestingAcct before returning it. func (p *Processor) TagTimelineGet( ctx context.Context, - requestingAcct *gtsmodel.Account, + requester *gtsmodel.Account, tagName string, maxID string, sinceID string, minID string, limit int, ) (*apimodel.PageableResponse, gtserror.WithCode) { + + // Fetch the requested tag with name. tag, errWithCode := p.getTag(ctx, tagName) if errWithCode != nil { return nil, errWithCode } + // Check for a useable returned tag for endpoint. if tag == nil || !*tag.Useable || !*tag.Listable { + // Obey mastodon API by returning 404 for this. - err := fmt.Errorf("tag was not found, or not useable/listable on this instance") - return nil, gtserror.NewErrorNotFound(err, err.Error()) + const text = "tag was not found, or not useable/listable on this instance" + return nil, gtserror.NewWithCode(http.StatusNotFound, text) } - statuses, err := p.state.DB.GetTagTimeline(ctx, tag.ID, maxID, sinceID, minID, limit) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("db error getting statuses: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } + // Fetch status timeline for tag. + return p.getStatusTimeline(ctx, + + // Auth'd + // account. + requester, + + // No + // cache. + nil, - return p.packageTagResponse( - ctx, - requestingAcct, - statuses, - limit, - // Use API URL for tag. + // Current + // page. + &paging.Page{ + Min: paging.EitherMinID(minID, sinceID), + Max: paging.MaxID(maxID), + Limit: limit, + }, + + // Tag timeline name's endpoint. "/api/v1/timelines/tag/"+tagName, + + // No page + // query. + nil, + + // Status filter context. + gtsmodel.FilterContextPublic, + + // Database load function. + func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { + return p.state.DB.GetTagTimeline(ctx, tag.ID, pg) + }, + + // Filtering function, + // i.e. filter before caching. + func(s *gtsmodel.Status) bool { + + // Check the visibility of passed status to requesting user. + ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true + } + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true + } + + return false + }, + + // Post filtering funtion, + // i.e. filter after caching. + nil, ) } @@ -92,69 +142,3 @@ func (p *Processor) getTag(ctx context.Context, tagName string) (*gtsmodel.Tag, return tag, nil } - -func (p *Processor) packageTagResponse( - ctx context.Context, - requestingAcct *gtsmodel.Account, - statuses []*gtsmodel.Status, - limit int, - requestPath string, -) (*apimodel.PageableResponse, gtserror.WithCode) { - count := len(statuses) - if count == 0 { - return util.EmptyPageableResponse(), nil - } - - var ( - items = make([]interface{}, 0, count) - - // Set next + prev values before filtering and API - // converting, so caller can still page properly. - nextMaxIDValue = statuses[count-1].ID - prevMinIDValue = statuses[0].ID - ) - - filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAcct.ID) - if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAcct.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAcct.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAcct.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - for _, s := range statuses { - timelineable, err := p.visFilter.StatusTagTimelineable(ctx, requestingAcct, s) - if err != nil { - log.Errorf(ctx, "error checking status visibility: %v", err) - continue - } - - if !timelineable { - continue - } - - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters, compiledMutes) - if errors.Is(err, statusfilter.ErrHideStatus) { - continue - } - if err != nil { - log.Errorf(ctx, "error converting to api status: %v", err) - continue - } - - items = append(items, apiStatus) - } - - return util.PackagePageableResponse(util.PageableResponseParams{ - Items: items, - Path: requestPath, - NextMaxIDValue: nextMaxIDValue, - PrevMinIDValue: prevMinIDValue, - Limit: limit, - }) -} diff --git a/internal/processing/timeline/timeline.go b/internal/processing/timeline/timeline.go index 5966fe864..06580b3c7 100644 --- a/internal/processing/timeline/timeline.go +++ b/internal/processing/timeline/timeline.go @@ -18,21 +18,151 @@ package timeline import ( - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "context" + "net/http" + "net/url" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + timelinepkg "code.superseriousbusiness.org/gotosocial/internal/cache/timeline" + "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util/xslices" +) + +var ( + // pre-prepared URL values to be passed in to + // paging response forms. The paging package always + // copies values before any modifications so it's + // safe to only use a single map variable for these. + localOnlyTrue = url.Values{"local": {"true"}} + localOnlyFalse = url.Values{"local": {"false"}} ) type Processor struct { - state *state.State - converter *typeutils.Converter - visFilter *visibility.Filter + state *state.State + converter *typeutils.Converter + visFilter *visibility.Filter + muteFilter *mutes.Filter + statusFilter *status.Filter } -func New(state *state.State, converter *typeutils.Converter, visFilter *visibility.Filter) Processor { +func New( + state *state.State, + converter *typeutils.Converter, + visFilter *visibility.Filter, + muteFilter *mutes.Filter, + statusFilter *status.Filter, +) Processor { return Processor{ - state: state, - converter: converter, - visFilter: visFilter, + state: state, + converter: converter, + visFilter: visFilter, + muteFilter: muteFilter, + statusFilter: statusFilter, + } +} + +func (p *Processor) getStatusTimeline( + ctx context.Context, + requester *gtsmodel.Account, + timeline *timelinepkg.StatusTimeline, + page *paging.Page, + pagePath string, + pageQuery url.Values, + filterCtx gtsmodel.FilterContext, + loadPage func(*paging.Page) (statuses []*gtsmodel.Status, err error), + filter func(*gtsmodel.Status) (delete bool), + postFilter func(*gtsmodel.Status) (remove bool), +) ( + *apimodel.PageableResponse, + gtserror.WithCode, +) { + var err error + + // Ensure we have valid + // input paging cursor. + id.ValidatePage(page) + + // Load status page via timeline cache, also + // getting lo, hi values for next, prev pages. + // + // NOTE: this safely handles the case of a nil + // input timeline, i.e. uncached timeline type. + apiStatuses, lo, hi, err := timeline.Load(ctx, + + // Status page + // to load. + page, + + // Caller provided database + // status page loading function. + loadPage, + + // Status load function for cached timeline entries. + func(ids []string) ([]*gtsmodel.Status, error) { + return p.state.DB.GetStatusesByIDs(ctx, ids) + }, + + // Call provided status + // filtering function. + filter, + + // Frontend API model preparation function. + func(status *gtsmodel.Status) (*apimodel.Status, error) { + + // Check if status needs filtering OUTSIDE of caching stage. + // TODO: this will be moved to separate postFilter hook when + // all filtering has been removed from the type converter. + if postFilter != nil && postFilter(status) { + return nil, nil + } + + // Check whether this status is filtered by requester in this context. + filters, hide, err := p.statusFilter.StatusFilterResultsInContext(ctx, + requester, + status, + filterCtx, + ) + if err != nil { + return nil, err + } else if hide { + return nil, nil + } + + // Finally, pass status to get converted to API model. + apiStatus, err := p.converter.StatusToAPIStatus(ctx, + status, + requester, + ) + if err != nil { + return nil, err + } + + // Set any filters on status. + apiStatus.Filtered = filters + + return apiStatus, nil + }, + ) + + if err != nil { + err := gtserror.Newf("error loading timeline: %w", err) + return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err) } + + // Package returned API statuses as pageable response. + return paging.PackageResponse(paging.ResponseParams{ + Items: xslices.ToAny(apiStatuses), + Path: pagePath, + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: pageQuery, + }), nil } diff --git a/internal/processing/timeline/timeline_test.go b/internal/processing/timeline/timeline_test.go index 8ff6be5d1..ce7817df6 100644 --- a/internal/processing/timeline/timeline_test.go +++ b/internal/processing/timeline/timeline_test.go @@ -18,15 +18,17 @@ package timeline_test import ( + "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/db" + "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/processing/timeline" + "code.superseriousbusiness.org/gotosocial/internal/state" + "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/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/processing/timeline" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" ) type TimelineStandardTestSuite struct { @@ -62,6 +64,8 @@ func (suite *TimelineStandardTestSuite) SetupTest() { &suite.state, typeutils.NewConverter(&suite.state), visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), + status.NewFilter(&suite.state), ) testrig.StandardDBSetup(suite.db, suite.testAccounts) diff --git a/internal/processing/user/create.go b/internal/processing/user/create.go index f878d8320..0b2451b4c 100644 --- a/internal/processing/user/create.go +++ b/internal/processing/user/create.go @@ -22,14 +22,14 @@ import ( "fmt" "time" - "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/messages" - "github.com/superseriousbusiness/gotosocial/internal/text" - "github.com/superseriousbusiness/oauth2/v4" + "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/messages" + "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/oauth2/v4" ) // Create processes the given form for creating a new user+account. @@ -122,7 +122,7 @@ func (p *Processor) Create( Username: form.Username, Email: form.Email, Password: form.Password, - Reason: text.SanitizeToPlaintext(reason), + Reason: text.StripHTMLFromText(reason), SignUpIP: form.IP, Locale: form.Locale, AppID: app.ID, diff --git a/internal/processing/user/create_test.go b/internal/processing/user/create_test.go index 9781a4214..9babbdfd5 100644 --- a/internal/processing/user/create_test.go +++ b/internal/processing/user/create_test.go @@ -18,12 +18,11 @@ package user_test import ( - "context" "net" "testing" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "github.com/stretchr/testify/suite" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" ) type CreateTestSuite struct { @@ -32,7 +31,7 @@ type CreateTestSuite struct { func (suite *CreateTestSuite) TestCreateOK() { var ( - ctx = context.Background() + ctx = suite.T().Context() app = suite.testApps["application_1"] appToken = suite.testTokens["local_account_1_client_application_token"] form = &apimodel.AccountCreateRequest{ diff --git a/internal/processing/user/delete.go b/internal/processing/user/delete.go index 9783010ef..8ab70b278 100644 --- a/internal/processing/user/delete.go +++ b/internal/processing/user/delete.go @@ -20,10 +20,10 @@ package user import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/messages" ) // DeleteSelf is like Account.Delete, but specifically diff --git a/internal/processing/user/email.go b/internal/processing/user/email.go index ea9dbb64c..7339b282d 100644 --- a/internal/processing/user/email.go +++ b/internal/processing/user/email.go @@ -23,13 +23,14 @@ import ( "fmt" "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/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/validate" + "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/validate" + "codeberg.org/gruf/go-byteutil" "golang.org/x/crypto/bcrypt" ) @@ -41,7 +42,10 @@ func (p *Processor) EmailChange( newEmail string, ) (*apimodel.User, gtserror.WithCode) { // Ensure provided password is correct. - if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { + if err := bcrypt.CompareHashAndPassword( + byteutil.S2B(user.EncryptedPassword), + byteutil.S2B(password), + ); err != nil { err := gtserror.Newf("%w", err) return nil, gtserror.NewErrorUnauthorized(err, "password was incorrect") } diff --git a/internal/processing/user/email_test.go b/internal/processing/user/email_test.go index 23d448a84..e68fe21d5 100644 --- a/internal/processing/user/email_test.go +++ b/internal/processing/user/email_test.go @@ -18,7 +18,6 @@ package user_test import ( - "context" "testing" "time" @@ -30,7 +29,7 @@ type EmailConfirmTestSuite struct { } func (suite *EmailConfirmTestSuite) TestConfirmEmail() { - ctx := context.Background() + ctx := suite.T().Context() user := suite.testUsers["local_account_1"] @@ -58,7 +57,7 @@ func (suite *EmailConfirmTestSuite) TestConfirmEmail() { } func (suite *EmailConfirmTestSuite) TestConfirmEmailOldToken() { - ctx := context.Background() + ctx := suite.T().Context() user := suite.testUsers["local_account_1"] diff --git a/internal/processing/user/get.go b/internal/processing/user/get.go index 9b19189a8..cbc8c6631 100644 --- a/internal/processing/user/get.go +++ b/internal/processing/user/get.go @@ -20,9 +20,9 @@ package user import ( "context" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "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/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) // Get returns the API model of the given user. diff --git a/internal/processing/user/password.go b/internal/processing/user/password.go index 68bc8ddb5..bcde46b3f 100644 --- a/internal/processing/user/password.go +++ b/internal/processing/user/password.go @@ -20,16 +20,20 @@ package user import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/validate" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/validate" + "codeberg.org/gruf/go-byteutil" "golang.org/x/crypto/bcrypt" ) // PasswordChange processes a password change request for the given user. func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, oldPassword string, newPassword string) gtserror.WithCode { // Ensure provided oldPassword is the correct current password. - if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(oldPassword)); err != nil { + if err := bcrypt.CompareHashAndPassword( + byteutil.S2B(user.EncryptedPassword), + byteutil.S2B(oldPassword), + ); err != nil { err := gtserror.Newf("%w", err) return gtserror.NewErrorUnauthorized(err, "old password was incorrect") } @@ -48,7 +52,7 @@ func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, old // Hash the new password. encryptedPassword, err := bcrypt.GenerateFromPassword( - []byte(newPassword), + byteutil.S2B(newPassword), bcrypt.DefaultCost, ) if err != nil { diff --git a/internal/processing/user/password_test.go b/internal/processing/user/password_test.go index ee30558c6..dfe249058 100644 --- a/internal/processing/user/password_test.go +++ b/internal/processing/user/password_test.go @@ -18,12 +18,12 @@ package user_test import ( - "context" "net/http" "testing" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "codeberg.org/gruf/go-byteutil" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "golang.org/x/crypto/bcrypt" ) @@ -34,55 +34,67 @@ type ChangePasswordTestSuite struct { func (suite *ChangePasswordTestSuite) TestChangePasswordOK() { user := suite.testUsers["local_account_1"] - errWithCode := suite.user.PasswordChange(context.Background(), user, "password", "verygoodnewpassword") + errWithCode := suite.user.PasswordChange(suite.T().Context(), user, "password", "verygoodnewpassword") suite.NoError(errWithCode) - err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte("verygoodnewpassword")) + err := bcrypt.CompareHashAndPassword( + byteutil.S2B(user.EncryptedPassword), + byteutil.S2B("verygoodnewpassword"), + ) suite.NoError(err) // get user from the db again dbUser := >smodel.User{} - err = suite.db.GetByID(context.Background(), user.ID, dbUser) + err = suite.db.GetByID(suite.T().Context(), user.ID, dbUser) suite.NoError(err) // check the password has changed - err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("verygoodnewpassword")) + err = bcrypt.CompareHashAndPassword( + byteutil.S2B(dbUser.EncryptedPassword), + byteutil.S2B("verygoodnewpassword"), + ) suite.NoError(err) } func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() { user := suite.testUsers["local_account_1"] - errWithCode := suite.user.PasswordChange(context.Background(), user, "ooooopsydoooopsy", "verygoodnewpassword") + errWithCode := suite.user.PasswordChange(suite.T().Context(), user, "ooooopsydoooopsy", "verygoodnewpassword") suite.EqualError(errWithCode, "PasswordChange: crypto/bcrypt: hashedPassword is not the hash of the given password") suite.Equal(http.StatusUnauthorized, errWithCode.Code()) suite.Equal("Unauthorized: old password was incorrect", errWithCode.Safe()) // get user from the db again dbUser := >smodel.User{} - err := suite.db.GetByID(context.Background(), user.ID, dbUser) + err := suite.db.GetByID(suite.T().Context(), user.ID, dbUser) suite.NoError(err) // check the password has not changed - err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password")) + err = bcrypt.CompareHashAndPassword( + byteutil.S2B(dbUser.EncryptedPassword), + byteutil.S2B("password"), + ) suite.NoError(err) } func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() { user := suite.testUsers["local_account_1"] - errWithCode := suite.user.PasswordChange(context.Background(), user, "password", "1234") + errWithCode := suite.user.PasswordChange(suite.T().Context(), user, "password", "1234") suite.EqualError(errWithCode, "password is only 11% strength, try including more special characters, using lowercase letters, using uppercase letters or using a longer password") suite.Equal(http.StatusBadRequest, errWithCode.Code()) suite.Equal("Bad Request: password is only 11% strength, try including more special characters, using lowercase letters, using uppercase letters or using a longer password", errWithCode.Safe()) // get user from the db again dbUser := >smodel.User{} - err := suite.db.GetByID(context.Background(), user.ID, dbUser) + err := suite.db.GetByID(suite.T().Context(), user.ID, dbUser) suite.NoError(err) // check the password has not changed - err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password")) + err = bcrypt.CompareHashAndPassword( + byteutil.S2B(dbUser.EncryptedPassword), + byteutil.S2B("password"), + ) suite.NoError(err) } diff --git a/internal/processing/user/twofactor.go b/internal/processing/user/twofactor.go new file mode 100644 index 000000000..cbd986b23 --- /dev/null +++ b/internal/processing/user/twofactor.go @@ -0,0 +1,285 @@ +// 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 user + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base32" + "errors" + "image/png" + "io" + "net/url" + "sort" + "strings" + "time" + + 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/state" + "code.superseriousbusiness.org/gotosocial/internal/util" + "codeberg.org/gruf/go-byteutil" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "golang.org/x/crypto/bcrypt" +) + +var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) + +func (p *Processor) TwoFactorQRCodePngGet( + ctx context.Context, + user *gtsmodel.User, +) (*apimodel.Content, gtserror.WithCode) { + // Get the 2FA url for this user. + totpURI, errWithCode := p.TwoFactorQRCodeURIGet(ctx, user) + if errWithCode != nil { + return nil, errWithCode + } + + key, err := otp.NewKeyFromURL(totpURI.String()) + if err != nil { + err := gtserror.Newf("error creating totp key from url: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Spawn a QR code image from the key. + qr, err := key.Image(256, 256) + if err != nil { + err := gtserror.Newf("error creating qr image from key: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Blat the key into a buffer. + buf := new(bytes.Buffer) + if err := png.Encode(buf, qr); err != nil { + err := gtserror.Newf("error encoding qr image to png: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Return it as our nice content model. + return &apimodel.Content{ + ContentType: "image/png", + ContentLength: int64(buf.Len()), + Content: io.NopCloser(buf), + }, nil +} + +// TwoFactorQRCodeURIGet will generate a new +// 2 factor auth secret for user, and return a +// URI of expected format for generating a QR code +// or inputting into a password manager. +// +// This may be called multiple times without error +// UNTIL the moment the user has finalized enabling +// 2FA. i.e. when user.TwoFactorEnabled() == true. +// Until this point, the URI may be requested for +// both QR code generation, and requesting the URI, +// but once 2FA is confirmed enabled it is not safe +// to re-share the agreed-upon secret. +func (p *Processor) TwoFactorQRCodeURIGet( + ctx context.Context, + user *gtsmodel.User, +) (*url.URL, gtserror.WithCode) { + if user.TwoFactorEnabled() { + const errText = "2fa already enabled; not sharing secret again" + return nil, gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Only generate new 2FA secret + // if not already been generated + // during this enabling process. + if user.TwoFactorSecret == "" { + + // 32 bytes should be plenty entropy. + secret := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, secret); err != nil { + err := gtserror.Newf("error generating new secret: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Set + store the secret. + user.TwoFactorSecret = b32NoPadding.EncodeToString(secret) + if err := p.state.DB.UpdateUser(ctx, user, "two_factor_secret"); err != nil { + err := gtserror.Newf("db error updating user: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + // see: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + issuer := config.GetHost() + " - GoToSocial" + return &url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + issuer + ":" + user.Email, + RawQuery: encodeQuery(url.Values{ + "secret": {user.TwoFactorSecret}, + "issuer": {issuer}, + "period": {"30"}, // 30 seconds totp validity. + "digits": {"6"}, // 6-digit totp. + "algorithm": {"SHA1"}, + }), + }, nil +} + +// TwoFactorEnable will enable 2 factor auth for +// account, using given TOTP code to validate the +// user's 2fa secret before continuing. +func (p *Processor) TwoFactorEnable( + ctx context.Context, + user *gtsmodel.User, + code string, +) ([]string, gtserror.WithCode) { + if user.TwoFactorEnabled() { + const errText = "2fa already enabled; disable it first then try again" + return nil, gtserror.NewErrorConflict(errors.New(errText), errText) + } + + if user.TwoFactorSecret == "" { + const errText = "no 2fa secret stored; first read qr code / totp secret" + return nil, gtserror.NewErrorForbidden(errors.New(errText), errText) + } + + // Try validating the provided code and give + // a helpful error message if it doesn't work. + if !totp.Validate(code, user.TwoFactorSecret) { + const errText = "invalid code provided, you may have been too late, try again; " + + "if it keeps not working, pester your admin to check that the server clock is correct" + return nil, gtserror.NewErrorForbidden(errors.New(errText), errText) + } + + // Valid code was provided so we + // should turn 2fa on for this user. + user.TwoFactorEnabledAt = time.Now() + + // Create recovery codes in cleartext + // to show to the user ONCE ONLY. + backupsClearText := make([]string, 8) + for i := 0; i < 8; i++ { + backupsClearText[i] = util.MustGenerateSecret() + } + + // Store only the bcrypt-encrypted + // versions of the recovery codes. + user.TwoFactorBackups = make([]string, 8) + for i, backup := range backupsClearText { + encryptedBackup, err := bcrypt.GenerateFromPassword( + byteutil.S2B(backup), + bcrypt.DefaultCost, + ) + if err != nil { + err := gtserror.Newf("error encrypting backup codes: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + user.TwoFactorBackups[i] = string(encryptedBackup) + } + + // Update user in the database. + if err := p.state.DB.UpdateUser(ctx, + user, + "two_factor_enabled_at", + "two_factor_backups", + ); err != nil { + err := gtserror.Newf("db error updating user: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return backupsClearText, nil +} + +// TwoFactorDisable: see TwoFactorDisable(). +func (p *Processor) TwoFactorDisable( + ctx context.Context, + user *gtsmodel.User, + password string, +) gtserror.WithCode { + // Ensure provided password is correct. + if err := bcrypt.CompareHashAndPassword( + byteutil.S2B(user.EncryptedPassword), + byteutil.S2B(password), + ); err != nil { + const errText = "incorrect password" + return gtserror.NewErrorUnauthorized(errors.New(errText), errText) + } + + // Disable 2 factor auth for this account. + return TwoFactorDisable(ctx, p.state, user) +} + +// TwoFactorDisable disables 2 factor auth +// for given user account. Note this should +// be gated with password authentication if +// accessed via web. +func TwoFactorDisable( + ctx context.Context, + state *state.State, + user *gtsmodel.User, +) gtserror.WithCode { + if !user.TwoFactorEnabled() { + const errText = "2fa already disabled" + return gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Clear 2FA fields on user account. + user.TwoFactorEnabledAt = time.Time{} + user.TwoFactorSecret = "" + user.TwoFactorBackups = nil + if err := state.DB.UpdateUser(ctx, + user, + "two_factor_enabled_at", + "two_factor_secret", + "two_factor_backups", + ); err != nil { + err := gtserror.Newf("db error updating user: %w", err) + return gtserror.NewErrorInternalError(err) + } + + return nil +} + +// encodeQuery is a copy-paste of url.Values.Encode, except it uses +// %20 instead of + to encode spaces. This is necessary to correctly +// render spaces in some authenticator apps, like Google Authenticator. +// +// [Note: this func and the above comment are both taken +// directly from github.com/pquerna/otp/internal/encode.go.] +func encodeQuery(v url.Values) string { + var buf strings.Builder + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + vs := v[k] + // Changed from url.QueryEscape. + keyEscaped := url.PathEscape(k) + for _, v := range vs { + if buf.Len() > 0 { + buf.WriteByte('&') + } + buf.WriteString(keyEscaped) + buf.WriteByte('=') + // Changed from url.QueryEscape. + buf.WriteString(url.PathEscape(v)) + } + } + return buf.String() +} diff --git a/internal/processing/user/user.go b/internal/processing/user/user.go index 5efb89061..412935fba 100644 --- a/internal/processing/user/user.go +++ b/internal/processing/user/user.go @@ -18,10 +18,10 @@ package user import ( - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/oauth" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { diff --git a/internal/processing/user/user_test.go b/internal/processing/user/user_test.go index 72fd22117..9db26a725 100644 --- a/internal/processing/user/user_test.go +++ b/internal/processing/user/user_test.go @@ -18,16 +18,16 @@ package user_test import ( + "code.superseriousbusiness.org/gotosocial/internal/admin" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/oauth" + "code.superseriousbusiness.org/gotosocial/internal/processing/user" + "code.superseriousbusiness.org/gotosocial/internal/state" + "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/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing/user" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" ) type UserStandardTestSuite struct { @@ -54,7 +54,7 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.db = testrig.NewTestDB(&suite.state) suite.state.DB = suite.db suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers) - suite.oauthServer = testrig.NewTestOauthServer(suite.state.DB) + suite.oauthServer = testrig.NewTestOauthServer(&suite.state) suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) @@ -62,7 +62,7 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.testTokens = testrig.NewTestTokens() suite.testUsers = testrig.NewTestUsers() - suite.user = user.New(&suite.state, typeutils.NewConverter(&suite.state), testrig.NewTestOauthServer(suite.db), suite.emailSender) + suite.user = user.New(&suite.state, typeutils.NewConverter(&suite.state), testrig.NewTestOauthServer(&suite.state), suite.emailSender) testrig.StandardDBSetup(suite.db, nil) } diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go index d6dec6691..7459f0114 100644 --- a/internal/processing/workers/federate.go +++ b/internal/processing/workers/federate.go @@ -21,15 +21,15 @@ import ( "context" "net/url" - "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/activity/streams" + "code.superseriousbusiness.org/activity/streams/vocab" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // federate wraps functions for federating @@ -115,7 +115,7 @@ func (f *federate) DeleteAccount(ctx context.Context, account *gtsmodel.Account) // Address the delete CC public. deleteCC := streams.NewActivityStreamsCcProperty() - deleteCC.AppendIRI(ap.PublicURI()) + deleteCC.AppendIRI(ap.PublicIRI()) delete.SetActivityStreamsCc(deleteCC) // Send the Delete via the Actor's outbox. @@ -491,12 +491,7 @@ func (f *federate) UndoAnnounce(ctx context.Context, boost *gtsmodel.Status) err } // Recreate the ActivityStreams Announce. - asAnnounce, err := f.converter.BoostToAS( - ctx, - boost, - boost.Account, - boost.BoostOfAccount, - ) + asAnnounce, err := f.converter.BoostToAS(ctx, boost) if err != nil { return gtserror.Newf("error converting boost to AS: %w", err) } @@ -767,12 +762,7 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { } // Create the ActivityStreams Announce. - announce, err := f.converter.BoostToAS( - ctx, - boost, - boost.Account, - boost.BoostOfAccount, - ) + announce, err := f.converter.BoostToAS(ctx, boost) if err != nil { return gtserror.Newf("error converting boost to AS: %w", err) } @@ -1104,7 +1094,7 @@ func (f *federate) MoveAccount(ctx context.Context, account *gtsmodel.Account) e ap.AppendTo(move, followersIRI) // Address the move CC public. - ap.AppendCc(move, ap.PublicURI()) + ap.AppendCc(move, ap.PublicIRI()) // Send the Move via the Actor's outbox. if _, err := f.FederatingActor().Send( diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index 28a2b37b9..992f6d9e8 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -22,21 +22,21 @@ import ( "errors" "time" - "codeberg.org/gruf/go-kv" - "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/id" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "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/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/uris" - "github.com/superseriousbusiness/gotosocial/internal/util" + "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/id" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "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/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "code.superseriousbusiness.org/gotosocial/internal/util" + "codeberg.org/gruf/go-kv/v2" ) // clientAPI wraps processing functions @@ -287,7 +287,7 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA // and/or notify the account that's being // interacted with (if it's local): they can // approve or deny the interaction later. - if err := p.utils.requestReply(ctx, status); err != nil { + if err := p.utils.impoliteReplyRequest(ctx, status); err != nil { return gtserror.Newf("error pending reply: %w", err) } @@ -310,19 +310,22 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA // URI attached. // Store an already-accepted interaction request. - id := id.NewULID() + requestID := id.NewULID() approval := >smodel.InteractionRequest{ - ID: id, - StatusID: status.InReplyToID, - TargetAccountID: status.InReplyToAccountID, - TargetAccount: status.InReplyToAccount, - InteractingAccountID: status.AccountID, - InteractingAccount: status.Account, - InteractionURI: status.URI, - InteractionType: gtsmodel.InteractionLike, - Reply: status, - URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id), - AcceptedAt: time.Now(), + ID: requestID, + TargetStatusID: status.InReplyToID, + TargetAccountID: status.InReplyToAccountID, + TargetAccount: status.InReplyToAccount, + InteractingAccountID: status.AccountID, + InteractingAccount: status.Account, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(status.URI, gtsmodel.ReplyRequestSuffix), + InteractionURI: status.URI, + InteractionType: gtsmodel.InteractionReply, + Polite: util.Ptr(false), // TODO: Change this in v0.21.0 when we only send out polite requests. + Reply: status, + ResponseURI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, requestID), + AuthorizationURI: uris.GenerateURIForAuthorization(status.InReplyToAccount.Username, requestID), + AcceptedAt: time.Now(), } if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { return gtserror.Newf("db error putting pre-approved interaction request: %w", err) @@ -331,7 +334,7 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA // Mark the status as now approved. status.PendingApproval = util.Ptr(false) status.PreApproved = false - status.ApprovedByURI = approval.URI + status.ApprovedByURI = approval.AuthorizationURI if err := p.state.DB.UpdateStatus( ctx, status, @@ -348,11 +351,6 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA // Don't return, just continue as normal. } - // Update stats for the actor account. - if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, status); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - // We specifically do not timeline // or notify for backfilled statuses, // as these are more for archival than @@ -371,7 +369,7 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA if status.InReplyToID != "" { // Interaction counts changed on the replied status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + p.surface.invalidateStatusFromTimelines(status.InReplyToID) } return nil @@ -413,7 +411,7 @@ func (p *clientAPI) CreatePollVote(ctx context.Context, cMsg *messages.FromClien } // Interaction counts changed on the source status, uncache from timelines. - p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID) + p.surface.invalidateStatusFromTimelines(vote.Poll.StatusID) return nil } @@ -448,11 +446,6 @@ func (p *clientAPI) CreateFollowReq(ctx context.Context, cMsg *messages.FromClie }) } - // Update stats for the target account. - if err := p.utils.incrementFollowRequestsCount(ctx, cMsg.Target); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - if err := p.surface.notifyFollowRequest(ctx, followRequest); err != nil { log.Errorf(ctx, "error notifying follow request: %v", err) } @@ -494,7 +487,7 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI // and/or notify the account that's being // interacted with (if it's local): they can // approve or deny the interaction later. - if err := p.utils.requestFave(ctx, fave); err != nil { + if err := p.utils.impoliteFaveRequest(ctx, fave); err != nil { return gtserror.Newf("error pending fave: %w", err) } @@ -517,19 +510,22 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI // URI attached. // Store an already-accepted interaction request. - id := id.NewULID() + requestID := id.NewULID() approval := >smodel.InteractionRequest{ - ID: id, - StatusID: fave.StatusID, - TargetAccountID: fave.TargetAccountID, - TargetAccount: fave.TargetAccount, - InteractingAccountID: fave.AccountID, - InteractingAccount: fave.Account, - InteractionURI: fave.URI, - InteractionType: gtsmodel.InteractionLike, - Like: fave, - URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id), - AcceptedAt: time.Now(), + ID: requestID, + TargetStatusID: fave.StatusID, + TargetAccountID: fave.TargetAccountID, + TargetAccount: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(fave.URI, gtsmodel.LikeRequestSuffix), + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + Polite: util.Ptr(false), // TODO: Change this in v0.21.0 when we only send out polite requests. + Like: fave, + ResponseURI: uris.GenerateURIForAccept(fave.TargetAccount.Username, requestID), + AuthorizationURI: uris.GenerateURIForAuthorization(fave.TargetAccount.Username, requestID), + AcceptedAt: time.Now(), } if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { return gtserror.Newf("db error putting pre-approved interaction request: %w", err) @@ -538,7 +534,7 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI // Mark the fave itself as now approved. fave.PendingApproval = util.Ptr(false) fave.PreApproved = false - fave.ApprovedByURI = approval.URI + fave.ApprovedByURI = approval.AuthorizationURI if err := p.state.DB.UpdateStatusFave( ctx, fave, @@ -565,7 +561,7 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI // Interaction counts changed on the faved status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID) + p.surface.invalidateStatusFromTimelines(fave.StatusID) return nil } @@ -589,7 +585,7 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // and/or notify the account that's being // interacted with (if it's local): they can // approve or deny the interaction later. - if err := p.utils.requestAnnounce(ctx, boost); err != nil { + if err := p.utils.impoliteAnnounceRequest(ctx, boost); err != nil { return gtserror.Newf("error pending boost: %w", err) } @@ -612,19 +608,22 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // URI attached. // Store an already-accepted interaction request. - id := id.NewULID() + requestID := id.NewULID() approval := >smodel.InteractionRequest{ - ID: id, - StatusID: boost.BoostOfID, - TargetAccountID: boost.BoostOfAccountID, - TargetAccount: boost.BoostOfAccount, - InteractingAccountID: boost.AccountID, - InteractingAccount: boost.Account, - InteractionURI: boost.URI, - InteractionType: gtsmodel.InteractionLike, - Announce: boost, - URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id), - AcceptedAt: time.Now(), + ID: requestID, + TargetStatusID: boost.BoostOfID, + TargetAccountID: boost.BoostOfAccountID, + TargetAccount: boost.BoostOfAccount, + InteractingAccountID: boost.AccountID, + InteractingAccount: boost.Account, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(boost.URI, gtsmodel.AnnounceRequestSuffix), + InteractionURI: boost.URI, + InteractionType: gtsmodel.InteractionAnnounce, + Polite: util.Ptr(false), // TODO: Change this in v0.21.0 when we only send out polite requests. + Announce: boost, + ResponseURI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, requestID), + AuthorizationURI: uris.GenerateURIForAuthorization(boost.BoostOfAccount.Username, requestID), + AcceptedAt: time.Now(), } if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { return gtserror.Newf("db error putting pre-approved interaction request: %w", err) @@ -633,7 +632,7 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // Mark the boost itself as now approved. boost.PendingApproval = util.Ptr(false) boost.PreApproved = false - boost.ApprovedByURI = approval.URI + boost.ApprovedByURI = approval.AuthorizationURI if err := p.state.DB.UpdateStatus( ctx, boost, @@ -650,11 +649,6 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // Don't return, just continue as normal. } - // Update stats for the actor account. - if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, boost); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - // Timeline and notify the boost wrapper status. if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { log.Errorf(ctx, "error timelining and notifying status: %v", err) @@ -671,7 +665,7 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // Interaction counts changed on the boosted status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + p.surface.invalidateStatusFromTimelines(boost.BoostOfID) return nil } @@ -682,22 +676,20 @@ func (p *clientAPI) CreateBlock(ctx context.Context, cMsg *messages.FromClientAP return gtserror.Newf("%T not parseable as *gtsmodel.Block", cMsg.GTSModel) } - // Remove blockee's statuses from blocker's timeline. - if err := p.state.Timelines.Home.WipeItemsFromAccountID( - ctx, - block.AccountID, - block.TargetAccountID, - ); err != nil { - return gtserror.Newf("error wiping timeline items for block: %w", err) + if block.Account.IsLocal() { + // Remove posts by target from origin's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + block.AccountID, + block.TargetAccountID, + ) } - // Remove blocker's statuses from blockee's timeline. - if err := p.state.Timelines.Home.WipeItemsFromAccountID( - ctx, - block.TargetAccountID, - block.AccountID, - ); err != nil { - return gtserror.Newf("error wiping timeline items for block: %w", err) + if block.TargetAccount.IsLocal() { + // Remove posts by origin from target's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + block.TargetAccountID, + block.AccountID, + ) } // TODO: same with notifications? @@ -731,13 +723,47 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA } } + // Notify any *new* mentions added + // to this status by the editor. + for _, mention := range status.Mentions { + // Check if we've seen + // this mention already. + if !mention.IsNew { + // Already seen + // it, skip. + continue + } + + // Haven't seen this mention + // yet, notify it if necessary. + mention.Status = status + if err := p.surface.notifyMention(ctx, mention); err != nil { + log.Errorf(ctx, "error notifying mention: %v", err) + } + } + + if len(status.EditIDs) > 0 { + // Ensure edits are fully populated for this status before anything. + if err := p.surface.State.DB.PopulateStatusEdits(ctx, status); err != nil { + log.Error(ctx, "error populating updated status edits: %v") + + // Then send notifications of a status edit + // to any local interactors of the status. + } else if err := p.surface.notifyStatusEdit(ctx, + status, + status.Edits[len(status.Edits)-1], // latest + ); err != nil { + log.Errorf(ctx, "error notifying status edit: %v", err) + } + } + // Push message that the status has been edited to streams. if err := p.surface.timelineStatusUpdate(ctx, status); err != nil { log.Errorf(ctx, "error streaming status edit: %v", err) } // Status representation has changed, invalidate from timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.ID) + p.surface.invalidateStatusFromTimelines(status.ID) return nil } @@ -752,6 +778,9 @@ func (p *clientAPI) UpdateAccount(ctx context.Context, cMsg *messages.FromClient log.Errorf(ctx, "error federating account update: %v", err) } + // Account representation has changed, invalidate from timelines. + p.surface.invalidateTimelineEntriesByAccount(account.ID) + return nil } @@ -796,20 +825,6 @@ func (p *clientAPI) AcceptFollow(ctx context.Context, cMsg *messages.FromClientA return gtserror.Newf("%T not parseable as *gtsmodel.Follow", cMsg.GTSModel) } - // Update stats for the target account. - if err := p.utils.decrementFollowRequestsCount(ctx, cMsg.Target); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - - if err := p.utils.incrementFollowersCount(ctx, cMsg.Target); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - - // Update stats for the origin account. - if err := p.utils.incrementFollowingCount(ctx, cMsg.Origin); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - if err := p.surface.notifyFollow(ctx, follow); err != nil { log.Errorf(ctx, "error notifying follow: %v", err) } @@ -827,13 +842,7 @@ func (p *clientAPI) RejectFollowRequest(ctx context.Context, cMsg *messages.From return gtserror.Newf("%T not parseable as *gtsmodel.FollowRequest", cMsg.GTSModel) } - // Update stats for the target account. - if err := p.utils.decrementFollowRequestsCount(ctx, cMsg.Target); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - - if err := p.federate.RejectFollow( - ctx, + if err := p.federate.RejectFollow(ctx, p.converter.FollowRequestToFollow(ctx, followReq), ); err != nil { log.Errorf(ctx, "error federating follow reject: %v", err) @@ -848,14 +857,20 @@ func (p *clientAPI) UndoFollow(ctx context.Context, cMsg *messages.FromClientAPI return gtserror.Newf("%T not parseable as *gtsmodel.Follow", cMsg.GTSModel) } - // Update stats for the origin account. - if err := p.utils.decrementFollowingCount(ctx, cMsg.Origin); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) + if follow.Account.IsLocal() { + // Remove posts by target from origin's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + follow.AccountID, + follow.TargetAccountID, + ) } - // Update stats for the target account. - if err := p.utils.decrementFollowersCount(ctx, cMsg.Target); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) + if follow.TargetAccount.IsLocal() { + // Remove posts by origin from target's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + follow.TargetAccountID, + follow.AccountID, + ) } if err := p.federate.UndoFollow(ctx, follow); err != nil { @@ -890,7 +905,7 @@ func (p *clientAPI) UndoFave(ctx context.Context, cMsg *messages.FromClientAPI) // Interaction counts changed on the faved status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, statusFave.StatusID) + p.surface.invalidateStatusFromTimelines(statusFave.StatusID) return nil } @@ -905,14 +920,8 @@ func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg *messages.FromClientA return gtserror.Newf("db error deleting status: %w", err) } - // Update stats for the origin account. - if err := p.utils.decrementStatusesCount(ctx, cMsg.Origin, status); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - - if err := p.surface.deleteStatusFromTimelines(ctx, status.ID); err != nil { - log.Errorf(ctx, "error removing timelined status: %v", err) - } + // Delete the boost wrapper status from timelines. + p.surface.deleteStatusFromTimelines(ctx, status.ID) if err := p.federate.UndoAnnounce(ctx, status); err != nil { log.Errorf(ctx, "error federating announce undo: %v", err) @@ -920,7 +929,7 @@ func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg *messages.FromClientA // Interaction counts changed on the boosted status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID) + p.surface.invalidateStatusFromTimelines(status.BoostOfID) return nil } @@ -971,11 +980,6 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientA log.Errorf(ctx, "error wiping status: %v", err) } - // Update stats for the origin account. - if err := p.utils.decrementStatusesCount(ctx, cMsg.Origin, status); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - if err := p.federate.DeleteStatus(ctx, status); err != nil { log.Errorf(ctx, "error federating status delete: %v", err) } @@ -983,7 +987,7 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientA if status.InReplyToID != "" { // Interaction counts changed on the replied status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + p.surface.invalidateStatusFromTimelines(status.InReplyToID) } return nil @@ -1026,11 +1030,30 @@ func (p *clientAPI) DeleteAccountOrUser(ctx context.Context, cMsg *messages.From p.state.Workers.Federator.Queue.Delete("Receiving.ID", account.ID) p.state.Workers.Federator.Queue.Delete("TargetURI", account.URI) + // Remove any entries authored by account from timelines. + p.surface.removeTimelineEntriesByAccount(account.ID) + + // Remove any of their cached timelines. + p.state.Caches.Timelines.Home.Delete(account.ID) + + // Get the IDs of all the lists owned by the given account ID. + listIDs, err := p.state.DB.GetListIDsByAccountID(ctx, account.ID) + if err != nil { + log.Errorf(ctx, "error getting lists for account %s: %v", account.ID, err) + } + + // Remove list timelines of account. + for _, listID := range listIDs { + p.state.Caches.Timelines.List.Delete(listID) + } + + // Federate out a delete activity targeting account to remote servers. if err := p.federate.DeleteAccount(ctx, cMsg.Target); err != nil { log.Errorf(ctx, "error federating account delete: %v", err) } - if err := p.account.Delete(ctx, cMsg.Target, originID); err != nil { + // And finally, perform the actual account deletion synchronously. + if err := p.account.Delete(ctx, account, originID); err != nil { log.Errorf(ctx, "error deleting account: %v", err) } @@ -1169,7 +1192,7 @@ func (p *clientAPI) AcceptLike(ctx context.Context, cMsg *messages.FromClientAPI // Interaction counts changed on the faved status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, req.Like.StatusID) + p.surface.invalidateStatusFromTimelines(req.Like.StatusID) return nil } @@ -1180,15 +1203,7 @@ func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAP return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) } - var ( - interactingAcct = req.InteractingAccount - reply = req.Reply - ) - - // Update stats for the reply author account. - if err := p.utils.incrementStatusesCount(ctx, interactingAcct, reply); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } + reply := req.Reply // Timeline the reply + notify relevant accounts. if err := p.surface.timelineAndNotifyStatus(ctx, reply); err != nil { @@ -1202,7 +1217,7 @@ func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAP // Interaction counts changed on the replied status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, reply.InReplyToID) + p.surface.invalidateStatusFromTimelines(reply.InReplyToID) return nil } @@ -1213,15 +1228,7 @@ func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClien return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) } - var ( - interactingAcct = req.InteractingAccount - boost = req.Announce - ) - - // Update stats for the boost author account. - if err := p.utils.incrementStatusesCount(ctx, interactingAcct, boost); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } + boost := req.Announce // Timeline and notify the announce. if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { @@ -1240,7 +1247,7 @@ func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClien // Interaction counts changed on the original status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + p.surface.invalidateStatusFromTimelines(boost.BoostOfID) return nil } diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index 1d70eb96c..5967d4d34 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -24,19 +24,18 @@ import ( "testing" "time" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/testrig" ) type FromClientAPITestSuite struct { @@ -89,6 +88,7 @@ func (suite *FromClientAPITestSuite) newStatus( OriginAccountID: account.ID, OriginAccountURI: account.URI, TargetAccountID: replyToStatus.AccountID, + IsNew: true, } if err := state.DB.PutMention(ctx, mention); err != nil { @@ -117,6 +117,7 @@ func (suite *FromClientAPITestSuite) newStatus( TargetAccountID: mentionedAccount.ID, TargetAccount: mentionedAccount, Silent: util.Ptr(false), + IsNew: true, } newStatus.Mentions = append(newStatus.Mentions, newMention) @@ -156,7 +157,7 @@ func (suite *FromClientAPITestSuite) checkStreamed( ) { // Set a 5s timeout on context. - ctx := context.Background() + ctx := suite.T().Context() ctx, cncl := context.WithTimeout(ctx, time.Second*5) defer cncl() @@ -211,9 +212,6 @@ func (suite *FromClientAPITestSuite) statusJSON( ctx, status, requestingAccount, - statusfilter.FilterContextNone, - nil, - nil, ) if err != nil { suite.FailNow(err.Error()) @@ -237,8 +235,6 @@ func (suite *FromClientAPITestSuite) conversationJSON( ctx, conversation, requestingAccount, - nil, - nil, ) if err != nil { suite.FailNow(err.Error()) @@ -257,7 +253,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] testList = suite.testLists["local_account_1_list_1"] @@ -266,9 +262,10 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { receivingAccount, []string{testList.ID}, ) - homeStream = streams[stream.TimelineHome] - listStream = streams[stream.TimelineList+":"+testList.ID] - notifStream = streams[stream.TimelineNotifications] + publicStream = streams[stream.TimelinePublic] + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + notifStream = streams[stream.TimelineNotifications] // Admin account posts a new top-level status. status = suite.newStatus( @@ -314,6 +311,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { receivingAccount, ) + // Check message in public stream. + suite.checkStreamed( + publicStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) + // Check message in home stream. suite.checkStreamed( homeStream, @@ -346,7 +351,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { suite.FailNow("timed out waiting for new status notification") } - apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil) + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif) if err != nil { suite.FailNow(err.Error()) } @@ -374,7 +379,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBackfilledStatusWithNotifi defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] testList = suite.testLists["local_account_1_list_1"] @@ -383,9 +388,10 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBackfilledStatusWithNotifi receivingAccount, []string{testList.ID}, ) - homeStream = streams[stream.TimelineHome] - listStream = streams[stream.TimelineList+":"+testList.ID] - notifStream = streams[stream.TimelineNotifications] + publicStream = streams[stream.TimelinePublic] + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + notifStream = streams[stream.TimelineNotifications] // Admin account posts a new top-level status. status = suite.newStatus( @@ -424,6 +430,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBackfilledStatusWithNotifi suite.FailNow(err.Error()) } + // There should be no message in public stream. + suite.checkStreamed( + publicStream, + false, + "", + "", + ) + // There should be no message in the home stream. suite.checkStreamed( homeStream, @@ -473,7 +487,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBackfilledStatusWithRemote defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["local_account_1"] receivingAccount = suite.testAccounts["remote_account_1"] @@ -529,11 +543,12 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] testList = suite.testLists["local_account_1_list_1"] streams = suite.openStreams(ctx, testStructs.Processor, receivingAccount, []string{testList.ID}) + publicStream = streams[stream.TimelinePublic] homeStream = streams[stream.TimelineHome] listStream = streams[stream.TimelineList+":"+testList.ID] @@ -575,6 +590,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { receivingAccount, ) + // Check message *not* in public stream. + suite.checkStreamed( + publicStream, + false, + "", + "", + ) + // Check message in home stream. suite.checkStreamed( homeStream, @@ -600,7 +623,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] @@ -664,7 +687,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] @@ -732,10 +755,11 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis *testList = *suite.testLists["local_account_1_list_1"] var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] streams = suite.openStreams(ctx, testStructs.Processor, receivingAccount, []string{testList.ID}) + publicStream = streams[stream.TimelinePublic] homeStream = streams[stream.TimelineHome] listStream = streams[stream.TimelineList+":"+testList.ID] @@ -782,6 +806,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis receivingAccount, ) + // Check message *not* in public stream. + suite.checkStreamed( + publicStream, + false, + "", + "", + ) + // Check message in home stream. suite.checkStreamed( homeStream, @@ -811,10 +843,11 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis *testList = *suite.testLists["local_account_1_list_1"] var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] streams = suite.openStreams(ctx, testStructs.Processor, receivingAccount, []string{testList.ID}) + publicStream = streams[stream.TimelinePublic] homeStream = streams[stream.TimelineHome] listStream = streams[stream.TimelineList+":"+testList.ID] @@ -867,6 +900,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis receivingAccount, ) + // Check message *not* in public stream. + suite.checkStreamed( + publicStream, + false, + "", + "", + ) + // Check message in home stream. suite.checkStreamed( homeStream, @@ -896,10 +937,11 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli *testList = *suite.testLists["local_account_1_list_1"] var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] streams = suite.openStreams(ctx, testStructs.Processor, receivingAccount, []string{testList.ID}) + publicStream = streams[stream.TimelinePublic] homeStream = streams[stream.TimelineHome] listStream = streams[stream.TimelineList+":"+testList.ID] @@ -946,6 +988,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli receivingAccount, ) + // Check message *not* in public stream. + suite.checkStreamed( + publicStream, + false, + "", + "", + ) + // Check message in home stream. suite.checkStreamed( homeStream, @@ -971,11 +1021,12 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] testList = suite.testLists["local_account_1_list_1"] streams = suite.openStreams(ctx, testStructs.Processor, receivingAccount, []string{testList.ID}) + publicStream = streams[stream.TimelinePublic] homeStream = streams[stream.TimelineHome] listStream = streams[stream.TimelineList+":"+testList.ID] @@ -1013,6 +1064,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { receivingAccount, ) + // Check message *not* in public stream. + suite.checkStreamed( + publicStream, + false, + "", + "", + ) + // Check message in home stream. suite.checkStreamed( homeStream, @@ -1038,11 +1097,12 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() { defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_1"] testList = suite.testLists["local_account_1_list_1"] streams = suite.openStreams(ctx, testStructs.Processor, receivingAccount, []string{testList.ID}) + publicStream = streams[stream.TimelinePublic] homeStream = streams[stream.TimelineHome] listStream = streams[stream.TimelineList+":"+testList.ID] @@ -1082,6 +1142,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() { suite.FailNow(err.Error()) } + // Check message *not* in public stream. + suite.checkStreamed( + publicStream, + false, + "", + "", + ) + // Check message NOT in home stream. suite.checkStreamed( homeStream, @@ -1105,7 +1173,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["local_account_2"] receivingAccount = suite.testAccounts["local_account_1"] streams = suite.openStreams(ctx, @@ -1194,7 +1262,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["local_account_2"] receivingAccount = suite.testAccounts["local_account_1"] streams = suite.openStreams(ctx, @@ -1267,7 +1335,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag( defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_2"] streams = suite.openStreams(ctx, @@ -1344,7 +1412,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagA defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["remote_account_1"] receivingAccount = suite.testAccounts["local_account_2"] streams = suite.openStreams(ctx, @@ -1428,7 +1496,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag() defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["remote_account_2"] boostingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_2"] @@ -1534,7 +1602,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["remote_account_1"] boostingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_2"] @@ -1647,7 +1715,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] boostingAccount = suite.testAccounts["remote_account_1"] receivingAccount = suite.testAccounts["local_account_2"] @@ -1758,7 +1826,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["local_account_2"] receivingAccount = suite.testAccounts["local_account_1"] testList = suite.testLists["local_account_1_list_1"] @@ -1767,8 +1835,9 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv receivingAccount, []string{testList.ID}, ) - homeStream = streams[stream.TimelineHome] - listStream = streams[stream.TimelineList+":"+testList.ID] + publicStream = streams[stream.TimelinePublic] + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] // postingAccount posts a new public status not mentioning anyone. status = suite.newStatus( @@ -1806,6 +1875,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv suite.FailNow(err.Error()) } + // Check status in public stream. + suite.checkStreamed( + publicStream, + true, + "", + stream.EventTypeUpdate, + ) + // Check status in list stream. suite.checkStreamed( listStream, @@ -1834,7 +1911,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["local_account_2"] receivingAccount = suite.testAccounts["local_account_1"] testInclusiveList = suite.testLists["local_account_1_list_1"] @@ -1861,6 +1938,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv testExclusiveList.ID, }, ) + publicStream = streams[stream.TimelinePublic] homeStream = streams[stream.TimelineHome] inclusiveListStream = streams[stream.TimelineList+":"+testInclusiveList.ID] exclusiveListStream = streams[stream.TimelineList+":"+testExclusiveList.ID] @@ -1915,6 +1993,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv suite.FailNow(err.Error()) } + // Check status in public stream. + suite.checkStreamed( + publicStream, + true, + "", + stream.EventTypeUpdate, + ) + // Check status in inclusive list stream. suite.checkStreamed( inclusiveListStream, @@ -1951,7 +2037,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["local_account_2"] receivingAccount = suite.testAccounts["local_account_1"] testFollow = suite.testFollows["local_account_1_local_account_2"] @@ -1961,9 +2047,10 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv receivingAccount, []string{testList.ID}, ) - homeStream = streams[stream.TimelineHome] - listStream = streams[stream.TimelineList+":"+testList.ID] - notifStream = streams[stream.TimelineNotifications] + publicStream = streams[stream.TimelinePublic] + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + notifStream = streams[stream.TimelineNotifications] // postingAccount posts a new public status not mentioning anyone. status = suite.newStatus( @@ -2009,6 +2096,14 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv suite.FailNow(err.Error()) } + // Check status in public stream. + suite.checkStreamed( + publicStream, + true, + "", + stream.EventTypeUpdate, + ) + // Check status in list stream. suite.checkStreamed( listStream, @@ -2033,7 +2128,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv suite.FailNow("timed out waiting for new status notification") } - apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil) + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif) if err != nil { suite.FailNow(err.Error()) } @@ -2078,7 +2173,7 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag( defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() postingAccount = suite.testAccounts["admin_account"] receivingAccount = suite.testAccounts["local_account_2"] streams = suite.openStreams(ctx, @@ -2147,12 +2242,102 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag( suite.checkNotWebPushed(testStructs.WebPushSender, receivingAccount.ID) } +// Test that when someone edits a status that's been interacted with, +// the interacter gets a notification that the status has been edited. +func (suite *FromClientAPITestSuite) TestProcessUpdateStatusInteractedWith() { + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) + + var ( + ctx = suite.T().Context() + postingAccount = suite.testAccounts["local_account_1"] + receivingAccount = suite.testAccounts["admin_account"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + notifStream = streams[stream.TimelineNotifications] + ) + + // Copy the test status. + // + // This is one that the receiving account + // has interacted with (by replying). + testStatus := new(gtsmodel.Status) + *testStatus = *suite.testStatuses["local_account_1_status_1"] + + // Create + store an edit. + edit := >smodel.StatusEdit{ + // Just set the ID + status ID, other + // fields don't matter for this test. + ID: "01JTR74W15VS6A6MK15N5JVJ55", + StatusID: testStatus.ID, + } + + if err := testStructs.State.DB.PutStatusEdit(ctx, edit); err != nil { + suite.FailNow(err.Error()) + } + + // Set edit on status as + // it would be for real. + testStatus.EditIDs = []string{edit.ID} + testStatus.Edits = []*gtsmodel.StatusEdit{edit} + + // Update the status. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityUpdate, + GTSModel: testStatus, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Wait for a notification to appear for the status. + var notif *gtsmodel.Notification + if !testrig.WaitFor(func() bool { + var err error + notif, err = testStructs.State.DB.GetNotification( + ctx, + gtsmodel.NotificationUpdate, + receivingAccount.ID, + postingAccount.ID, + edit.ID, + ) + return err == nil + }) { + suite.FailNow("timed out waiting for edited status notification") + } + + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif) + if err != nil { + suite.FailNow(err.Error()) + } + + notifJSON, err := json.Marshal(apiNotif) + if err != nil { + suite.FailNow(err.Error()) + } + + // Check notif in stream. + suite.checkStreamed( + notifStream, + true, + string(notifJSON), + stream.EventTypeNotification, + ) +} + func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) defer testrig.TearDownTestStructs(testStructs) var ( - ctx = context.Background() + ctx = suite.T().Context() deletingAccount = suite.testAccounts["local_account_1"] receivingAccount = suite.testAccounts["local_account_2"] deletedStatus = suite.testStatuses["local_account_1_status_1"] diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 2e513449b..b64b8bbec 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -23,22 +23,22 @@ import ( "net/url" "time" - "codeberg.org/gruf/go-kv" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/uris" - - "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/processing/account" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/federation/dereferencing" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/uris" + "codeberg.org/gruf/go-kv/v2" + + "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/processing/account" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // fediAPI wraps processing functions @@ -88,6 +88,10 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF case ap.ObjectNote: return p.fediAPI.CreateStatus(ctx, fMsg) + // REQUEST TO REPLY TO A STATUS + case ap.ActivityReplyRequest: + return p.fediAPI.CreateReplyRequest(ctx, fMsg) + // CREATE FOLLOW (request) case ap.ActivityFollow: return p.fediAPI.CreateFollowReq(ctx, fMsg) @@ -96,10 +100,18 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF case ap.ActivityLike: return p.fediAPI.CreateLike(ctx, fMsg) + // REQUEST TO LIKE A STATUS + case ap.ActivityLikeRequest: + return p.fediAPI.CreateLikeRequest(ctx, fMsg) + // CREATE ANNOUNCE/BOOST case ap.ActivityAnnounce: return p.fediAPI.CreateAnnounce(ctx, fMsg) + // REQUEST TO BOOST A STATUS + case ap.ActivityAnnounceRequest: + return p.fediAPI.CreateAnnounceRequest(ctx, fMsg) + // CREATE BLOCK case ap.ActivityBlock: return p.fediAPI.CreateBlock(ctx, fMsg) @@ -146,11 +158,15 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF case ap.ObjectNote: return p.fediAPI.AcceptReply(ctx, fMsg) + // ACCEPT (pending) POLITE REPLY REQUEST + case ap.ActivityReplyRequest: + return p.fediAPI.AcceptPoliteReplyRequest(ctx, fMsg) + // ACCEPT (pending) ANNOUNCE case ap.ActivityAnnounce: return p.fediAPI.AcceptAnnounce(ctx, fMsg) - // ACCEPT (remote) REPLY or ANNOUNCE + // ACCEPT (remote) IMPOLITE REPLY or ANNOUNCE case ap.ObjectUnknown: return p.fediAPI.AcceptRemoteStatus(ctx, fMsg) } @@ -197,15 +213,31 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF // UNDO SOMETHING case ap.ActivityUndo: + switch fMsg.APObjectType { + // UNDO FOLLOW + case ap.ActivityFollow: + return p.fediAPI.UndoFollow(ctx, fMsg) + + // UNDO BLOCK + case ap.ActivityBlock: + return p.fediAPI.UndoBlock(ctx, fMsg) + // UNDO ANNOUNCE - if fMsg.APObjectType == ap.ActivityAnnounce { + case ap.ActivityAnnounce: return p.fediAPI.UndoAnnounce(ctx, fMsg) + + // UNDO LIKE + case ap.ActivityLike: + return p.fediAPI.UndoFave(ctx, fMsg) } } return gtserror.Newf("unhandled: %s %s", fMsg.APActivityType, fMsg.APObjectType) } +// CreateStatus handles the creation of a status/post sent as a Create message. +// It is also capable of handling impolite reply requests to local + remote statuses, +// ie., replies sent directly without doing the ReplyRequest process first. func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) error { var ( status *gtsmodel.Status @@ -278,7 +310,7 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // preapproved, then just notify the account // that's being interacted with: they can // approve or deny the interaction later. - if err := p.utils.requestReply(ctx, status); err != nil { + if err := p.utils.impoliteReplyRequest(ctx, status); err != nil { return gtserror.Newf("error pending reply: %w", err) } @@ -293,20 +325,24 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // collection. Do the Accept immediately and // then process everything else as normal. - // Store an already-accepted interaction request. - id := id.NewULID() + // Store an already-accepted + // impolite interaction request. + requestID := id.NewULID() approval := >smodel.InteractionRequest{ - ID: id, - StatusID: status.InReplyToID, - TargetAccountID: status.InReplyToAccountID, - TargetAccount: status.InReplyToAccount, - InteractingAccountID: status.AccountID, - InteractingAccount: status.Account, - InteractionURI: status.URI, - InteractionType: gtsmodel.InteractionLike, - Reply: status, - URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id), - AcceptedAt: time.Now(), + ID: requestID, + TargetStatusID: status.InReplyToID, + TargetAccountID: status.InReplyToAccountID, + TargetAccount: status.InReplyToAccount, + InteractingAccountID: status.AccountID, + InteractingAccount: status.Account, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(status.URI, gtsmodel.ReplyRequestSuffix), + InteractionURI: status.URI, + InteractionType: gtsmodel.InteractionReply, + Polite: util.Ptr(false), + Reply: status, + ResponseURI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, requestID), + AuthorizationURI: uris.GenerateURIForAuthorization(status.InReplyToAccount.Username, requestID), + AcceptedAt: time.Now(), } if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { return gtserror.Newf("db error putting pre-approved interaction request: %w", err) @@ -315,7 +351,7 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // Mark the status as now approved. status.PendingApproval = util.Ptr(false) status.PreApproved = false - status.ApprovedByURI = approval.URI + status.ApprovedByURI = approval.AuthorizationURI if err := p.state.DB.UpdateStatus( ctx, status, @@ -333,11 +369,6 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // Don't return, just continue as normal. } - // Update stats for the remote account. - if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, status); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { log.Errorf(ctx, "error timelining and notifying status: %v", err) } @@ -346,12 +377,119 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // Interaction counts changed on the replied status; uncache the // prepared version from all timelines. The status dereferencer // functions will ensure necessary ancestors exist before this point. - p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + p.surface.invalidateStatusFromTimelines(status.InReplyToID) } return nil } +// CreateReplyRequest handles a polite ReplyRequest. +// This is distinct from CreateStatus, which is capable +// of handling both "normal" top-level status creation, +// in addition to *impolite* reply requests. +func (p *fediAPI) CreateReplyRequest(ctx context.Context, fMsg *messages.FromFediAPI) error { + // Extract the ap model Statusable + // set by the federating db. + statusable, ok := fMsg.APObject.(ap.Statusable) + if !ok { + return gtserror.Newf("cannot cast %T -> ap.Statusable", fMsg.APObject) + } + + // Call RefreshStatus to parse and process the + // statusable. This will also check permissions. + replyURI := ap.GetJSONLDId(statusable).String() + reply, _, err := p.federate.RefreshStatus(ctx, + fMsg.Receiving.Username, + >smodel.Status{ + URI: replyURI, + Local: util.Ptr(false), + }, + statusable, + // Force refresh within 5min window. + dereferencing.Fresh, + ) + + switch { + case err == nil: + // All fine. + + case gtserror.IsNotPermitted(err): + // Reply is straight up not permitted by + // the interaction policy of the status + // it's replying to. Nothing more to do. + log.Debugf(ctx, + "dropping unpermitted ReplyRequest with instrument %s", + replyURI, + ) + return nil + + default: + // There's some real error. + return gtserror.Newf( + "error processing ReplyRequest with instrument %s: %w", + replyURI, err, + ) + } + + // The reply is permitted. Check if we + // should send out an Accept immediately. + manualApproval := *reply.PendingApproval && !reply.PreApproved + if manualApproval { + // The reply requires manual approval. + // + // Just notify target account about + // the requested interaction. + if err := p.surface.notifyPendingReply(ctx, reply); err != nil { + return gtserror.Newf("error notifying pending reply: %w", err) + } + + return nil + } + + // The reply is automatically approved, + // handle side effects of this. + req, ok := fMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", fMsg.GTSModel) + } + + // Mark the request as accepted. + req.AcceptedAt = time.Now() + req.ResponseURI = uris.GenerateURIForAccept( + req.TargetAccount.Username, req.ID, + ) + req.AuthorizationURI = uris.GenerateURIForAuthorization( + req.TargetAccount.Username, req.ID, + ) + + // Update in the db. + if err := p.state.DB.UpdateInteractionRequest( + ctx, + req, + "accepted_at", + "response_uri", + "authorization_uri", + ); err != nil { + return gtserror.Newf("db error updating interaction request: %w", err) + } + + // Send out the accept. + if err := p.federate.AcceptInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating accept: %v", err) + } + + // Timeline the reply + notify recipient(s). + if err := p.surface.timelineAndNotifyStatus(ctx, reply); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Interaction counts changed on the replied status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(reply.InReplyToID) + + return nil +} + func (p *fediAPI) CreatePollVote(ctx context.Context, fMsg *messages.FromFediAPI) error { // Cast poll vote type from the worker message. vote, ok := fMsg.GTSModel.(*gtsmodel.PollVote) @@ -393,7 +531,7 @@ func (p *fediAPI) CreatePollVote(ctx context.Context, fMsg *messages.FromFediAPI } // Interaction counts changed, uncache from timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.ID) + p.surface.invalidateStatusFromTimelines(status.ID) return nil } @@ -417,18 +555,18 @@ func (p *fediAPI) UpdatePollVote(ctx context.Context, fMsg *messages.FromFediAPI } // Get the origin status. - status := vote.Poll.Status + reply := vote.Poll.Status - if *status.Local { + if *reply.Local { // These were poll votes in a local status, we need to // federate the updated status model with latest vote counts. - if err := p.federate.UpdateStatus(ctx, status); err != nil { + if err := p.federate.UpdateStatus(ctx, reply); err != nil { log.Errorf(ctx, "error federating status update: %v", err) } } // Interaction counts changed, uncache from timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.ID) + p.surface.invalidateStatusFromTimelines(reply.ID) return nil } @@ -449,11 +587,6 @@ func (p *fediAPI) CreateFollowReq(ctx context.Context, fMsg *messages.FromFediAP log.Errorf(ctx, "error notifying follow request: %v", err) } - // And update stats for the local account. - if err := p.utils.incrementFollowRequestsCount(ctx, fMsg.Receiving); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - return nil } @@ -469,16 +602,6 @@ func (p *fediAPI) CreateFollowReq(ctx context.Context, fMsg *messages.FromFediAP return gtserror.Newf("error accepting follow request: %w", err) } - // Update stats for the local account. - if err := p.utils.incrementFollowersCount(ctx, fMsg.Receiving); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - - // Update stats for the remote account. - if err := p.utils.incrementFollowingCount(ctx, fMsg.Requesting); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - if err := p.federate.AcceptFollow(ctx, follow); err != nil { log.Errorf(ctx, "error federating follow request accept: %v", err) } @@ -490,6 +613,8 @@ func (p *fediAPI) CreateFollowReq(ctx context.Context, fMsg *messages.FromFediAP return nil } +// CreateLike handles an impolite Like, ie., a Like sent directly. +// This is different from the CreateLikeRequest function, which handles polite LikeRequests. func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) error { fave, ok := fMsg.GTSModel.(*gtsmodel.StatusFave) if !ok { @@ -512,7 +637,7 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er // preapproved, then just notify the account // that's being interacted with: they can // approve or deny the interaction later. - if err := p.utils.requestFave(ctx, fave); err != nil { + if err := p.utils.impoliteFaveRequest(ctx, fave); err != nil { return gtserror.Newf("error pending fave: %w", err) } @@ -527,20 +652,24 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er // collection. Do the Accept immediately and // then process everything else as normal. - // Store an already-accepted interaction request. - id := id.NewULID() + // Store an already-accepted + // impolite interaction request. + requestID := id.NewULID() approval := >smodel.InteractionRequest{ - ID: id, - StatusID: fave.StatusID, - TargetAccountID: fave.TargetAccountID, - TargetAccount: fave.TargetAccount, - InteractingAccountID: fave.AccountID, - InteractingAccount: fave.Account, - InteractionURI: fave.URI, - InteractionType: gtsmodel.InteractionLike, - Like: fave, - URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id), - AcceptedAt: time.Now(), + ID: requestID, + TargetStatusID: fave.StatusID, + TargetAccountID: fave.TargetAccountID, + TargetAccount: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(fave.URI, gtsmodel.LikeRequestSuffix), + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + Polite: util.Ptr(false), + Like: fave, + ResponseURI: uris.GenerateURIForAccept(fave.TargetAccount.Username, requestID), + AuthorizationURI: uris.GenerateURIForAuthorization(fave.TargetAccount.Username, requestID), + AcceptedAt: time.Now(), } if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { return gtserror.Newf("db error putting pre-approved interaction request: %w", err) @@ -549,7 +678,7 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er // Mark the fave itself as now approved. fave.PendingApproval = util.Ptr(false) fave.PreApproved = false - fave.ApprovedByURI = approval.URI + fave.ApprovedByURI = approval.AuthorizationURI if err := p.state.DB.UpdateStatusFave( ctx, fave, @@ -573,7 +702,88 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er // Interaction counts changed on the faved status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID) + p.surface.invalidateStatusFromTimelines(fave.StatusID) + + return nil +} + +// CreateLikeRequest handles a polite LikeRequest, as +// opposed to CreateLike, which handles *impolite* like +// requests (ie., Likes sent directly). +func (p *fediAPI) CreateLikeRequest(ctx context.Context, fMsg *messages.FromFediAPI) error { + req, ok := fMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", fMsg.GTSModel) + } + + // At this point the not-yet-approved + // interaction request, and the pending + // fave, are both in the database. + + if !req.Like.PreApproved { + // The fave is *not* pre-approved, and + // therefore requires manual approval. + // + // Just notify target account about + // the requested interaction. + if err := p.surface.notifyPendingFave(ctx, req.Like); err != nil { + return gtserror.Newf("error notifying pending like: %w", err) + } + + return nil + } + + // If it's pre-approved on the other hand + // we can handle everything immediately. + + // Mark the request as accepted. + req.AcceptedAt = time.Now() + req.ResponseURI = uris.GenerateURIForAccept( + req.TargetAccount.Username, req.ID, + ) + req.AuthorizationURI = uris.GenerateURIForAuthorization( + req.TargetAccount.Username, req.ID, + ) + + // Update in the db. + if err := p.state.DB.UpdateInteractionRequest( + ctx, + req, + "accepted_at", + "response_uri", + "authorization_uri", + ); err != nil { + return gtserror.Newf("db error updating interaction request: %w", err) + } + + // Send out the accept. + if err := p.federate.AcceptInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating accept: %v", err) + } + + // Mark the fave as approved. + req.Like.PendingApproval = util.Ptr(false) + req.Like.ApprovedByURI = req.AuthorizationURI + req.Like.PreApproved = false + + // Update in the db. + if err := p.state.DB.UpdateStatusFave( + ctx, + req.Like, + "pending_approval", + "approved_by_uri", + ); err != nil { + return gtserror.Newf("db error updating status fave: %w", err) + } + + // Notify the faved account. + if err := p.surface.notifyFave(ctx, req.Like); err != nil { + log.Errorf(ctx, "error notifying fave: %v", err) + } + + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(req.Like.StatusID) return nil } @@ -597,7 +807,7 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI ) if err != nil { if gtserror.IsUnretrievable(err) || - gtserror.NotPermitted(err) { + gtserror.IsNotPermitted(err) { // Boosted status domain blocked, or // otherwise not permitted, nothing to do. log.Debugf(ctx, "skipping announce: %v", err) @@ -619,7 +829,7 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // preapproved, then just notify the account // that's being interacted with: they can // approve or deny the interaction later. - if err := p.utils.requestAnnounce(ctx, boost); err != nil { + if err := p.utils.impoliteAnnounceRequest(ctx, boost); err != nil { return gtserror.Newf("error pending boost: %w", err) } @@ -634,20 +844,24 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // collection. Do the Accept immediately and // then process everything else as normal. - // Store an already-accepted interaction request. - id := id.NewULID() + // Store an already-accepted + // impolite interaction request. + requestID := id.NewULID() approval := >smodel.InteractionRequest{ - ID: id, - StatusID: boost.BoostOfID, - TargetAccountID: boost.BoostOfAccountID, - TargetAccount: boost.BoostOfAccount, - InteractingAccountID: boost.AccountID, - InteractingAccount: boost.Account, - InteractionURI: boost.URI, - InteractionType: gtsmodel.InteractionLike, - Announce: boost, - URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id), - AcceptedAt: time.Now(), + ID: requestID, + TargetStatusID: boost.BoostOfID, + TargetAccountID: boost.BoostOfAccountID, + TargetAccount: boost.BoostOfAccount, + InteractingAccountID: boost.AccountID, + InteractingAccount: boost.Account, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(boost.URI, gtsmodel.AnnounceRequestSuffix), + InteractionURI: boost.URI, + InteractionType: gtsmodel.InteractionAnnounce, + Polite: util.Ptr(false), + Announce: boost, + ResponseURI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, requestID), + AuthorizationURI: uris.GenerateURIForAuthorization(boost.BoostOfAccount.Username, requestID), + AcceptedAt: time.Now(), } if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { return gtserror.Newf("db error putting pre-approved interaction request: %w", err) @@ -656,7 +870,7 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // Mark the boost itself as now approved. boost.PendingApproval = util.Ptr(false) boost.PreApproved = false - boost.ApprovedByURI = approval.URI + boost.ApprovedByURI = approval.AuthorizationURI if err := p.state.DB.UpdateStatus( ctx, boost, @@ -674,11 +888,6 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // Don't return, just continue as normal. } - // Update stats for the remote account. - if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, boost); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - // Timeline and notify the announce. if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { log.Errorf(ctx, "error timelining and notifying status: %v", err) @@ -690,64 +899,135 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // Interaction counts changed on the original status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + p.surface.invalidateStatusFromTimelines(boost.BoostOfID) return nil } -func (p *fediAPI) CreateBlock(ctx context.Context, fMsg *messages.FromFediAPI) error { - block, ok := fMsg.GTSModel.(*gtsmodel.Block) +func (p *fediAPI) CreateAnnounceRequest(ctx context.Context, fMsg *messages.FromFediAPI) error { + req, ok := fMsg.GTSModel.(*gtsmodel.InteractionRequest) if !ok { - return gtserror.Newf("%T not parseable as *gtsmodel.Block", fMsg.GTSModel) + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", fMsg.GTSModel) } - // Remove each account's posts from the other's timelines. + // At this point the not-yet-handled interaction req + // is in the database, but the announce isn't yet. // - // First home timelines. - if err := p.state.Timelines.Home.WipeItemsFromAccountID( + // We can check permissions for the announce *and* + // put it in the db (if acceptable) by doing Enrich. + boost, err := p.federate.EnrichAnnounce( ctx, - block.AccountID, - block.TargetAccountID, - ); err != nil { - log.Errorf(ctx, "error wiping items from block -> target's home timeline: %v", err) + req.Announce, + fMsg.Receiving.Username, + ) + + switch { + case err == nil: + // All fine. + + case gtserror.IsNotPermitted(err): + // Announce is straight up not permitted + // by the interaction policy of the status + // it's targeting. Nothing more to do. + log.Debugf(ctx, + "dropping unpermitted AnnounceRequest with instrument %s", + req.Announce.URI, + ) + return nil + + default: + // There's some real error. + return gtserror.Newf( + "error processing AnnounceRequest with instrument %s: %w", + req.Announce.URI, err, + ) } - if err := p.state.Timelines.Home.WipeItemsFromAccountID( - ctx, - block.TargetAccountID, - block.AccountID, - ); err != nil { - log.Errorf(ctx, "error wiping items from target -> block's home timeline: %v", err) + // The announce is permitted. Check if we + // should send out an Accept immediately. + manualApproval := *boost.PendingApproval && !boost.PreApproved + if manualApproval { + // The announce requires manual approval. + // + // Just notify target account about + // the requested interaction. + if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil { + return gtserror.Newf("error notifying pending announce: %w", err) + } + + return nil } - // Now list timelines. - if err := p.state.Timelines.List.WipeItemsFromAccountID( + // The announce is automatically approved, + // mark the request as accepted. + req.AcceptedAt = time.Now() + req.ResponseURI = uris.GenerateURIForAccept( + req.TargetAccount.Username, req.ID, + ) + req.AuthorizationURI = uris.GenerateURIForAuthorization( + req.TargetAccount.Username, req.ID, + ) + + // Update in the db. + if err := p.state.DB.UpdateInteractionRequest( ctx, - block.AccountID, - block.TargetAccountID, + req, + "accepted_at", + "response_uri", + "authorization_uri", ); err != nil { - log.Errorf(ctx, "error wiping items from block -> target's list timeline(s): %v", err) + return gtserror.Newf("db error updating interaction request: %w", err) } - if err := p.state.Timelines.List.WipeItemsFromAccountID( - ctx, - block.TargetAccountID, - block.AccountID, - ); err != nil { - log.Errorf(ctx, "error wiping items from target -> block's list timeline(s): %v", err) + // Send out the accept. + if err := p.federate.AcceptInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating accept: %v", err) + } + + // Timeline the boost + notify recipient(s). + if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(boost.BoostOfID) + + return nil +} + +func (p *fediAPI) CreateBlock(ctx context.Context, fMsg *messages.FromFediAPI) error { + block, ok := fMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Block", fMsg.GTSModel) + } + + if block.Account.IsLocal() { + // Remove posts by target from origin's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + block.AccountID, + block.TargetAccountID, + ) + } + + if block.TargetAccount.IsLocal() { + // Remove posts by origin from target's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + block.TargetAccountID, + block.AccountID, + ) } // Remove any follows that existed between blocker + blockee. - if err := p.state.DB.DeleteFollow( - ctx, + // (note this handles removing any necessary list entries). + if err := p.state.DB.DeleteFollow(ctx, block.AccountID, block.TargetAccountID, ); err != nil { log.Errorf(ctx, "error deleting follow from block -> target: %v", err) } - if err := p.state.DB.DeleteFollow( - ctx, + if err := p.state.DB.DeleteFollow(ctx, block.TargetAccountID, block.AccountID, ); err != nil { @@ -755,16 +1035,14 @@ func (p *fediAPI) CreateBlock(ctx context.Context, fMsg *messages.FromFediAPI) e } // Remove any follow requests that existed between blocker + blockee. - if err := p.state.DB.DeleteFollowRequest( - ctx, + if err := p.state.DB.DeleteFollowRequest(ctx, block.AccountID, block.TargetAccountID, ); err != nil { log.Errorf(ctx, "error deleting follow request from block -> target: %v", err) } - if err := p.state.DB.DeleteFollowRequest( - ctx, + if err := p.state.DB.DeleteFollowRequest(ctx, block.TargetAccountID, block.AccountID, ); err != nil { @@ -821,24 +1099,13 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg *messages.FromFediAPI) log.Errorf(ctx, "error refreshing account: %v", err) } + // Account representation has changed, invalidate from timelines. + p.surface.invalidateTimelineEntriesByAccount(account.ID) + return nil } func (p *fediAPI) AcceptFollow(ctx context.Context, fMsg *messages.FromFediAPI) error { - // Update stats for the remote account. - if err := p.utils.decrementFollowRequestsCount(ctx, fMsg.Requesting); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - - if err := p.utils.incrementFollowersCount(ctx, fMsg.Requesting); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - - // Update stats for the local account. - if err := p.utils.incrementFollowingCount(ctx, fMsg.Receiving); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - return nil } @@ -849,29 +1116,24 @@ func (p *fediAPI) AcceptLike(ctx context.Context, fMsg *messages.FromFediAPI) er } func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) error { - status, ok := fMsg.GTSModel.(*gtsmodel.Status) + reply, ok := fMsg.GTSModel.(*gtsmodel.Status) if !ok { return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) } - // Update stats for the actor account. - if err := p.utils.incrementStatusesCount(ctx, status.Account, status); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - // Timeline and notify the status. - if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + if err := p.surface.timelineAndNotifyStatus(ctx, reply); err != nil { log.Errorf(ctx, "error timelining and notifying status: %v", err) } // Send out the reply again, fully this time. - if err := p.federate.CreateStatus(ctx, status); err != nil { + if err := p.federate.CreateStatus(ctx, reply); err != nil { log.Errorf(ctx, "error federating announce: %v", err) } // Interaction counts changed on the replied-to status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + p.surface.invalidateStatusFromTimelines(reply.InReplyToID) return nil } @@ -900,9 +1162,9 @@ func (p *fediAPI) AcceptRemoteStatus(ctx context.Context, fMsg *messages.FromFed // barebones status and insert it into the database, // if indeed it's actually a status URI we can fetch. // - // This will also check whether the given AcceptIRI + // This will also check whether the given approvedByURI // actually grants permission for this status. - status, _, err := p.federate.RefreshStatus(ctx, + reply, _, err := p.federate.RefreshStatus(ctx, fMsg.Receiving.Username, bareStatus, nil, nil, @@ -913,20 +1175,65 @@ func (p *fediAPI) AcceptRemoteStatus(ctx context.Context, fMsg *messages.FromFed // No error means it was indeed a remote status, and the // given approvedByURI permitted it. Timeline and notify it. - if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + if err := p.surface.timelineAndNotifyStatus(ctx, reply); err != nil { log.Errorf(ctx, "error timelining and notifying status: %v", err) } // Interaction counts changed on the interacted status; // uncache the prepared version from all timelines. - if status.InReplyToID != "" { - p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + if reply.InReplyToID != "" { + p.surface.invalidateStatusFromTimelines(reply.InReplyToID) + } + + if reply.BoostOfID != "" { + p.surface.invalidateStatusFromTimelines(reply.BoostOfID) + } + + return nil +} + +func (p *fediAPI) AcceptPoliteReplyRequest(ctx context.Context, fMsg *messages.FromFediAPI) error { + if util.IsNil(fMsg.GTSModel) { + // If the interaction request is nil, this + // must be an accept of a remote ReplyRequest + // not targeting one of our statuses. + // + // Just pass it to the AcceptRemoteStatus + // func to do dereferencing + side effects. + log.Debug(ctx, "accepting remote ReplyRequest for remote reply") + return p.AcceptRemoteStatus(ctx, fMsg) + } + + // If the interaction request is not nil, this will + // be an accept of one of our replies to a remote. + // + // Since the int req + reply have already been updated + // in the federatingDB, we just need to do side effects. + intReq, ok := fMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", fMsg.GTSModel) + } + + // Ensure reply populated. + reply := intReq.Reply + if err := p.state.DB.PopulateStatus(ctx, reply); err != nil { + return gtserror.Newf("error populating status: %w", err) + } + + // Timeline and notify the status. + if err := p.surface.timelineAndNotifyStatus(ctx, reply); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) } - if status.BoostOfID != "" { - p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID) + // Send out the reply with approval attached. + if err := p.federate.CreateStatus(ctx, reply); err != nil { + log.Errorf(ctx, "error federating announce: %v", err) } + // Interaction counts changed on the replied-to status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(reply.InReplyToID) + return nil } @@ -936,11 +1243,6 @@ func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) } - // Update stats for the actor account. - if err := p.utils.incrementStatusesCount(ctx, boost.Account, boost); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - // Timeline and notify the boost wrapper status. if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { log.Errorf(ctx, "error timelining and notifying status: %v", err) @@ -953,7 +1255,7 @@ func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // Interaction counts changed on the boosted status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + p.surface.invalidateStatusFromTimelines(boost.BoostOfID) return nil } @@ -998,13 +1300,47 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) } } + // Notify any *new* mentions added + // to this status by the editor. + for _, mention := range status.Mentions { + // Check if we've seen + // this mention already. + if !mention.IsNew { + // Already seen + // it, skip. + continue + } + + // Haven't seen this mention + // yet, notify it if necessary. + mention.Status = status + if err := p.surface.notifyMention(ctx, mention); err != nil { + log.Errorf(ctx, "error notifying mention: %v", err) + } + } + + if len(status.EditIDs) > 0 { + // Ensure edits are fully populated for this status before anything. + if err := p.surface.State.DB.PopulateStatusEdits(ctx, status); err != nil { + log.Error(ctx, "error populating updated status edits: %v") + + // Then send notifications of a status edit + // to any local interactors of the status. + } else if err := p.surface.notifyStatusEdit(ctx, + status, + status.Edits[len(status.Edits)-1], // latest + ); err != nil { + log.Errorf(ctx, "error notifying status edit: %v", err) + } + } + // Push message that the status has been edited to streams. if err := p.surface.timelineStatusUpdate(ctx, status); err != nil { log.Errorf(ctx, "error streaming status edit: %v", err) } - // Status representation was refetched, uncache from timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.ID) + // Status representation changed, uncache from timelines. + p.surface.invalidateStatusFromTimelines(status.ID) return nil } @@ -1055,15 +1391,10 @@ func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg *messages.FromFediAPI) log.Errorf(ctx, "error wiping status: %v", err) } - // Update stats for the remote account. - if err := p.utils.decrementStatusesCount(ctx, fMsg.Requesting, status); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - if status.InReplyToID != "" { // Interaction counts changed on the replied status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + p.surface.invalidateStatusFromTimelines(status.InReplyToID) } return nil @@ -1090,7 +1421,10 @@ func (p *fediAPI) DeleteAccount(ctx context.Context, fMsg *messages.FromFediAPI) p.state.Workers.Federator.Queue.Delete("Requesting.ID", account.ID) p.state.Workers.Federator.Queue.Delete("TargetURI", account.URI) - // First perform the actual account deletion. + // Remove any entries authored by account from timelines. + p.surface.removeTimelineEntriesByAccount(account.ID) + + // And finally, perform the actual account deletion synchronously. if err := p.account.Delete(ctx, account, account.ID); err != nil { log.Errorf(ctx, "error deleting account: %v", err) } @@ -1139,7 +1473,7 @@ func (p *fediAPI) RejectReply(ctx context.Context, fMsg *messages.FromFediAPI) e // be in the database, we just need to do side effects. // Get the rejected status. - status, err := p.state.DB.GetStatusByURI( + reply, err := p.state.DB.GetStatusByURI( gtscontext.SetBarebones(ctx), req.InteractionURI, ) @@ -1159,7 +1493,7 @@ func (p *fediAPI) RejectReply(ctx context.Context, fMsg *messages.FromFediAPI) e // Perform the actual status deletion. if err := p.utils.wipeStatus( ctx, - status, + reply, deleteAttachments, copyToSinBin, ); err != nil { @@ -1208,6 +1542,42 @@ func (p *fediAPI) RejectAnnounce(ctx context.Context, fMsg *messages.FromFediAPI return nil } +func (p *fediAPI) UndoFollow(ctx context.Context, fMsg *messages.FromFediAPI) error { + follow, ok := fMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Follow", fMsg.GTSModel) + } + + if follow.Account.IsLocal() { + // Remove posts by target from origin's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + follow.AccountID, + follow.TargetAccountID, + ) + } + + if follow.TargetAccount.IsLocal() { + // Remove posts by origin from target's timelines. + p.surface.removeRelationshipFromTimelines(ctx, + follow.TargetAccountID, + follow.AccountID, + ) + } + + return nil +} + +func (p *fediAPI) UndoBlock(ctx context.Context, fMsg *messages.FromFediAPI) error { + _, ok := fMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Block", fMsg.GTSModel) + } + + // TODO: any required changes + + return nil +} + func (p *fediAPI) UndoAnnounce( ctx context.Context, fMsg *messages.FromFediAPI, @@ -1222,19 +1592,25 @@ func (p *fediAPI) UndoAnnounce( return gtserror.Newf("db error deleting boost: %w", err) } - // Update statuses count for the requesting account. - if err := p.utils.decrementStatusesCount(ctx, fMsg.Requesting, boost); err != nil { - log.Errorf(ctx, "error updating account stats: %v", err) - } - // Remove the boost wrapper from all timelines. - if err := p.surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil { - log.Errorf(ctx, "error removing timelined boost: %v", err) - } + p.surface.deleteStatusFromTimelines(ctx, boost.ID) // Interaction counts changed on the boosted status; // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + p.surface.invalidateStatusFromTimelines(boost.BoostOfID) + + return nil +} + +func (p *fediAPI) UndoFave(ctx context.Context, fMsg *messages.FromFediAPI) error { + statusFave, ok := fMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", fMsg.GTSModel) + } + + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(statusFave.StatusID) return nil } diff --git a/internal/processing/workers/fromfediapi_move.go b/internal/processing/workers/fromfediapi_move.go index d1e43c0c7..93e7b39a4 100644 --- a/internal/processing/workers/fromfediapi_move.go +++ b/internal/processing/workers/fromfediapi_move.go @@ -22,14 +22,14 @@ import ( "errors" "time" - "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/log" - "github.com/superseriousbusiness/gotosocial/internal/messages" + "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/log" + "code.superseriousbusiness.org/gotosocial/internal/messages" ) // ShouldProcessMove checks whether we should attempt @@ -269,9 +269,8 @@ func (p *fediAPI) MoveAccount(ctx context.Context, fMsg *messages.FromFediAPI) e // try to send the same Move several times with // different IDs (you never know), but we only // want to process them based on origin + target. - unlock := p.state.FedLocks.Lock( - "move:" + originAcctURIStr + ":" + targetAcctURIStr, - ) + key := "move:" + originAcctURIStr + ":" + targetAcctURIStr + unlock := p.state.FedLocks.Lock(key) defer unlock() // Check if Move is rate limited based @@ -303,10 +302,13 @@ func (p *fediAPI) MoveAccount(ctx context.Context, fMsg *messages.FromFediAPI) e } // Account to which the Move is taking place. + // + // Match by uri only. targetAcct, targetAcctable, err := p.federate.GetAccountByURI( ctx, fMsg.Receiving.Username, targetAcctURI, + false, ) if err != nil { return gtserror.Newf( diff --git a/internal/processing/workers/fromfediapi_test.go b/internal/processing/workers/fromfediapi_test.go index f3e719890..790c78b70 100644 --- a/internal/processing/workers/fromfediapi_test.go +++ b/internal/processing/workers/fromfediapi_test.go @@ -18,6 +18,7 @@ package workers_test import ( + "bytes" "context" "encoding/json" "errors" @@ -26,16 +27,18 @@ import ( "testing" "time" + "code.superseriousbusiness.org/activity/streams/vocab" + "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/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "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/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/testrig" ) type FromFediAPITestSuite struct { @@ -62,7 +65,7 @@ func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() { announceStatus.Account = boostingAccount announceStatus.Visibility = boostedStatus.Visibility - err := testStructs.Processor.Workers().ProcessFromFediAPI(context.Background(), &messages.FromFediAPI{ + err := testStructs.Processor.Workers().ProcessFromFediAPI(suite.T().Context(), &messages.FromFediAPI{ APObjectType: ap.ActivityAnnounce, APActivityType: ap.ActivityCreate, GTSModel: announceStatus, @@ -81,7 +84,7 @@ func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() { } _, err = testStructs.State.DB.GetStatusByID( - context.Background(), + suite.T().Context(), announceStatus.ID, ) return err == nil @@ -97,12 +100,12 @@ func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() { }, } notif := >smodel.Notification{} - err = testStructs.State.DB.GetWhere(context.Background(), where, notif) + err = testStructs.State.DB.GetWhere(suite.T().Context(), where, notif) suite.NoError(err) suite.Equal(gtsmodel.NotificationReblog, notif.NotificationType) suite.Equal(boostedStatus.AccountID, notif.TargetAccountID) suite.Equal(announceStatus.AccountID, notif.OriginAccountID) - suite.Equal(announceStatus.ID, notif.StatusID) + suite.Equal(announceStatus.ID, notif.StatusOrEditID) suite.False(*notif.Read) } @@ -125,7 +128,7 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() { replyingAccount.FetchedAt = time.Now() replyingAccount.SuspendedAt = time.Time{} replyingAccount.SuspensionOrigin = "" - err := testStructs.State.DB.UpdateAccount(context.Background(), + err := testStructs.State.DB.UpdateAccount(suite.T().Context(), replyingAccount, "fetched_at", "suspended_at", @@ -139,11 +142,11 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() { ap.AppendInReplyTo(replyingStatusable, testrig.URLMustParse(repliedStatus.URI)) // Open a websocket stream to later test the streamed status reply. - wssStream, errWithCode := testStructs.Processor.Stream().Open(context.Background(), repliedAccount, stream.TimelineHome) + wssStream, errWithCode := testStructs.Processor.Stream().Open(suite.T().Context(), repliedAccount, stream.TimelineHome) suite.NoError(errWithCode) // Send the replied status off to the fedi worker to be further processed. - err = testStructs.Processor.Workers().ProcessFromFediAPI(context.Background(), &messages.FromFediAPI{ + err = testStructs.Processor.Workers().ProcessFromFediAPI(suite.T().Context(), &messages.FromFediAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityCreate, APObject: replyingStatusable, @@ -158,7 +161,7 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() { // 1. status should be in the database var replyingStatus *gtsmodel.Status if !testrig.WaitFor(func() bool { - replyingStatus, err = testStructs.State.DB.GetStatusByURI(context.Background(), replyingURI) + replyingStatus, err = testStructs.State.DB.GetStatusByURI(suite.T().Context(), replyingURI) return err == nil }) { suite.FailNow("timed out waiting for replying status to be in the database") @@ -166,17 +169,17 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() { // 2. a notification should exist for the mention var notif gtsmodel.Notification - err = testStructs.State.DB.GetWhere(context.Background(), []db.Where{ + err = testStructs.State.DB.GetWhere(suite.T().Context(), []db.Where{ {Key: "status_id", Value: replyingStatus.ID}, }, ¬if) suite.NoError(err) suite.Equal(gtsmodel.NotificationMention, notif.NotificationType) suite.Equal(replyingStatus.InReplyToAccountID, notif.TargetAccountID) suite.Equal(replyingStatus.AccountID, notif.OriginAccountID) - suite.Equal(replyingStatus.ID, notif.StatusID) + suite.Equal(replyingStatus.ID, notif.StatusOrEditID) suite.False(*notif.Read) - ctx, _ := context.WithTimeout(context.Background(), time.Second*5) + ctx, _ := context.WithTimeout(suite.T().Context(), time.Second*5) msg, ok := wssStream.Recv(ctx) suite.True(ok) @@ -198,7 +201,7 @@ func (suite *FromFediAPITestSuite) TestProcessFave() { favedStatus := suite.testStatuses["local_account_1_status_1"] favingAccount := suite.testAccounts["remote_account_1"] - wssStream, errWithCode := testStructs.Processor.Stream().Open(context.Background(), favedAccount, stream.TimelineNotifications) + wssStream, errWithCode := testStructs.Processor.Stream().Open(suite.T().Context(), favedAccount, stream.TimelineNotifications) suite.NoError(errWithCode) fave := >smodel.StatusFave{ @@ -214,10 +217,10 @@ func (suite *FromFediAPITestSuite) TestProcessFave() { URI: favingAccount.URI + "/faves/aaaaaaaaaaaa", } - err := testStructs.State.DB.Put(context.Background(), fave) + err := testStructs.State.DB.Put(suite.T().Context(), fave) suite.NoError(err) - err = testStructs.Processor.Workers().ProcessFromFediAPI(context.Background(), &messages.FromFediAPI{ + err = testStructs.Processor.Workers().ProcessFromFediAPI(suite.T().Context(), &messages.FromFediAPI{ APObjectType: ap.ActivityLike, APActivityType: ap.ActivityCreate, GTSModel: fave, @@ -240,15 +243,15 @@ func (suite *FromFediAPITestSuite) TestProcessFave() { } notif := >smodel.Notification{} - err = testStructs.State.DB.GetWhere(context.Background(), where, notif) + err = testStructs.State.DB.GetWhere(suite.T().Context(), where, notif) suite.NoError(err) suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType) suite.Equal(fave.TargetAccountID, notif.TargetAccountID) suite.Equal(fave.AccountID, notif.OriginAccountID) - suite.Equal(fave.StatusID, notif.StatusID) + suite.Equal(fave.StatusID, notif.StatusOrEditID) suite.False(*notif.Read) - ctx, _ := context.WithTimeout(context.Background(), time.Second*5) + ctx, _ := context.WithTimeout(suite.T().Context(), time.Second*5) msg, ok := wssStream.Recv(ctx) suite.True(ok) @@ -271,7 +274,7 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount( favedStatus := suite.testStatuses["local_account_1_status_1"] favingAccount := suite.testAccounts["remote_account_1"] - wssStream, errWithCode := testStructs.Processor.Stream().Open(context.Background(), receivingAccount, stream.TimelineHome) + wssStream, errWithCode := testStructs.Processor.Stream().Open(suite.T().Context(), receivingAccount, stream.TimelineHome) suite.NoError(errWithCode) fave := >smodel.StatusFave{ @@ -287,10 +290,10 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount( URI: favingAccount.URI + "/faves/aaaaaaaaaaaa", } - err := testStructs.State.DB.Put(context.Background(), fave) + err := testStructs.State.DB.Put(suite.T().Context(), fave) suite.NoError(err) - err = testStructs.Processor.Workers().ProcessFromFediAPI(context.Background(), &messages.FromFediAPI{ + err = testStructs.Processor.Workers().ProcessFromFediAPI(suite.T().Context(), &messages.FromFediAPI{ APObjectType: ap.ActivityLike, APActivityType: ap.ActivityCreate, GTSModel: fave, @@ -313,16 +316,16 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount( } notif := >smodel.Notification{} - err = testStructs.State.DB.GetWhere(context.Background(), where, notif) + err = testStructs.State.DB.GetWhere(suite.T().Context(), where, notif) suite.NoError(err) suite.Equal(gtsmodel.NotificationFavourite, notif.NotificationType) suite.Equal(fave.TargetAccountID, notif.TargetAccountID) suite.Equal(fave.AccountID, notif.OriginAccountID) - suite.Equal(fave.StatusID, notif.StatusID) + suite.Equal(fave.StatusID, notif.StatusOrEditID) suite.False(*notif.Read) // 2. no notification should be streamed to the account that received the fave message, because they weren't the target - ctx, _ := context.WithTimeout(context.Background(), time.Second*5) + ctx, _ := context.WithTimeout(suite.T().Context(), time.Second*5) _, ok := wssStream.Recv(ctx) suite.False(ok) } @@ -331,7 +334,7 @@ func (suite *FromFediAPITestSuite) TestProcessAccountDelete() { testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) defer testrig.TearDownTestStructs(testStructs) - ctx := context.Background() + ctx := suite.T().Context() deletedAccount := >smodel.Account{} *deletedAccount = *suite.testAccounts["remote_account_1"] @@ -425,14 +428,14 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() { testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) defer testrig.TearDownTestStructs(testStructs) - ctx := context.Background() + ctx := suite.T().Context() originAccount := suite.testAccounts["remote_account_1"] // target is a locked account targetAccount := suite.testAccounts["local_account_2"] - wssStream, errWithCode := testStructs.Processor.Stream().Open(context.Background(), targetAccount, stream.TimelineHome) + wssStream, errWithCode := testStructs.Processor.Stream().Open(suite.T().Context(), targetAccount, stream.TimelineHome) suite.NoError(errWithCode) // put the follow request in the database as though it had passed through the federating db already @@ -462,7 +465,7 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() { suite.NoError(err) ctx, _ = context.WithTimeout(ctx, time.Second*5) - msg, ok := wssStream.Recv(context.Background()) + msg, ok := wssStream.Recv(suite.T().Context()) suite.True(ok) suite.Equal(stream.EventTypeNotification, msg.Event) @@ -482,14 +485,14 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() { testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) defer testrig.TearDownTestStructs(testStructs) - ctx := context.Background() + ctx := suite.T().Context() originAccount := suite.testAccounts["remote_account_1"] // target is an unlocked account targetAccount := suite.testAccounts["local_account_1"] - wssStream, errWithCode := testStructs.Processor.Stream().Open(context.Background(), targetAccount, stream.TimelineHome) + wssStream, errWithCode := testStructs.Processor.Stream().Open(suite.T().Context(), targetAccount, stream.TimelineHome) suite.NoError(errWithCode) // put the follow request in the database as though it had passed through the federating db already @@ -565,7 +568,7 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() { suite.Equal("Accept", accept.Type) ctx, _ = context.WithTimeout(ctx, time.Second*5) - msg, ok := wssStream.Recv(context.Background()) + msg, ok := wssStream.Recv(suite.T().Context()) suite.True(ok) suite.Equal(stream.EventTypeNotification, msg.Event) @@ -583,7 +586,7 @@ func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() { testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) defer testrig.TearDownTestStructs(testStructs) - ctx := context.Background() + ctx := suite.T().Context() receivingAccount := suite.testAccounts["local_account_1"] statusCreator := suite.testAccounts["remote_account_2"] @@ -599,7 +602,7 @@ func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() { suite.NoError(err) // status should now be in the database, attributed to remote_account_2 - s, err := testStructs.State.DB.GetStatusByURI(context.Background(), "http://example.org/users/Some_User/statuses/afaba698-5740-4e32-a702-af61aa543bc1") + s, err := testStructs.State.DB.GetStatusByURI(suite.T().Context(), "http://example.org/users/Some_User/statuses/afaba698-5740-4e32-a702-af61aa543bc1") suite.NoError(err) suite.Equal(statusCreator.URI, s.AccountURI) } @@ -609,7 +612,7 @@ func (suite *FromFediAPITestSuite) TestMoveAccount() { defer testrig.TearDownTestStructs(testStructs) // We're gonna migrate foss_satan to our local admin account. - ctx := context.Background() + ctx := suite.T().Context() receivingAcct := suite.testAccounts["local_account_1"] // Copy requesting and target accounts @@ -683,7 +686,7 @@ func (suite *FromFediAPITestSuite) TestMoveAccount() { func (suite *FromFediAPITestSuite) TestUndoAnnounce() { var ( - ctx = context.Background() + ctx = suite.T().Context() testStructs = testrig.SetupTestStructs(rMediaPath, rTemplatePath) requestingAcct = suite.testAccounts["remote_account_1"] receivingAcct = suite.testAccounts["local_account_1"] @@ -735,6 +738,169 @@ func (suite *FromFediAPITestSuite) TestUndoAnnounce() { } } +func (suite *FromFediAPITestSuite) TestUpdateNote() { + var ( + ctx = suite.T().Context() + testStructs = testrig.SetupTestStructs(rMediaPath, rTemplatePath) + requestingAcct = suite.testAccounts["remote_account_2"] + receivingAcct = suite.testAccounts["local_account_1"] + ) + defer testrig.TearDownTestStructs(testStructs) + + update := testrig.NewTestActivities(suite.testAccounts)["remote_account_2_status_1_update"] + statusable := update.Activity.GetActivityStreamsObject().At(0).GetActivityStreamsNote() + noteURI := ap.GetJSONLDId(statusable) + + // Get the OG status. + status, err := testStructs.State.DB.GetStatusByURI(ctx, noteURI.String()) + if err != nil { + suite.FailNow(err.Error()) + } + + // Process the Update. + err = testStructs.Processor.Workers().ProcessFromFediAPI(ctx, &messages.FromFediAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityUpdate, + GTSModel: status, // original status + APObject: (ap.Statusable)(statusable), + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + suite.NoError(err) + + // Wait for side effects to trigger: + // zork should have a mention notif. + if !testrig.WaitFor(func() bool { + _, err := testStructs.State.DB.GetNotification( + gtscontext.SetBarebones(ctx), + gtsmodel.NotificationMention, + receivingAcct.ID, + requestingAcct.ID, + status.ID, + ) + return err == nil + }) { + suite.FailNow("timed out waiting for mention notif") + } +} + +func (suite *FromFediAPITestSuite) TestCreateReplyRequest() { + var ( + ctx = suite.T().Context() + testStructs = testrig.SetupTestStructs(rMediaPath, rTemplatePath) + requesting = suite.testAccounts["remote_account_1"] + receiving = suite.testAccounts["admin_account"] + testStatus = suite.testStatuses["admin_account_status_1"] + intReqURI = "http://fossbros-anonymous.io/requests/87fb1478-ac46-406a-8463-96ce05645219" + intURI = "http://fossbros-anonymous.io/users/foss_satan/statuses/87fb1478-ac46-406a-8463-96ce05645219" + jsonStr = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://gotosocial.org/ns", + { + "sensitive": "as:sensitive" + } + ], + "type": "ReplyRequest", + "id": "` + intReqURI + `", + "actor": "` + requesting.URI + `", + "object": "` + testStatus.URI + `", + "to": "` + receiving.URI + `", + "instrument": { + "attributedTo": "` + requesting.URI + `", + "cc": "` + requesting.FollowersURI + `", + "content": "\u003cp\u003ethis is a reply!\u003c/p\u003e", + "id": "` + intURI + `", + "inReplyTo": "` + testStatus.URI + `", + "tag": { + "href": "` + receiving.URI + `", + "name": "@` + receiving.Username + `@localhost:8080", + "type": "Mention" + }, + "to": "https://www.w3.org/ns/activitystreams#Public", + "type": "Note" + } +}` + ) + defer testrig.TearDownTestStructs(testStructs) + + suite.T().Logf("testing reply request:\n\n%s", jsonStr) + + // Decode the reply request + embedded statusable. + t, err := ap.DecodeType(ctx, io.NopCloser(bytes.NewBufferString(jsonStr))) + if err != nil { + suite.FailNow(err.Error()) + } + replyReq := t.(vocab.GoToSocialReplyRequest) + statusable := replyReq.GetActivityStreamsInstrument().At(0).GetActivityStreamsNote().(ap.Statusable) + + // Create a pending interaction request in the + // database, as though the reply req had already + // passed through the federatingdb function. + intReq := >smodel.InteractionRequest{ + ID: id.NewULID(), + TargetStatusID: testStatus.ID, + TargetStatus: testStatus, + TargetAccountID: receiving.ID, + TargetAccount: receiving, + InteractingAccountID: requesting.ID, + InteractingAccount: requesting, + InteractionRequestURI: intReqURI, + InteractionURI: ap.GetJSONLDId(statusable).String(), + InteractionType: gtsmodel.InteractionReply, + Polite: util.Ptr(true), + Reply: nil, // Not settable yet. + } + if err := testStructs.State.DB.PutInteractionRequest(ctx, intReq); err != nil { + suite.FailNow(err.Error()) + } + + // Process the message. + if err = testStructs.Processor.Workers().ProcessFromFediAPI( + ctx, + &messages.FromFediAPI{ + APObjectType: ap.ActivityReplyRequest, + APActivityType: ap.ActivityCreate, + GTSModel: intReq, + APObject: statusable, + Receiving: receiving, + Requesting: requesting, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // The interaction request should be accepted. + intReq, err = testStructs.State.DB.GetInteractionRequestByID(ctx, intReq.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.WithinDuration(time.Now(), intReq.AcceptedAt, 1*time.Minute) + suite.NotEmpty(intReq.AuthorizationURI) + suite.NotEmpty(intReq.ResponseURI) + + // Federator should send out an Accept that looks something like: + // + // { + // "@context": [ + // "https://gotosocial.org/ns", + // "https://www.w3.org/ns/activitystreams" + // ], + // "actor": "http://localhost:8080/users/admin", + // "id": "http://localhost:8080/users/admin/accepts/01K2CV90660VRPZM39R35NMSG9", + // "object": { + // "actor": "http://fossbros-anonymous.io/users/foss_satan", + // "id": "http://fossbros-anonymous.io/requests/87fb1478-ac46-406a-8463-96ce05645219", + // "instrument": "http://fossbros-anonymous.io/users/foss_satan/statuses/87fb1478-ac46-406a-8463-96ce05645219", + // "object": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + // "type": "ReplyRequest" + // }, + // "result": "http://localhost:8080/users/admin/authorizations/01K2CV90660VRPZM39R35NMSG9", + // "to": "http://fossbros-anonymous.io/users/foss_satan", + // "type": "Accept" + // } +} + func TestFromFederatorTestSuite(t *testing.T) { suite.Run(t, &FromFediAPITestSuite{}) } diff --git a/internal/processing/workers/surface.go b/internal/processing/workers/surface.go index 4dc58c433..e0e441479 100644 --- a/internal/processing/workers/surface.go +++ b/internal/processing/workers/surface.go @@ -18,13 +18,15 @@ package workers import ( - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/processing/conversations" - "github.com/superseriousbusiness/gotosocial/internal/processing/stream" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/webpush" + "code.superseriousbusiness.org/gotosocial/internal/email" + "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/processing/conversations" + "code.superseriousbusiness.org/gotosocial/internal/processing/stream" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/webpush" ) // Surface wraps functions for 'surfacing' the result @@ -38,6 +40,8 @@ type Surface struct { Converter *typeutils.Converter Stream *stream.Processor VisFilter *visibility.Filter + MuteFilter *mutes.Filter + StatusFilter *status.Filter EmailSender email.Sender WebPushSender webpush.Sender Conversations *conversations.Processor diff --git a/internal/processing/workers/surfaceemail.go b/internal/processing/workers/surfaceemail.go index 56f33eaf3..219f395ad 100644 --- a/internal/processing/workers/surfaceemail.go +++ b/internal/processing/workers/surfaceemail.go @@ -22,13 +22,13 @@ import ( "errors" "time" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/uris" "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/uris" ) // emailUserReportClosed emails the user who created the diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index fdbd5e3c1..15ad79b26 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -22,14 +22,14 @@ import ( "errors" "strings" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" - "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/util" + 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/util" + "code.superseriousbusiness.org/gotosocial/internal/util/xslices" ) // notifyPendingReply notifies the account replied-to @@ -58,8 +58,7 @@ func (s *Surface) notifyPendingReply( // Ensure thread not muted // by replied-to account. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, status.ThreadID, status.InReplyToAccountID, ) @@ -80,7 +79,8 @@ func (s *Surface) notifyPendingReply( gtsmodel.NotificationPendingReply, status.InReplyToAccount, status.Account, - status.ID, + status, + nil, ); err != nil { return gtserror.Newf("error notifying replied-to account %s: %w", status.InReplyToAccountID, err) } @@ -99,54 +99,75 @@ func (s *Surface) notifyMentions( for _, mention := range status.Mentions { // Set status on the mention (stops - // the below function populating it). + // notifyMention having to populate it). mention.Status = status - // Beforehand, ensure the passed mention is fully populated. - if err := s.State.DB.PopulateMention(ctx, mention); err != nil { - errs.Appendf("error populating mention %s: %w", mention.ID, err) - continue + // Do the thing. + if err := s.notifyMention(ctx, mention); err != nil { + errs = append(errs, err) } + } - if mention.TargetAccount.IsRemote() { - // no need to notify - // remote accounts. - continue - } + return errs.Combine() +} - // Ensure thread not muted - // by mentioned account. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, - status.ThreadID, - mention.TargetAccountID, +// notifyMention notifies the target +// of the given mention that they've +// been mentioned in a status. +func (s *Surface) notifyMention( + ctx context.Context, + mention *gtsmodel.Mention, +) error { + // Beforehand, ensure the passed mention is fully populated. + if err := s.State.DB.PopulateMention(ctx, mention); err != nil { + return gtserror.Newf( + "error populating mention %s: %w", + mention.ID, err, ) - if err != nil { - errs.Appendf("error checking status thread mute %s: %w", status.ThreadID, err) - continue - } + } - if muted { - // This mentioned account - // has muted the thread. - // Don't pester them. - continue - } + if mention.TargetAccount.IsRemote() { + // no need to notify + // remote accounts. + return nil + } - // notify mentioned - // by status author. - if err := s.Notify(ctx, - gtsmodel.NotificationMention, - mention.TargetAccount, - mention.OriginAccount, - mention.StatusID, - ); err != nil { - errs.Appendf("error notifying mention target %s: %w", mention.TargetAccountID, err) - continue - } + // Ensure thread not muted + // by mentioned account. + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, + mention.Status.ThreadID, + mention.TargetAccountID, + ) + if err != nil { + return gtserror.Newf( + "error checking status thread mute %s: %w", + mention.Status.ThreadID, err, + ) } - return errs.Combine() + if muted { + // This mentioned account + // has muted the thread. + // Don't pester them. + return nil + } + + // Notify mentioned + // by status author. + if err := s.Notify(ctx, + gtsmodel.NotificationMention, + mention.TargetAccount, + mention.OriginAccount, + mention.Status, + nil, + ); err != nil { + return gtserror.Newf( + "error notifying mention target %s: %w", + mention.TargetAccountID, err, + ) + } + + return nil } // notifyFollowRequest notifies the target of the given @@ -171,7 +192,8 @@ func (s *Surface) notifyFollowRequest( gtsmodel.NotificationFollowRequest, followReq.TargetAccount, followReq.Account, - "", + nil, + nil, ); err != nil { return gtserror.Newf("error notifying follow target %s: %w", followReq.TargetAccountID, err) } @@ -223,7 +245,8 @@ func (s *Surface) notifyFollow( gtsmodel.NotificationFollow, follow.TargetAccount, follow.Account, - "", + nil, + nil, ); err != nil { return gtserror.Newf("error notifying follow target %s: %w", follow.TargetAccountID, err) } @@ -253,7 +276,8 @@ func (s *Surface) notifyFave( gtsmodel.NotificationFavourite, fave.TargetAccount, fave.Account, - fave.StatusID, + fave.Status, + nil, ); err != nil { return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) } @@ -284,7 +308,8 @@ func (s *Surface) notifyPendingFave( gtsmodel.NotificationPendingFave, fave.TargetAccount, fave.Account, - fave.StatusID, + fave.Status, + nil, ); err != nil { return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) } @@ -317,8 +342,7 @@ func (s *Surface) notifyableFave( // Ensure favee hasn't // muted the thread. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, fave.Status.ThreadID, fave.TargetAccountID, ) @@ -357,7 +381,8 @@ func (s *Surface) notifyAnnounce( gtsmodel.NotificationReblog, boost.BoostOfAccount, boost.Account, - boost.ID, + boost, + nil, ); err != nil { return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) } @@ -388,7 +413,8 @@ func (s *Surface) notifyPendingAnnounce( gtsmodel.NotificationPendingReblog, boost.BoostOfAccount, boost.Account, - boost.ID, + boost, + nil, ); err != nil { return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) } @@ -426,8 +452,7 @@ func (s *Surface) notifyableAnnounce( // Ensure boostee hasn't // muted the thread. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, status.BoostOf.ThreadID, status.BoostOfAccountID, ) @@ -466,7 +491,8 @@ func (s *Surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status) gtsmodel.NotificationPoll, status.Account, status.Account, - status.ID, + status, + nil, ); err != nil { errs.Appendf("error notifying poll author: %w", err) } @@ -485,7 +511,8 @@ func (s *Surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status) gtsmodel.NotificationPoll, vote.Account, status.Account, - status.ID, + status, + nil, ); err != nil { errs.Appendf("error notifying poll voter %s: %w", vote.AccountID, err) continue @@ -524,7 +551,8 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro gtsmodel.NotificationAdminSignup, mod, newUser.Account, - "", + nil, + nil, ); err != nil { errs.Appendf("error notifying moderator %s: %w", mod.ID, err) continue @@ -534,19 +562,68 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro return errs.Combine() } +func (s *Surface) notifyStatusEdit( + ctx context.Context, + status *gtsmodel.Status, + edit *gtsmodel.StatusEdit, +) error { + // Get local-only interactions (we can't/don't notify remotes). + interactions, err := s.State.DB.GetStatusInteractions(ctx, status.ID, true) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("db error getting status interactions: %w", err) + } + + // Deduplicate interactions by account ID, + // we don't need to notify someone twice + // if they've both boosted *and* replied + // to an edited status, for example. + interactions = xslices.DeduplicateFunc( + interactions, + func(v gtsmodel.Interaction) string { + return v.GetAccount().ID + }, + ) + + // Notify each account that's + // interacted with the status. + var errs gtserror.MultiError + for _, i := range interactions { + targetAcct := i.GetAccount() + if targetAcct.ID == status.AccountID { + // Don't notify an account + // if they've interacted + // with their *own* status. + continue + } + + if err := s.Notify(ctx, + gtsmodel.NotificationUpdate, + targetAcct, + status.Account, + status, + edit, + ); err != nil { + errs.Appendf("error notifying status edit: %w", err) + continue + } + } + + return errs.Combine() +} + func getNotifyLockURI( notificationType gtsmodel.NotificationType, targetAccount *gtsmodel.Account, originAccount *gtsmodel.Account, - statusID string, + statusOrEditID string, ) string { builder := strings.Builder{} builder.WriteString("notification:?") builder.WriteString("type=" + notificationType.String()) - builder.WriteString("&target=" + targetAccount.URI) - builder.WriteString("&origin=" + originAccount.URI) - if statusID != "" { - builder.WriteString("&statusID=" + statusID) + builder.WriteString("&targetAcct=" + targetAccount.URI) + builder.WriteString("&originAcct=" + originAccount.URI) + if statusOrEditID != "" { + builder.WriteString("&statusOrEditID=" + statusOrEditID) } return builder.String() } @@ -561,28 +638,38 @@ func getNotifyLockURI( // for non-local first. // // targetAccount and originAccount must be -// set, but statusID can be an empty string. +// set, but statusOrEditID can be empty. func (s *Surface) Notify( ctx context.Context, notificationType gtsmodel.NotificationType, targetAccount *gtsmodel.Account, originAccount *gtsmodel.Account, - statusID string, + status *gtsmodel.Status, + edit *gtsmodel.StatusEdit, ) error { if targetAccount.IsRemote() { // nothing to do. return nil } + // Get status / edit ID + // if either was provided. + // (prefer edit though!) + var statusOrEditID string + if edit != nil { + statusOrEditID = edit.ID + } else if status != nil { + statusOrEditID = status.ID + } + // We're doing state-y stuff so get a // lock on this combo of notif params. - lockURI := getNotifyLockURI( + unlock := s.State.ProcessingLocks.Lock(getNotifyLockURI( notificationType, targetAccount, originAccount, - statusID, - ) - unlock := s.State.ProcessingLocks.Lock(lockURI) + statusOrEditID, + )) // Wrap the unlock so we // can do granular unlocking. @@ -596,7 +683,7 @@ func (s *Surface) Notify( notificationType, targetAccount.ID, originAccount.ID, - statusID, + statusOrEditID, ); err == nil { // Notification exists; // nothing to do. @@ -615,7 +702,7 @@ func (s *Surface) Notify( TargetAccount: targetAccount, OriginAccountID: originAccount.ID, OriginAccount: originAccount, - StatusID: statusID, + StatusOrEditID: statusOrEditID, } if err := s.State.DB.PutNotification(ctx, notif); err != nil { @@ -626,29 +713,72 @@ func (s *Surface) Notify( // with the state-y stuff. unlock() - // Stream notification to the user. - filters, err := s.State.DB.GetFiltersForAccountID(ctx, targetAccount.ID) + // Check whether origin account is muted by target account. + muted, err := s.MuteFilter.AccountNotificationsMuted(ctx, + targetAccount, + originAccount, + ) if err != nil { - return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err) + return gtserror.Newf("error checking account mute: %w", err) } - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), targetAccount.ID, nil) - if err != nil { - return gtserror.Newf("couldn't retrieve mutes for account %s: %w", targetAccount.ID, err) + if muted { + // Don't notify. + return nil } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes) - if err != nil { - if errors.Is(err, status.ErrHideStatus) { + var filtered []apimodel.FilterResult + + if status != nil { + // Check whether status is muted by the target account. + muted, err := s.MuteFilter.StatusNotificationsMuted(ctx, + targetAccount, + status, + ) + if err != nil { + return gtserror.Newf("error checking status mute: %w", err) + } + + if muted { + // Don't notify. + return nil + } + + var hide bool + + // Check whether notification status is filtered by requester in notifs. + filtered, hide, err = s.StatusFilter.StatusFilterResultsInContext(ctx, + targetAccount, + status, + gtsmodel.FilterContextNotifications, + ) + if err != nil { + return gtserror.Newf("error checking status filtering: %w", err) + } + + if hide { + // Don't notify. return nil } + } + + // Convert notification to frontend API model for streaming / web push. + apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif) + if err != nil { return gtserror.Newf("error converting notification to api representation: %w", err) } + + if apiNotif.Status != nil { + // Set filter results on status, + // in case any were set above. + apiNotif.Status.Filtered = filtered + } + + // Stream notification to the user. s.Stream.Notify(ctx, targetAccount, apiNotif) // Send Web Push notification to the user. - if err = s.WebPushSender.Send(ctx, notif, filters, compiledMutes); err != nil { + if err = s.WebPushSender.Send(ctx, notif, apiNotif); err != nil { return gtserror.Newf("error sending Web Push notifications: %w", err) } diff --git a/internal/processing/workers/surfacenotify_test.go b/internal/processing/workers/surfacenotify_test.go index 6444314e2..a5124a3af 100644 --- a/internal/processing/workers/surfacenotify_test.go +++ b/internal/processing/workers/surfacenotify_test.go @@ -18,17 +18,17 @@ package workers_test import ( - "context" "sync" "testing" "time" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" + "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/processing/workers" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/processing/workers" - "github.com/superseriousbusiness/gotosocial/testrig" ) type SurfaceNotifyTestSuite struct { @@ -44,13 +44,14 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() { Converter: testStructs.TypeConverter, Stream: testStructs.Processor.Stream(), VisFilter: visibility.NewFilter(testStructs.State), + MuteFilter: mutes.NewFilter(testStructs.State), EmailSender: testStructs.EmailSender, WebPushSender: testStructs.WebPushSender, Conversations: testStructs.Processor.Conversations(), } var ( - ctx = context.Background() + ctx = suite.T().Context() notificationType = gtsmodel.NotificationFollow targetAccount = suite.testAccounts["local_account_1"] originAccount = suite.testAccounts["local_account_2"] @@ -75,7 +76,8 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() { notificationType, targetAccount, originAccount, - "", + nil, + nil, ); err != nil { suite.FailNow(err.Error()) } diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index b071bd72e..0e30f54f7 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -19,17 +19,15 @@ package workers import ( "context" - "errors" - - statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" - "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" - "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/stream" - "github.com/superseriousbusiness/gotosocial/internal/timeline" - "github.com/superseriousbusiness/gotosocial/internal/util" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/cache/timeline" + "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/stream" + "code.superseriousbusiness.org/gotosocial/internal/util" ) // timelineAndNotifyStatus inserts the given status into the HOME @@ -40,6 +38,7 @@ import ( // the account, notifications for any local accounts that want // to know when this account posts, and conversations containing the status. func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.Status) error { + // Ensure status fully populated; including account, mentions, etc. if err := s.State.DB.PopulateStatus(ctx, status); err != nil { return gtserror.Newf("error populating status with id %s: %w", status.ID, err) @@ -62,6 +61,11 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. }) } + // Stream the status for public timelines for all local users as update msg. + if err := s.timelineStatusForPublic(ctx, status, s.Stream.Update); err != nil { + return err + } + // Timeline the status for each local follower of this account. This will // also handle notifying any followers with notify set to true on their follow. homeTimelinedAccountIDs := s.timelineAndNotifyStatusForFollowers(ctx, status, follows) @@ -71,16 +75,18 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. return gtserror.Newf("error timelining status %s for tag followers: %w", status.ID, err) } - // Notify each local account that's mentioned by this status. + // Notify each local account mentioned by status. if err := s.notifyMentions(ctx, status); err != nil { return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err) } - // Update any conversations containing this status, and send conversation notifications. + // Update any conversations containing this status, and get notifications for them. notifications, err := s.Conversations.UpdateConversationsForStatus(ctx, status) if err != nil { return gtserror.Newf("error updating conversations for status %s: %w", status.ID, err) } + + // Stream these conversation notfications. for _, notification := range notifications { s.Stream.Conversation(ctx, notification.AccountID, notification.Conversation) } @@ -88,6 +94,95 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. return nil } +// timelineStatusForPublic timelines the given status +// to LOCAL and PUBLIC (i.e. federated) timelines. +func (s *Surface) timelineStatusForPublic( + ctx context.Context, + status *gtsmodel.Status, + streamFn func(context.Context, *gtsmodel.Account, *apimodel.Status, string), +) error { + // Nil check function + // outside main loop. + if streamFn == nil { + panic("nil func") + } + + if status.Visibility != gtsmodel.VisibilityPublic || + status.BoostOfID != "" { + // Fast code path, if it's not "public" + // or a boost, don't public timeline it. + return nil + } + + // Get a list of all our local users. + users, err := s.State.DB.GetAllUsers(ctx) + if err != nil { + return gtserror.Newf("error getting local users: %v", err) + } + + // Iterate our list of users. + isLocal := status.IsLocal() + for _, user := range users { + + // Check whether this status should be visible this user on public timelines. + visible, err := s.VisFilter.StatusPublicTimelineable(ctx, user.Account, status) + if err != nil { + log.Errorf(ctx, "error checking status %s visibility: %v", status.URI, err) + continue + } + + if !visible { + continue + } + + // Check whether this status is muted in any form by this user. + muted, err := s.MuteFilter.StatusMuted(ctx, user.Account, status) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", status.URI, err) + continue + } + + if muted { + continue + } + + // Get status-filter results for this status in context by this user. + filtered, hidden, err := s.StatusFilter.StatusFilterResultsInContext(ctx, + user.Account, + status, + gtsmodel.FilterContextPublic, + ) + if err != nil { + log.Errorf(ctx, "error getting status %s filter results: %v", status.URI, err) + continue + } + + if hidden { + continue + } + + // Now all checks / filters are passed, convert status to frontend model. + apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, user.Account) + if err != nil { + log.Errorf(ctx, "error converting status %s: %v", status.URI, err) + continue + } + + // Set API model filter results. + apiStatus.Filtered = filtered + + if isLocal { + // This is local status, send it to local timeline stream. + streamFn(ctx, user.Account, apiStatus, stream.TimelineLocal) + } + + // For public timeline stream, send all local / remote statuses. + streamFn(ctx, user.Account, apiStatus, stream.TimelinePublic) + } + + return nil +} + // timelineAndNotifyStatusForFollowers iterates through the given // slice of followers of the account that posted the given status, // adding the status to list timelines + home timelines of each @@ -119,11 +214,12 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // if something is hometimelineable according to this filter, // it's also eligible to appear in exclusive lists, // even if it ultimately doesn't appear on the home timeline. - timelineable, err := s.VisFilter.StatusHomeTimelineable( - ctx, follow.Account, status, + timelineable, err := s.VisFilter.StatusHomeTimelineable(ctx, + follow.Account, + status, ) if err != nil { - log.Errorf(ctx, "error checking status home visibility for follow: %v", err) + log.Errorf(ctx, "error checking status home visibility: %v", err) continue } @@ -132,11 +228,18 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( continue } - // Get relevant filters and mutes for this follow's account. - // (note the origin account of the follow is receiver of status). - filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) + // Check if the status is muted by this follower. + muted, err := s.MuteFilter.StatusMuted(ctx, + follow.Account, + status, + ) if err != nil { - log.Error(ctx, err) + log.Errorf(ctx, "error checking status mute: %v", err) + continue + } + + if muted { + // Nothing to do. continue } @@ -144,8 +247,6 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( listTimelined, exclusive, err := s.listTimelineStatusForFollow(ctx, status, follow, - filters, - mutes, ) if err != nil { log.Errorf(ctx, "error list timelining status: %v", err) @@ -161,21 +262,14 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // Add status to home timeline for owner of // this follow (origin account), if applicable. - homeTimelined, err = s.timelineStatus(ctx, - s.State.Timelines.Home.IngestOne, - follow.AccountID, // home timelines are keyed by account ID + if homeTimelined = s.timelineStatus(ctx, + s.State.Caches.Timelines.Home.MustGet(follow.AccountID), follow.Account, status, stream.TimelineHome, - filters, - mutes, - ) - if err != nil { - log.Errorf(ctx, "error home timelining status: %v", err) - continue - } + gtsmodel.FilterContextHome, + ); homeTimelined { - if homeTimelined { // If hometimelined, add to list of returned account IDs. homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) } @@ -210,7 +304,8 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( gtsmodel.NotificationStatus, follow.Account, status.Account, - status.ID, + status, + nil, ); err != nil { log.Errorf(ctx, "error notifying status for account: %v", err) continue @@ -230,8 +325,6 @@ func (s *Surface) listTimelineStatusForFollow( ctx context.Context, status *gtsmodel.Status, follow *gtsmodel.Follow, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (timelined bool, exclusive bool, err error) { // Get all lists that contain this given follow. @@ -261,22 +354,14 @@ func (s *Surface) listTimelineStatusForFollow( exclusive = exclusive || *list.Exclusive // At this point we are certain this status - // should be included in the timeline of the - // list that this list entry belongs to. - listTimelined, err := s.timelineStatus( - ctx, - s.State.Timelines.List.IngestOne, - list.ID, // list timelines are keyed by list ID + // should be included in timeline of this list. + listTimelined := s.timelineStatus(ctx, + s.State.Caches.Timelines.List.MustGet(list.ID), follow.Account, status, stream.TimelineList+":"+list.ID, // key streamType to this specific list - filters, - mutes, + gtsmodel.FilterContextHome, ) - if err != nil { - log.Errorf(ctx, "error adding status to list timeline: %v", err) - continue - } // Update flag based on if timelined. timelined = timelined || listTimelined @@ -285,22 +370,6 @@ func (s *Surface) listTimelineStatusForFollow( return timelined, exclusive, nil } -// getFiltersAndMutes returns an account's filters and mutes. -func (s *Surface) getFiltersAndMutes(ctx context.Context, accountID string) ([]*gtsmodel.Filter, *usermute.CompiledUserMuteList, error) { - filters, err := s.State.DB.GetFiltersForAccountID(ctx, accountID) - if err != nil { - return nil, nil, gtserror.Newf("couldn't retrieve filters for account %s: %w", accountID, err) - } - - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), accountID, nil) - if err != nil { - return nil, nil, gtserror.Newf("couldn't retrieve mutes for account %s: %w", accountID, err) - } - - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - return filters, compiledMutes, err -} - // listEligible checks if the given status is eligible // for inclusion in the list that that the given listEntry // belongs to, based on the replies policy of the list. @@ -367,53 +436,56 @@ func (s *Surface) listEligible( } } -// timelineStatus uses the provided ingest function to put the given -// status in a timeline with the given ID, if it's timelineable. -// -// If the status was inserted into the timeline, true will be returned -// + it will also be streamed to the user using the given streamType. +// timelineStatus will insert the given status into the given timeline, if it's +// timelineable. if the status was inserted into the timeline, true will be returned. func (s *Surface) timelineStatus( ctx context.Context, - ingest func(context.Context, string, timeline.Timelineable) (bool, error), - timelineID string, + timeline *timeline.StatusTimeline, account *gtsmodel.Account, status *gtsmodel.Status, streamType string, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, -) (bool, error) { + filterCtx gtsmodel.FilterContext, +) bool { + // Check whether status is filtered in this context by timeline account. + filtered, hide, err := s.StatusFilter.StatusFilterResultsInContext(ctx, + account, + status, + filterCtx, + ) + if err != nil { + log.Errorf(ctx, "error filtering status %s: %v", status.URI, err) + } - // Ingest status into given timeline using provided function. - if inserted, err := ingest(ctx, timelineID, status); err != nil && - !errors.Is(err, statusfilter.ErrHideStatus) { - err := gtserror.Newf("error ingesting status %s: %w", status.ID, err) - return false, err - } else if !inserted { - // Nothing more to do. - return false, nil + if hide { + // Don't even show to + // timeline account. + return false } - // Convert updated database model to frontend model. - apiStatus, err := s.Converter.StatusToAPIStatus(ctx, + // Attempt to convert status to frontend API representation, + // this will check whether status is filtered / muted. + apiModel, err := s.Converter.StatusToAPIStatus(ctx, status, account, - statusfilter.FilterContextHome, - filters, - mutes, ) - if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { - err := gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) - return true, err + if err != nil { + log.Error(ctx, "error converting status %s to frontend: %v", status.URI, err) + } else { + + // Attach any filter results. + apiModel.Filtered = filtered } - if apiStatus != nil { - // The status was inserted so stream it to the user. - s.Stream.Update(ctx, account, apiStatus, streamType) - return true, nil + // Insert status to timeline cache regardless of + // if API model was succesfully prepared or not. + repeatBoost := timeline.InsertOne(status, apiModel) + + if !repeatBoost { + // Only stream if not repeated boost of recent status. + s.Stream.Update(ctx, account, apiModel, streamType) } - // Status was hidden. - return false, nil + return true } // timelineAndNotifyStatusForTagFollowers inserts the status into the @@ -435,32 +507,17 @@ func (s *Surface) timelineAndNotifyStatusForTagFollowers( status = status.BoostOf } + var errs gtserror.MultiError + // Insert the status into the home timeline of each tag follower. - errs := gtserror.MultiError{} for _, tagFollowerAccount := range tagFollowerAccounts { - filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) - if err != nil { - errs.Append(err) - continue - } - - if _, err := s.timelineStatus( - ctx, - s.State.Timelines.Home.IngestOne, - tagFollowerAccount.ID, // home timelines are keyed by account ID + _ = s.timelineStatus(ctx, + s.State.Caches.Timelines.Home.MustGet(tagFollowerAccount.ID), tagFollowerAccount, status, stream.TimelineHome, - filters, - mutes, - ); err != nil { - errs.Appendf( - "error inserting status %s into home timeline for account %s: %w", - status.ID, - tagFollowerAccount.ID, - err, - ) - } + gtsmodel.FilterContextHome, + ) } return errs.Combine() @@ -550,39 +607,6 @@ func (s *Surface) tagFollowersForStatus( return visibleTagFollowerAccounts, errs.Combine() } -// deleteStatusFromTimelines completely removes the given status from all timelines. -// It will also stream deletion of the status to all open streams. -func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) error { - if err := s.State.Timelines.Home.WipeItemFromAllTimelines(ctx, statusID); err != nil { - return err - } - if err := s.State.Timelines.List.WipeItemFromAllTimelines(ctx, statusID); err != nil { - return err - } - s.Stream.Delete(ctx, statusID) - return nil -} - -// invalidateStatusFromTimelines does cache invalidation on the given status by -// unpreparing it from all timelines, forcing it to be prepared again (with updated -// stats, boost counts, etc) next time it's fetched by the timeline owner. This goes -// both for the status itself, and for any boosts of the status. -func (s *Surface) invalidateStatusFromTimelines(ctx context.Context, statusID string) { - if err := s.State.Timelines.Home.UnprepareItemFromAllTimelines(ctx, statusID); err != nil { - log. - WithContext(ctx). - WithField("statusID", statusID). - Errorf("error unpreparing status from home timelines: %v", err) - } - - if err := s.State.Timelines.List.UnprepareItemFromAllTimelines(ctx, statusID); err != nil { - log. - WithContext(ctx). - WithField("statusID", statusID). - Errorf("error unpreparing status from list timelines: %v", err) - } -} - // timelineStatusUpdate looks up HOME and LIST timelines of accounts // that follow the the status author or tags and pushes edit messages into any // active streams. @@ -612,6 +636,11 @@ func (s *Surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Sta }) } + // Stream the status update for public timelines for all of our local users. + if err := s.timelineStatusForPublic(ctx, status, s.Stream.StatusUpdate); err != nil { + return err + } + // Push updated status to streams for each local follower of this account. homeTimelinedAccountIDs := s.timelineStatusUpdateForFollowers(ctx, status, follows) @@ -660,20 +689,10 @@ func (s *Surface) timelineStatusUpdateForFollowers( continue } - // Get relevant filters and mutes for this follow's account. - // (note the origin account of the follow is receiver of status). - filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) - if err != nil { - log.Error(ctx, err) - continue - } - // Add status to relevant lists for this follow, if applicable. _, exclusive, err := s.listTimelineStatusUpdateForFollow(ctx, status, follow, - filters, - mutes, ) if err != nil { log.Errorf(ctx, "error list timelining status: %v", err) @@ -693,8 +712,6 @@ func (s *Surface) timelineStatusUpdateForFollowers( follow.Account, status, stream.TimelineHome, - filters, - mutes, ) if err != nil { log.Errorf(ctx, "error home timelining status: %v", err) @@ -719,8 +736,6 @@ func (s *Surface) listTimelineStatusUpdateForFollow( ctx context.Context, status *gtsmodel.Status, follow *gtsmodel.Follow, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (bool, bool, error) { // Get all lists that contain this given follow. @@ -759,8 +774,6 @@ func (s *Surface) listTimelineStatusUpdateForFollow( follow.Account, status, stream.TimelineList+":"+list.ID, // key streamType to this specific list - filters, - mutes, ) if err != nil { log.Errorf(ctx, "error adding status to list timeline: %v", err) @@ -783,31 +796,35 @@ func (s *Surface) timelineStreamStatusUpdate( account *gtsmodel.Account, status *gtsmodel.Status, streamType string, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (bool, error) { + // Check whether status is filtered in this context by timeline account. + filtered, hide, err := s.StatusFilter.StatusFilterResultsInContext(ctx, + account, + status, + gtsmodel.FilterContextHome, + ) + if err != nil { + return false, gtserror.Newf("error filtering status: %w", err) + } + + if hide { + // Don't even show to + // timeline account. + return false, nil + } // Convert updated database model to frontend model. apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, - statusfilter.FilterContextHome, - filters, - mutes, ) - - switch { - case err == nil: - // no issue. - - case errors.Is(err, statusfilter.ErrHideStatus): - // Don't put this status in the stream. - return false, nil - - default: + if err != nil { return false, gtserror.Newf("error converting status: %w", err) } + // Attach any filter results. + apiStatus.Filtered = filtered + // The status was updated so stream it to the user. s.Stream.StatusUpdate(ctx, account, apiStatus, streamType) @@ -835,19 +852,11 @@ func (s *Surface) timelineStatusUpdateForTagFollowers( // Stream the update to the home timeline of each tag follower. errs := gtserror.MultiError{} for _, tagFollowerAccount := range tagFollowerAccounts { - filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) - if err != nil { - errs.Append(err) - continue - } - if _, err := s.timelineStreamStatusUpdate( ctx, tagFollowerAccount, status, stream.TimelineHome, - filters, - mutes, ); err != nil { errs.Appendf( "error updating status %s on home timeline for account %s: %w", @@ -859,3 +868,53 @@ func (s *Surface) timelineStatusUpdateForTagFollowers( } return errs.Combine() } + +// deleteStatusFromTimelines completely removes the given status from all timelines. +// It will also stream deletion of the status to all open streams. +func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) { + s.State.Caches.Timelines.Home.RemoveByStatusIDs(statusID) + s.State.Caches.Timelines.List.RemoveByStatusIDs(statusID) + s.Stream.Delete(ctx, statusID) +} + +// invalidateStatusFromTimelines does cache invalidation on the given status by +// unpreparing it from all timelines, forcing it to be prepared again (with updated +// stats, boost counts, etc) next time it's fetched by the timeline owner. This goes +// both for the status itself, and for any boosts of the status. +func (s *Surface) invalidateStatusFromTimelines(statusID string) { + s.State.Caches.Timelines.Home.UnprepareByStatusIDs(statusID) + s.State.Caches.Timelines.List.UnprepareByStatusIDs(statusID) +} + +// removeTimelineEntriesByAccount removes all cached timeline entries authored by account ID. +func (s *Surface) removeTimelineEntriesByAccount(accountID string) { + s.State.Caches.Timelines.Home.RemoveByAccountIDs(accountID) + s.State.Caches.Timelines.List.RemoveByAccountIDs(accountID) +} + +// removeTimelineEntriesByAccount invalidates all cached timeline entries authored by account ID. +func (s *Surface) invalidateTimelineEntriesByAccount(accountID string) { + s.State.Caches.Timelines.Home.UnprepareByAccountIDs(accountID) + s.State.Caches.Timelines.List.UnprepareByAccountIDs(accountID) +} + +func (s *Surface) removeRelationshipFromTimelines(ctx context.Context, timelineAccountID string, targetAccountID string) { + // Remove all statuses by target account + // from given account's home timeline. + s.State.Caches.Timelines.Home. + MustGet(timelineAccountID). + RemoveByAccountIDs(targetAccountID) + + // Get the IDs of all the lists owned by the given account ID. + listIDs, err := s.State.DB.GetListIDsByAccountID(ctx, timelineAccountID) + if err != nil { + log.Errorf(ctx, "error getting lists for account %s: %v", timelineAccountID, err) + } + + for _, listID := range listIDs { + // Remove all statuses by target account + // from given account's list timelines. + s.State.Caches.Timelines.List.MustGet(listID). + RemoveByAccountIDs(targetAccountID) + } +} diff --git a/internal/processing/workers/util.go b/internal/processing/workers/util.go index b358dc951..0aa0febf0 100644 --- a/internal/processing/workers/util.go +++ b/internal/processing/workers/util.go @@ -21,17 +21,16 @@ import ( "context" "errors" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/processing/account" - "github.com/superseriousbusiness/gotosocial/internal/processing/media" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "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/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/processing/account" + "code.superseriousbusiness.org/gotosocial/internal/processing/media" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // util provides util functions used by both @@ -172,15 +171,11 @@ func (u *utils) wipeStatus( } // Remove the boost from any and all timelines. - if err := u.surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil { - errs.Appendf("error deleting boost from timelines: %w", err) - } + u.surface.deleteStatusFromTimelines(ctx, boost.ID) } // Delete the status itself from any and all timelines. - if err := u.surface.deleteStatusFromTimelines(ctx, status.ID); err != nil { - errs.Appendf("error deleting status from timelines: %w", err) - } + u.surface.deleteStatusFromTimelines(ctx, status.ID) // Delete this status from any conversations it's part of. if err := u.state.DB.DeleteStatusFromConversations(ctx, status.ID); err != nil { @@ -289,250 +284,13 @@ func (u *utils) redirectFollowers( return true } -func (u *utils) incrementStatusesCount( - ctx context.Context, - account *gtsmodel.Account, - status *gtsmodel.Status, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update status meta for account. - *account.Stats.StatusesCount++ - account.Stats.LastStatusAt = status.CreatedAt - - // Update details in the database for stats. - if err := u.state.DB.UpdateAccountStats(ctx, - account.Stats, - "statuses_count", - "last_status_at", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -func (u *utils) decrementStatusesCount( - ctx context.Context, - account *gtsmodel.Account, - status *gtsmodel.Status, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update status meta for account (safely checking for zero value). - *account.Stats.StatusesCount = util.Decr(*account.Stats.StatusesCount) - - if !status.PinnedAt.IsZero() { - // Update status pinned count for account (safely checking for zero value). - *account.Stats.StatusesPinnedCount = util.Decr(*account.Stats.StatusesPinnedCount) - } - - // Update details in the database for stats. - if err := u.state.DB.UpdateAccountStats(ctx, - account.Stats, - "statuses_count", - "statuses_pinned_count", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -func (u *utils) incrementFollowersCount( - ctx context.Context, - account *gtsmodel.Account, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update stats by incrementing followers - // count by one and setting last posted. - *account.Stats.FollowersCount++ - if err := u.state.DB.UpdateAccountStats( - ctx, - account.Stats, - "followers_count", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -func (u *utils) decrementFollowersCount( - ctx context.Context, - account *gtsmodel.Account, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update stats by decrementing - // followers count by one. - // - // Clamp to 0 to avoid funny business. - *account.Stats.FollowersCount-- - if *account.Stats.FollowersCount < 0 { - *account.Stats.FollowersCount = 0 - } - if err := u.state.DB.UpdateAccountStats( - ctx, - account.Stats, - "followers_count", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -func (u *utils) incrementFollowingCount( - ctx context.Context, - account *gtsmodel.Account, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update stats by incrementing - // followers count by one. - *account.Stats.FollowingCount++ - if err := u.state.DB.UpdateAccountStats( - ctx, - account.Stats, - "following_count", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -func (u *utils) decrementFollowingCount( - ctx context.Context, - account *gtsmodel.Account, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update stats by decrementing - // following count by one. - // - // Clamp to 0 to avoid funny business. - *account.Stats.FollowingCount-- - if *account.Stats.FollowingCount < 0 { - *account.Stats.FollowingCount = 0 - } - if err := u.state.DB.UpdateAccountStats( - ctx, - account.Stats, - "following_count", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -func (u *utils) incrementFollowRequestsCount( - ctx context.Context, - account *gtsmodel.Account, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update stats by incrementing - // follow requests count by one. - *account.Stats.FollowRequestsCount++ - if err := u.state.DB.UpdateAccountStats( - ctx, - account.Stats, - "follow_requests_count", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -func (u *utils) decrementFollowRequestsCount( - ctx context.Context, - account *gtsmodel.Account, -) error { - // Lock on this account since we're changing stats. - unlock := u.state.ProcessingLocks.Lock(account.URI) - defer unlock() - - // Ensure account stats are populated. - if err := u.state.DB.PopulateAccountStats(ctx, account); err != nil { - return gtserror.Newf("db error getting account stats: %w", err) - } - - // Update stats by decrementing - // follow requests count by one. - // - // Clamp to 0 to avoid funny business. - *account.Stats.FollowRequestsCount-- - if *account.Stats.FollowRequestsCount < 0 { - *account.Stats.FollowRequestsCount = 0 - } - if err := u.state.DB.UpdateAccountStats( - ctx, - account.Stats, - "follow_requests_count", - ); err != nil { - return gtserror.Newf("db error updating account stats: %w", err) - } - - return nil -} - -// requestFave stores an interaction request +// impoliteFaveRequest stores an interaction request // for the given fave, and notifies the interactee. -func (u *utils) requestFave( +// +// It should be used only when an actor has sent a Like +// directly in response to a post that requires approval +// for it, instead of sending a LikeRequest. +func (u *utils) impoliteFaveRequest( ctx context.Context, fave *gtsmodel.StatusFave, ) error { @@ -559,13 +317,13 @@ func (u *utils) requestFave( return nil } - // Create + store new interaction request. - req = typeutils.StatusFaveToInteractionRequest(fave) + // Create + store new impolite interaction request. + req = typeutils.StatusFaveToImpoliteInteractionRequest(fave) if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { return gtserror.Newf("db error storing interaction request: %w", err) } - // Notify *local* account of pending announce. + // Notify *local* account of pending fave. if err := u.surface.notifyPendingFave(ctx, fave); err != nil { return gtserror.Newf("error notifying pending fave: %w", err) } @@ -573,9 +331,13 @@ func (u *utils) requestFave( return nil } -// requestReply stores an interaction request +// impoliteReplyRequest stores an interaction request // for the given reply, and notifies the interactee. -func (u *utils) requestReply( +// +// It should be used only when an actor has sent a reply +// directly in response to a post that requires approval +// for it, instead of sending a ReplyRequest. +func (u *utils) impoliteReplyRequest( ctx context.Context, reply *gtsmodel.Status, ) error { @@ -602,8 +364,8 @@ func (u *utils) requestReply( return nil } - // Create + store interaction request. - req = typeutils.StatusToInteractionRequest(reply) + // Create + store impolite interaction request. + req = typeutils.StatusToImpoliteInteractionRequest(reply) if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { return gtserror.Newf("db error storing interaction request: %w", err) } @@ -616,9 +378,13 @@ func (u *utils) requestReply( return nil } -// requestAnnounce stores an interaction request +// impoliteAnnounceRequest stores an interaction request // for the given announce, and notifies the interactee. -func (u *utils) requestAnnounce( +// +// It should be used only when an actor has sent an Announce +// directly in response to a post that requires approval +// for it, instead of sending an AnnounceRequest. +func (u *utils) impoliteAnnounceRequest( ctx context.Context, boost *gtsmodel.Status, ) error { @@ -645,8 +411,8 @@ func (u *utils) requestAnnounce( return nil } - // Create + store interaction request. - req = typeutils.StatusToInteractionRequest(boost) + // Create + store impolite interaction request. + req = typeutils.StatusToImpoliteInteractionRequest(boost) if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { return gtserror.Newf("db error storing interaction request: %w", err) } diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go index 9f37f554e..67e928db0 100644 --- a/internal/processing/workers/workers.go +++ b/internal/processing/workers/workers.go @@ -18,18 +18,20 @@ package workers import ( - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" - "github.com/superseriousbusiness/gotosocial/internal/processing/account" - "github.com/superseriousbusiness/gotosocial/internal/processing/common" - "github.com/superseriousbusiness/gotosocial/internal/processing/conversations" - "github.com/superseriousbusiness/gotosocial/internal/processing/media" - "github.com/superseriousbusiness/gotosocial/internal/processing/stream" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/webpush" - "github.com/superseriousbusiness/gotosocial/internal/workers" + "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/processing/account" + "code.superseriousbusiness.org/gotosocial/internal/processing/common" + "code.superseriousbusiness.org/gotosocial/internal/processing/conversations" + "code.superseriousbusiness.org/gotosocial/internal/processing/media" + "code.superseriousbusiness.org/gotosocial/internal/processing/stream" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" + "code.superseriousbusiness.org/gotosocial/internal/webpush" + "code.superseriousbusiness.org/gotosocial/internal/workers" ) type Processor struct { @@ -44,6 +46,8 @@ func New( federator *federation.Federator, converter *typeutils.Converter, visFilter *visibility.Filter, + muteFilter *mutes.Filter, + statusFilter *status.Filter, emailSender email.Sender, webPushSender webpush.Sender, account *account.Processor, @@ -66,6 +70,8 @@ func New( Converter: converter, Stream: stream, VisFilter: visFilter, + MuteFilter: muteFilter, + StatusFilter: statusFilter, EmailSender: emailSender, WebPushSender: webPushSender, Conversations: conversations, diff --git a/internal/processing/workers/workers_test.go b/internal/processing/workers/workers_test.go index ffd40d8fb..ddd5bd0e4 100644 --- a/internal/processing/workers/workers_test.go +++ b/internal/processing/workers/workers_test.go @@ -20,12 +20,12 @@ package workers_test import ( "context" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/processing" + "code.superseriousbusiness.org/gotosocial/internal/stream" + "code.superseriousbusiness.org/gotosocial/testrig" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/testrig" ) const ( @@ -39,7 +39,6 @@ type WorkersTestSuite 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 @@ -48,7 +47,7 @@ type WorkersTestSuite struct { testStatuses map[string]*gtsmodel.Status testTags map[string]*gtsmodel.Tag testMentions map[string]*gtsmodel.Mention - testAutheds map[string]*oauth.Auth + testAutheds map[string]*apiutil.Auth testBlocks map[string]*gtsmodel.Block testActivities map[string]testrig.ActivityWithSignature testLists map[string]*gtsmodel.List @@ -57,7 +56,6 @@ type WorkersTestSuite struct { func (suite *WorkersTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() @@ -66,7 +64,7 @@ func (suite *WorkersTestSuite) SetupSuite() { suite.testStatuses = testrig.NewTestStatuses() suite.testTags = testrig.NewTestTags() suite.testMentions = testrig.NewTestMentions() - suite.testAutheds = map[string]*oauth.Auth{ + suite.testAutheds = map[string]*apiutil.Auth{ "local_account_1": { Application: suite.testApplications["local_account_1"], User: suite.testUsers["local_account_1"], |
