From e8595f0c64f527af0913d1a426b697e67ff74ac9 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 20 Mar 2023 19:10:08 +0100 Subject: [PATCH] [chore] Refactor account deleting/block logic, tidy up some other processing things (#1599) * start refactoring account deletion * update to use state.DB * further messing about * some more tidying up * more tidying, cleaning, nice-making * further adventures in refactoring and the woes of technical debt * update fr accept/reject * poking + prodding * fix up deleting * create fave uri * don't log using requestingAccount.ID because it might be nil * move getBookmarks function * use exists query to check for status bookmark * use deletenotifications func * fiddle * delete follow request notif * split up some db functions * Fix possible nil pointer panic * fix more possible nil pointers * fix license headers * warn when follow missing (target) account * return wrapped err when bookmark/fave models can't be retrieved * simplify self account delete * warn log likely race condition * de-sillify status delete loop * move error check due north * warn when unfollowSideEffects has no target account * warn when no boost account is found * warn + dump follow when no account * more warnings * warn on fave account not set * move for loop inside anonymous function * fix funky logic * don't remove mutual account items on block; do make sure unfollow occurs in both directions! --- .../api/activitypub/users/userget_test.go | 6 +- internal/api/client/accounts/accountdelete.go | 10 +- .../api/client/bookmarks/bookmarks_test.go | 2 +- .../api/client/statuses/statusfave_test.go | 2 +- internal/api/model/account.go | 3 - internal/db/account.go | 4 +- internal/db/bundb/account.go | 40 +- internal/db/bundb/account_test.go | 6 - internal/db/bundb/bundb.go | 10 + internal/db/bundb/bundb_test.go | 4 + internal/db/bundb/notification.go | 62 +- internal/db/bundb/notification_test.go | 73 ++- internal/db/bundb/relationship.go | 433 +++++++++---- internal/db/bundb/relationship_test.go | 129 +++- internal/db/bundb/status.go | 23 - internal/db/bundb/statusbookmark.go | 198 ++++++ internal/db/bundb/statusbookmark_test.go | 129 ++++ internal/db/bundb/statusfave.go | 172 ++++++ internal/db/bundb/statusfave_test.go | 148 +++++ internal/db/db.go | 2 + internal/db/notification.go | 27 +- internal/db/relationship.go | 69 ++- internal/db/status.go | 4 - internal/db/statusbookmark.go | 66 ++ internal/db/statusfave.go | 63 ++ .../federatingdb/federatingdb_test.go | 6 +- internal/federation/federatingdb/followers.go | 33 +- internal/federation/federatingdb/following.go | 34 +- internal/federation/federatingdb/inbox.go | 14 +- internal/processing/account/account_test.go | 6 +- internal/processing/account/block.go | 214 +++---- internal/processing/account/bookmarks.go | 2 +- internal/processing/account/delete.go | 579 ++++++++++++------ internal/processing/account/follow.go | 243 ++++---- internal/processing/account/relationships.go | 112 ++-- internal/processing/account_test.go | 6 +- internal/processing/followrequest.go | 98 ++- internal/processing/fromclientapi.go | 64 +- internal/processing/fromcommon.go | 105 ++-- internal/processing/notification.go | 8 +- internal/processing/processor.go | 20 +- internal/processing/status/bookmark.go | 114 ++-- internal/processing/status/boost.go | 15 +- internal/processing/status/common.go | 63 ++ internal/processing/status/create.go | 8 +- internal/processing/status/delete.go | 10 +- internal/processing/status/fave.go | 229 +++---- internal/processing/status/get.go | 43 +- internal/processing/status/pin.go | 50 +- internal/typeutils/internaltofrontend.go | 15 +- internal/workers/workers.go | 4 +- testrig/testmodels.go | 9 + testrig/util.go | 4 +- 53 files changed, 2472 insertions(+), 1321 deletions(-) create mode 100644 internal/db/bundb/statusbookmark.go create mode 100644 internal/db/bundb/statusbookmark_test.go create mode 100644 internal/db/bundb/statusfave.go create mode 100644 internal/db/bundb/statusfave_test.go create mode 100644 internal/db/statusbookmark.go create mode 100644 internal/db/statusfave.go create mode 100644 internal/processing/status/common.go diff --git a/internal/api/activitypub/users/userget_test.go b/internal/api/activitypub/users/userget_test.go index 0da308684..ac8b2c0eb 100644 --- a/internal/api/activitypub/users/userget_test.go +++ b/internal/api/activitypub/users/userget_test.go @@ -30,7 +30,6 @@ "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -98,10 +97,7 @@ func (suite *UserGetTestSuite) TestGetUserPublicKeyDeleted() { userModule := users.New(suite.processor) targetAccount := suite.testAccounts["local_account_1"] - suite.processor.Account().DeleteLocal(context.Background(), suite.testAccounts["local_account_1"], &apimodel.AccountDeleteRequest{ - Password: "password", - DeleteOriginID: targetAccount.ID, - }) + suite.processor.Account().DeleteSelf(context.Background(), suite.testAccounts["local_account_1"]) // wait for the account delete to be processed if !testrig.WaitFor(func() bool { diff --git a/internal/api/client/accounts/accountdelete.go b/internal/api/client/accounts/accountdelete.go index 02a75b670..242902cab 100644 --- a/internal/api/client/accounts/accountdelete.go +++ b/internal/api/client/accounts/accountdelete.go @@ -26,6 +26,7 @@ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "golang.org/x/crypto/bcrypt" ) // AccountDeletePOSTHandler swagger:operation POST /api/v1/accounts/delete accountDelete @@ -77,15 +78,20 @@ func (m *Module) AccountDeletePOSTHandler(c *gin.Context) { return } + // Self account delete requires password to ensure it's for real. if form.Password == "" { err = errors.New("no password provided in account delete request") apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) return } - form.DeleteOriginID = authed.Account.ID + if err := bcrypt.CompareHashAndPassword([]byte(authed.User.EncryptedPassword), []byte(form.Password)); err != nil { + err = errors.New("invalid password provided in account delete request") + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } - if errWithCode := m.processor.Account().DeleteLocal(c.Request.Context(), authed.Account, form); errWithCode != nil { + if errWithCode := m.processor.Account().DeleteSelf(c.Request.Context(), authed.Account); errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } diff --git a/internal/api/client/bookmarks/bookmarks_test.go b/internal/api/client/bookmarks/bookmarks_test.go index df9d83339..6f20c4762 100644 --- a/internal/api/client/bookmarks/bookmarks_test.go +++ b/internal/api/client/bookmarks/bookmarks_test.go @@ -275,7 +275,7 @@ func (suite *BookmarkTestSuite) TestGetBookmarksNone() { testUser := suite.testUsers["local_account_1"] // Remove all bookmarks for this account. - if err := suite.db.DeleteWhere(context.Background(), []db.Where{{Key: "account_id", Value: testAccount.ID}}, &[]*gtsmodel.StatusBookmark{}); err != nil { + if err := suite.db.DeleteStatusBookmarks(context.Background(), "", testAccount.ID); err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/statuses/statusfave_test.go b/internal/api/client/statuses/statusfave_test.go index 6b6890c42..ebe4603a8 100644 --- a/internal/api/client/statuses/statusfave_test.go +++ b/internal/api/client/statuses/statusfave_test.go @@ -123,7 +123,7 @@ func (suite *StatusFaveTestSuite) TestPostUnfaveable() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"Forbidden"}`, string(b)) + assert.Equal(suite.T(), `{"error":"Forbidden: status is not faveable"}`, string(b)) } func TestStatusFaveTestSuite(t *testing.T) { diff --git a/internal/api/model/account.go b/internal/api/model/account.go index b4b325983..36138a618 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -206,9 +206,6 @@ type AccountFollowRequest struct { type AccountDeleteRequest struct { // Password of the account's user, for confirmation. Password string `form:"password" json:"password" xml:"password"` - // The origin of the delete account request. - // Can be the ID of the account owner, or the ID of an admin account. - DeleteOriginID string `form:"-" json:"-" xml:"-"` } // AccountRole models the role of an account. diff --git a/internal/db/account.go b/internal/db/account.go index 419337d6e..6ecfea018 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -45,7 +45,7 @@ type Account interface { PutAccount(ctx context.Context, account *gtsmodel.Account) Error // UpdateAccount updates one account by ID. - UpdateAccount(ctx context.Context, account *gtsmodel.Account) Error + UpdateAccount(ctx context.Context, account *gtsmodel.Account, columns ...string) Error // DeleteAccount deletes one account from the database by its ID. // DO NOT USE THIS WHEN SUSPENDING ACCOUNTS! In that case you should mark the @@ -86,8 +86,6 @@ type Account interface { // In the case of no statuses, this function will return db.ErrNoEntries. GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error) - GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, Error) - GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error) // GetAccountLastPosted simply gets the timestamp of the most recent post by the account. diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 17f2cae65..df73168e2 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -191,9 +191,12 @@ func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) d }) } -func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account) db.Error { - // Update the account's last-updated +func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account, columns ...string) db.Error { account.UpdatedAt = time.Now() + if len(columns) > 0 { + // If we're updating by column, ensure "updated_at" is included. + columns = append(columns, "updated_at") + } return a.state.Caches.GTS.Account().Store(account, func() error { // It is safe to run this database transaction within cache.Store @@ -226,6 +229,7 @@ func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account _, err := tx.NewUpdate(). Model(account). Where("? = ?", bun.Ident("account.id"), account.ID). + Column(columns...). Exec(ctx) return err }) @@ -477,38 +481,6 @@ func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, return a.statusesFromIDs(ctx, statusIDs) } -func (a *accountDB) GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, db.Error) { - bookmarks := []*gtsmodel.StatusBookmark{} - - q := a.conn. - NewSelect(). - TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). - Order("status_bookmark.id DESC"). - Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID) - - if accountID == "" { - return nil, errors.New("must provide an account") - } - - if limit != 0 { - q = q.Limit(limit) - } - - if maxID != "" { - q = q.Where("? < ?", bun.Ident("status_bookmark.id"), maxID) - } - - if minID != "" { - q = q.Where("? > ?", bun.Ident("status_bookmark.id"), minID) - } - - if err := q.Scan(ctx, &bookmarks); err != nil { - return nil, a.conn.ProcessError(err) - } - - return bookmarks, nil -} - func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) { blocks := []*gtsmodel.Block{} diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index 1bf7c654d..b7e8aaadc 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -208,12 +208,6 @@ func (suite *AccountTestSuite) TestInsertAccountWithDefaults() { suite.False(*newAccount.HideCollections) } -func (suite *AccountTestSuite) TestGettingBookmarksWithNoAccount() { - statuses, err := suite.db.GetBookmarks(context.Background(), "", 10, "", "") - suite.Error(err) - suite.Nil(statuses) -} - func (suite *AccountTestSuite) TestGetAccountPinnedStatusesSomeResults() { testAccount := suite.testAccounts["admin_account"] diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index d20c81ad0..71433dbc2 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -71,6 +71,8 @@ type DBService struct { db.Report db.Session db.Status + db.StatusBookmark + db.StatusFave db.Timeline db.User db.Tombstone @@ -203,6 +205,14 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { conn: conn, state: state, }, + StatusBookmark: &statusBookmarkDB{ + conn: conn, + state: state, + }, + StatusFave: &statusFaveDB{ + conn: conn, + state: state, + }, Timeline: &timelineDB{ conn: conn, state: state, diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index 843978f24..e6d482ac1 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -44,6 +44,8 @@ type BunDBStandardTestSuite struct { testFollows map[string]*gtsmodel.Follow testEmojis map[string]*gtsmodel.Emoji testReports map[string]*gtsmodel.Report + testBookmarks map[string]*gtsmodel.StatusBookmark + testFaves map[string]*gtsmodel.StatusFave } func (suite *BunDBStandardTestSuite) SetupSuite() { @@ -59,6 +61,8 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { suite.testFollows = testrig.NewTestFollows() suite.testEmojis = testrig.NewTestEmojis() suite.testReports = testrig.NewTestReports() + suite.testBookmarks = testrig.NewTestBookmarks() + suite.testFaves = testrig.NewTestFaves() } func (suite *BunDBStandardTestSuite) SetupTest() { diff --git a/internal/db/bundb/notification.go b/internal/db/bundb/notification.go index 627dc1783..b1e7f45ff 100644 --- a/internal/db/bundb/notification.go +++ b/internal/db/bundb/notification.go @@ -19,6 +19,7 @@ import ( "context" + "errors" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -104,15 +105,70 @@ func (n *notificationDB) GetNotifications(ctx context.Context, accountID string, return notifs, nil } -func (n *notificationDB) ClearNotifications(ctx context.Context, accountID string) db.Error { +func (n *notificationDB) DeleteNotification(ctx context.Context, id string) db.Error { if _, err := n.conn. NewDelete(). TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). - Where("? = ?", bun.Ident("notification.target_account_id"), accountID). + Where("? = ?", bun.Ident("notification.id"), id). Exec(ctx); err != nil { return n.conn.ProcessError(err) } - n.state.Caches.GTS.Notification().Clear() + n.state.Caches.GTS.Notification().Invalidate("ID", id) + return nil +} + +func (n *notificationDB) DeleteNotifications(ctx context.Context, targetAccountID string, originAccountID string) db.Error { + if targetAccountID == "" && originAccountID == "" { + return errors.New("DeleteNotifications: one of targetAccountID or originAccountID must be set") + } + + // Capture notification IDs in a RETURNING statement. + ids := []string{} + + q := n.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). + Returning("?", bun.Ident("id")) + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("notification.target_account_id"), targetAccountID) + } + + if originAccountID != "" { + q = q.Where("? = ?", bun.Ident("notification.origin_account_id"), originAccountID) + } + + if _, err := q.Exec(ctx, &ids); err != nil { + return n.conn.ProcessError(err) + } + + // Invalidate each returned ID. + for _, id := range ids { + n.state.Caches.GTS.Notification().Invalidate("ID", id) + } + + return nil +} + +func (n *notificationDB) DeleteNotificationsForStatus(ctx context.Context, statusID string) db.Error { + // Capture notification IDs in a RETURNING statement. + ids := []string{} + + q := n.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). + Where("? = ?", bun.Ident("notification.status_id"), statusID). + Returning("?", bun.Ident("id")) + + if _, err := q.Exec(ctx, &ids); err != nil { + return n.conn.ProcessError(err) + } + + // Invalidate each returned ID. + for _, id := range ids { + n.state.Caches.GTS.Notification().Invalidate("ID", id) + } + return nil } diff --git a/internal/db/bundb/notification_test.go b/internal/db/bundb/notification_test.go index 450ecc332..117fc329c 100644 --- a/internal/db/bundb/notification_test.go +++ b/internal/db/bundb/notification_test.go @@ -19,11 +19,13 @@ import ( "context" + "errors" "fmt" "testing" "time" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/testrig" @@ -112,10 +114,10 @@ func (suite *NotificationTestSuite) TestGetNotificationsWithoutSpam() { } } -func (suite *NotificationTestSuite) TestClearNotificationsWithSpam() { +func (suite *NotificationTestSuite) TestDeleteNotificationsWithSpam() { suite.spamNotifs() testAccount := suite.testAccounts["local_account_1"] - err := suite.db.ClearNotifications(context.Background(), testAccount.ID) + err := suite.db.DeleteNotifications(context.Background(), testAccount.ID, "") suite.NoError(err) notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest) @@ -124,10 +126,10 @@ func (suite *NotificationTestSuite) TestClearNotificationsWithSpam() { suite.Empty(notifications) } -func (suite *NotificationTestSuite) TestClearNotificationsWithTwoAccounts() { +func (suite *NotificationTestSuite) TestDeleteNotificationsWithTwoAccounts() { suite.spamNotifs() testAccount := suite.testAccounts["local_account_1"] - err := suite.db.ClearNotifications(context.Background(), testAccount.ID) + err := suite.db.DeleteNotifications(context.Background(), testAccount.ID, "") suite.NoError(err) notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest) @@ -141,6 +143,69 @@ func (suite *NotificationTestSuite) TestClearNotificationsWithTwoAccounts() { suite.NotEmpty(notif) } +func (suite *NotificationTestSuite) TestDeleteNotificationsOriginatingFromAccount() { + testAccount := suite.testAccounts["local_account_2"] + + if err := suite.db.DeleteNotifications(context.Background(), "", testAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + notif := []*gtsmodel.Notification{} + if err := suite.db.GetAll(context.Background(), ¬if); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, n := range notif { + if n.OriginAccountID == testAccount.ID { + suite.FailNowf("", "no notifications with origin account id %s should remain", testAccount.ID) + } + } +} + +func (suite *NotificationTestSuite) TestDeleteNotificationsOriginatingFromAndTargetingAccount() { + originAccount := suite.testAccounts["local_account_2"] + targetAccount := suite.testAccounts["admin_account"] + + if err := suite.db.DeleteNotifications(context.Background(), targetAccount.ID, originAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + notif := []*gtsmodel.Notification{} + if err := suite.db.GetAll(context.Background(), ¬if); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, n := range notif { + if n.OriginAccountID == originAccount.ID || n.TargetAccountID == targetAccount.ID { + suite.FailNowf( + "", + "no notifications with origin account id %s and target account %s should remain", + originAccount.ID, + targetAccount.ID, + ) + } + } +} + +func (suite *NotificationTestSuite) TestDeleteNotificationsPertainingToStatusID() { + testStatus := suite.testStatuses["local_account_1_status_1"] + + if err := suite.db.DeleteNotificationsForStatus(context.Background(), testStatus.ID); err != nil { + suite.FailNow(err.Error()) + } + + notif := []*gtsmodel.Notification{} + if err := suite.db.GetAll(context.Background(), ¬if); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, n := range notif { + if n.StatusID == testStatus.ID { + suite.FailNowf("", "no notifications with status id %s should remain", testStatus.ID) + } + } +} + func TestNotificationTestSuite(t *testing.T) { suite.Run(t, new(NotificationTestSuite)) } diff --git a/internal/db/bundb/relationship.go b/internal/db/bundb/relationship.go index 90f9cebe7..21a29b5dc 100644 --- a/internal/db/bundb/relationship.go +++ b/internal/db/bundb/relationship.go @@ -19,12 +19,12 @@ import ( "context" - "database/sql" "errors" "fmt" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/uptrace/bun" ) @@ -34,14 +34,6 @@ type relationshipDB struct { state *state.State } -func (r *relationshipDB) newFollowQ(follow interface{}) *bun.SelectQuery { - return r.conn. - NewSelect(). - Model(follow). - Relation("Account"). - Relation("TargetAccount") -} - func (r *relationshipDB) IsBlocked(ctx context.Context, account1 string, account2 string, eitherDirection bool) (bool, db.Error) { // Look for a block in direction of account1->account2 block1, err := r.getBlock(ctx, account1, account2) @@ -305,169 +297,340 @@ func (r *relationshipDB) IsMutualFollowing(ctx context.Context, account1 *gtsmod } func (r *relationshipDB) AcceptFollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (*gtsmodel.Follow, db.Error) { - var follow *gtsmodel.Follow - - if err := r.conn.RunInTx(ctx, func(tx bun.Tx) error { - // get original follow request - followRequest := >smodel.FollowRequest{} - if err := tx. - NewSelect(). - Model(followRequest). - Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). - Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). - Scan(ctx); err != nil { - return err - } - - // create a new follow to 'replace' the request with - follow = >smodel.Follow{ - ID: followRequest.ID, - AccountID: originAccountID, - TargetAccountID: targetAccountID, - URI: followRequest.URI, - } - - // if the follow already exists, just update the URI -- we don't need to do anything else - if _, err := tx. - NewInsert(). - Model(follow). - On("CONFLICT (?,?) DO UPDATE set ? = ?", bun.Ident("account_id"), bun.Ident("target_account_id"), bun.Ident("uri"), follow.URI). - Exec(ctx); err != nil { - return err - } - - // now remove the follow request - if _, err := tx. - NewDelete(). - TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). - Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). - Exec(ctx); err != nil { - return err - } - - return nil - }); err != nil { + // Get original follow request. + var followRequestID string + if err := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id"). + Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). + Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). + Scan(ctx, &followRequestID); err != nil { return nil, r.conn.ProcessError(err) } + followRequest, err := r.getFollowRequest(ctx, followRequestID) + if err != nil { + return nil, r.conn.ProcessError(err) + } + + // Create a new follow to 'replace' + // the original follow request with. + follow := >smodel.Follow{ + ID: followRequest.ID, + AccountID: originAccountID, + Account: followRequest.Account, + TargetAccountID: targetAccountID, + TargetAccount: followRequest.TargetAccount, + URI: followRequest.URI, + } + + // If the follow already exists, just + // replace the URI with the new one. + if _, err := r.conn. + NewInsert(). + Model(follow). + On("CONFLICT (?,?) DO UPDATE set ? = ?", bun.Ident("account_id"), bun.Ident("target_account_id"), bun.Ident("uri"), follow.URI). + Exec(ctx); err != nil { + return nil, r.conn.ProcessError(err) + } + + // Delete original follow request. + if _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). + Exec(ctx); err != nil { + return nil, r.conn.ProcessError(err) + } + + // Delete original follow request notification. + if err := r.deleteFollowRequestNotif(ctx, originAccountID, targetAccountID); err != nil { + return nil, err + } + // return the new follow return follow, nil } func (r *relationshipDB) RejectFollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (*gtsmodel.FollowRequest, db.Error) { - followRequest := >smodel.FollowRequest{} - - if err := r.conn.RunInTx(ctx, func(tx bun.Tx) error { - // get original follow request - if err := tx. - NewSelect(). - Model(followRequest). - Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). - Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). - Scan(ctx); err != nil { - return err - } - - // now delete it from the database by ID - if _, err := tx. - NewDelete(). - TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). - Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). - Exec(ctx); err != nil { - return err - } - - return nil - }); err != nil { + // Get original follow request. + var followRequestID string + if err := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id"). + Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). + Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). + Scan(ctx, &followRequestID); err != nil { return nil, r.conn.ProcessError(err) } - // return the deleted follow request + followRequest, err := r.getFollowRequest(ctx, followRequestID) + if err != nil { + return nil, r.conn.ProcessError(err) + } + + // Delete original follow request. + if _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). + Exec(ctx); err != nil { + return nil, r.conn.ProcessError(err) + } + + // Delete original follow request notification. + if err := r.deleteFollowRequestNotif(ctx, originAccountID, targetAccountID); err != nil { + return nil, err + } + + // Return the now deleted follow request. return followRequest, nil } -func (r *relationshipDB) GetAccountFollowRequests(ctx context.Context, accountID string) ([]*gtsmodel.FollowRequest, db.Error) { - followRequests := []*gtsmodel.FollowRequest{} +func (r *relationshipDB) deleteFollowRequestNotif(ctx context.Context, originAccountID string, targetAccountID string) db.Error { + var id string + if err := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). + Column("notification.id"). + Where("? = ?", bun.Ident("notification.origin_account_id"), originAccountID). + Where("? = ?", bun.Ident("notification.target_account_id"), targetAccountID). + Where("? = ?", bun.Ident("notification.notification_type"), gtsmodel.NotificationFollowRequest). + Limit(1). // There should only be one! + Scan(ctx, &id); err != nil { + err = r.conn.ProcessError(err) + if errors.Is(err, db.ErrNoEntries) { + // If no entries, the notif didn't + // exist anyway so nothing to do here. + return nil + } + // Return on real error. + return err + } - q := r.newFollowQ(&followRequests). - Where("? = ?", bun.Ident("follow_request.target_account_id"), accountID). - Order("follow_request.updated_at DESC") + return r.state.DB.DeleteNotification(ctx, id) +} - if err := q.Scan(ctx); err != nil { +func (r *relationshipDB) getFollow(ctx context.Context, id string) (*gtsmodel.Follow, db.Error) { + follow := >smodel.Follow{} + + err := r.conn. + NewSelect(). + Model(follow). + Where("? = ?", bun.Ident("follow.id"), id). + Scan(ctx) + if err != nil { return nil, r.conn.ProcessError(err) } + follow.Account, err = r.state.DB.GetAccountByID(ctx, follow.AccountID) + if err != nil { + log.Errorf(ctx, "error getting follow account %q: %v", follow.AccountID, err) + } + + follow.TargetAccount, err = r.state.DB.GetAccountByID(ctx, follow.TargetAccountID) + if err != nil { + log.Errorf(ctx, "error getting follow target account %q: %v", follow.TargetAccountID, err) + } + + return follow, nil +} + +func (r *relationshipDB) GetLocalFollowersIDs(ctx context.Context, targetAccountID string) ([]string, db.Error) { + accountIDs := []string{} + + // Select only the account ID of each follow. + q := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + ColumnExpr("? AS ?", bun.Ident("follow.account_id"), bun.Ident("account_id")). + Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID) + + // Join on accounts table to select only + // those with NULL domain (local accounts). + q = q. + Join("JOIN ? AS ? ON ? = ?", + bun.Ident("accounts"), + bun.Ident("account"), + bun.Ident("follow.account_id"), + bun.Ident("account.id"), + ). + Where("? IS NULL", bun.Ident("account.domain")) + + // We don't *really* need to order these, + // but it makes it more consistent to do so. + q = q.Order("account_id DESC") + + if err := q.Scan(ctx, &accountIDs); err != nil { + return nil, r.conn.ProcessError(err) + } + + return accountIDs, nil +} + +func (r *relationshipDB) GetFollows(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.Follow, db.Error) { + ids := []string{} + + q := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + Column("follow.id"). + Order("follow.updated_at DESC") + + if accountID != "" { + q = q.Where("? = ?", bun.Ident("follow.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID) + } + + if err := q.Scan(ctx, &ids); err != nil { + return nil, r.conn.ProcessError(err) + } + + follows := make([]*gtsmodel.Follow, 0, len(ids)) + for _, id := range ids { + follow, err := r.getFollow(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting follow %q: %v", id, err) + continue + } + + follows = append(follows, follow) + } + + return follows, nil +} + +func (r *relationshipDB) CountFollows(ctx context.Context, accountID string, targetAccountID string) (int, db.Error) { + q := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + Column("follow.id") + + if accountID != "" { + q = q.Where("? = ?", bun.Ident("follow.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID) + } + + return q.Count(ctx) +} + +func (r *relationshipDB) getFollowRequest(ctx context.Context, id string) (*gtsmodel.FollowRequest, db.Error) { + followRequest := >smodel.FollowRequest{} + + err := r.conn. + NewSelect(). + Model(followRequest). + Where("? = ?", bun.Ident("follow_request.id"), id). + Scan(ctx) + if err != nil { + return nil, r.conn.ProcessError(err) + } + + followRequest.Account, err = r.state.DB.GetAccountByID(ctx, followRequest.AccountID) + if err != nil { + log.Errorf(ctx, "error getting follow request account %q: %v", followRequest.AccountID, err) + } + + followRequest.TargetAccount, err = r.state.DB.GetAccountByID(ctx, followRequest.TargetAccountID) + if err != nil { + log.Errorf(ctx, "error getting follow request target account %q: %v", followRequest.TargetAccountID, err) + } + + return followRequest, nil +} + +func (r *relationshipDB) GetFollowRequests(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.FollowRequest, db.Error) { + ids := []string{} + + q := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id") + + if accountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID) + } + + if err := q.Scan(ctx, &ids); err != nil { + return nil, r.conn.ProcessError(err) + } + + followRequests := make([]*gtsmodel.FollowRequest, 0, len(ids)) + for _, id := range ids { + followRequest, err := r.getFollowRequest(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting follow request %q: %v", id, err) + continue + } + + followRequests = append(followRequests, followRequest) + } + return followRequests, nil } -func (r *relationshipDB) GetAccountFollows(ctx context.Context, accountID string) ([]*gtsmodel.Follow, db.Error) { - follows := []*gtsmodel.Follow{} - - q := r.newFollowQ(&follows). - Where("? = ?", bun.Ident("follow.account_id"), accountID). - Order("follow.updated_at DESC") - - if err := q.Scan(ctx); err != nil { - return nil, r.conn.ProcessError(err) - } - - return follows, nil -} - -func (r *relationshipDB) CountAccountFollows(ctx context.Context, accountID string, localOnly bool) (int, db.Error) { +func (r *relationshipDB) CountFollowRequests(ctx context.Context, accountID string, targetAccountID string) (int, db.Error) { q := r.conn. NewSelect(). - TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")) + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id"). + Order("follow_request.updated_at DESC") - if localOnly { - q = q. - Join("JOIN ? AS ? ON ? = ?", bun.Ident("accounts"), bun.Ident("account"), bun.Ident("follow.target_account_id"), bun.Ident("account.id")). - Where("? = ?", bun.Ident("follow.account_id"), accountID). - Where("? IS NULL", bun.Ident("account.domain")) - } else { - q = q.Where("? = ?", bun.Ident("follow.account_id"), accountID) + if accountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID) } return q.Count(ctx) } -func (r *relationshipDB) GetAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) ([]*gtsmodel.Follow, db.Error) { - follows := []*gtsmodel.Follow{} +func (r *relationshipDB) Unfollow(ctx context.Context, originAccountID string, targetAccountID string) (string, db.Error) { + uri := new(string) - q := r.conn. - NewSelect(). - Model(&follows). - Order("follow.updated_at DESC") + _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID). + Where("? = ?", bun.Ident("follow.account_id"), originAccountID). + Returning("?", bun.Ident("uri")).Exec(ctx, uri) - if localOnly { - q = q. - Join("JOIN ? AS ? ON ? = ?", bun.Ident("accounts"), bun.Ident("account"), bun.Ident("follow.account_id"), bun.Ident("account.id")). - Where("? = ?", bun.Ident("follow.target_account_id"), accountID). - Where("? IS NULL", bun.Ident("account.domain")) - } else { - q = q.Where("? = ?", bun.Ident("follow.target_account_id"), accountID) + // Only return proper errors. + if err = r.conn.ProcessError(err); err != db.ErrNoEntries { + return *uri, err } - err := q.Scan(ctx) - if err != nil && err != sql.ErrNoRows { - return nil, r.conn.ProcessError(err) - } - return follows, nil + return *uri, nil } -func (r *relationshipDB) CountAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) (int, db.Error) { - q := r.conn. - NewSelect(). - TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")) +func (r *relationshipDB) UnfollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (string, db.Error) { + uri := new(string) - if localOnly { - q = q. - Join("JOIN ? AS ? ON ? = ?", bun.Ident("accounts"), bun.Ident("account"), bun.Ident("follow.account_id"), bun.Ident("account.id")). - Where("? = ?", bun.Ident("follow.target_account_id"), accountID). - Where("? IS NULL", bun.Ident("account.domain")) - } else { - q = q.Where("? = ?", bun.Ident("follow.target_account_id"), accountID) + _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). + Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). + Returning("?", bun.Ident("uri")).Exec(ctx, uri) + + // Only return proper errors. + if err = r.conn.ProcessError(err); err != db.ErrNoEntries { + return *uri, err } - return q.Count(ctx) + return *uri, nil } diff --git a/internal/db/bundb/relationship_test.go b/internal/db/bundb/relationship_test.go index 04a04b088..3d307ecde 100644 --- a/internal/db/bundb/relationship_test.go +++ b/internal/db/bundb/relationship_test.go @@ -289,6 +289,47 @@ func (suite *RelationshipTestSuite) TestAcceptFollowRequestOK() { suite.FailNow(err.Error()) } + followRequestNotification := >smodel.Notification{ + ID: "01GV8MY1Q9KX2ZSWN4FAQ3V1PB", + OriginAccountID: account.ID, + TargetAccountID: targetAccount.ID, + NotificationType: gtsmodel.NotificationFollowRequest, + } + + if err := suite.db.Put(ctx, followRequestNotification); err != nil { + suite.FailNow(err.Error()) + } + + follow, err := suite.db.AcceptFollowRequest(ctx, account.ID, targetAccount.ID) + suite.NoError(err) + suite.NotNil(follow) + suite.Equal(followRequest.URI, follow.URI) + + // Ensure notification is deleted. + notification, err := suite.db.GetNotification(ctx, followRequestNotification.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(notification) +} + +func (suite *RelationshipTestSuite) TestAcceptFollowRequestNoNotification() { + ctx := context.Background() + account := suite.testAccounts["admin_account"] + targetAccount := suite.testAccounts["local_account_2"] + + followRequest := >smodel.FollowRequest{ + ID: "01GEF753FWHCHRDWR0QEHBXM8W", + URI: "http://localhost:8080/weeeeeeeeeeeeeeeee", + AccountID: account.ID, + TargetAccountID: targetAccount.ID, + } + + if err := suite.db.Put(ctx, followRequest); err != nil { + suite.FailNow(err.Error()) + } + + // Unlike the above test, don't create a notification. + // Follow request accept should still produce no error. + follow, err := suite.db.AcceptFollowRequest(ctx, account.ID, targetAccount.ID) suite.NoError(err) suite.NotNil(follow) @@ -352,9 +393,25 @@ func (suite *RelationshipTestSuite) TestRejectFollowRequestOK() { suite.FailNow(err.Error()) } + followRequestNotification := >smodel.Notification{ + ID: "01GV8MY1Q9KX2ZSWN4FAQ3V1PB", + OriginAccountID: account.ID, + TargetAccountID: targetAccount.ID, + NotificationType: gtsmodel.NotificationFollowRequest, + } + + if err := suite.db.Put(ctx, followRequestNotification); err != nil { + suite.FailNow(err.Error()) + } + rejectedFollowRequest, err := suite.db.RejectFollowRequest(ctx, account.ID, targetAccount.ID) suite.NoError(err) suite.NotNil(rejectedFollowRequest) + + // Ensure notification is deleted. + notification, err := suite.db.GetNotification(ctx, followRequestNotification.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(notification) } func (suite *RelationshipTestSuite) TestRejectFollowRequestNotExisting() { @@ -383,58 +440,92 @@ func (suite *RelationshipTestSuite) TestGetAccountFollowRequests() { suite.FailNow(err.Error()) } - followRequests, err := suite.db.GetAccountFollowRequests(ctx, targetAccount.ID) + followRequests, err := suite.db.GetFollowRequests(ctx, "", targetAccount.ID) suite.NoError(err) suite.Len(followRequests, 1) } func (suite *RelationshipTestSuite) TestGetAccountFollows() { account := suite.testAccounts["local_account_1"] - follows, err := suite.db.GetAccountFollows(context.Background(), account.ID) + follows, err := suite.db.GetFollows(context.Background(), account.ID, "") suite.NoError(err) suite.Len(follows, 2) } -func (suite *RelationshipTestSuite) TestCountAccountFollowsLocalOnly() { - account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollows(context.Background(), account.ID, true) - suite.NoError(err) - suite.Equal(2, followsCount) -} - func (suite *RelationshipTestSuite) TestCountAccountFollows() { account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollows(context.Background(), account.ID, false) + followsCount, err := suite.db.CountFollows(context.Background(), account.ID, "") suite.NoError(err) suite.Equal(2, followsCount) } func (suite *RelationshipTestSuite) TestGetAccountFollowedBy() { account := suite.testAccounts["local_account_1"] - follows, err := suite.db.GetAccountFollowedBy(context.Background(), account.ID, false) + follows, err := suite.db.GetFollows(context.Background(), "", account.ID) suite.NoError(err) suite.Len(follows, 2) } -func (suite *RelationshipTestSuite) TestGetAccountFollowedByLocalOnly() { +func (suite *RelationshipTestSuite) TestGetLocalFollowersIDs() { account := suite.testAccounts["local_account_1"] - follows, err := suite.db.GetAccountFollowedBy(context.Background(), account.ID, true) + accountIDs, err := suite.db.GetLocalFollowersIDs(context.Background(), account.ID) suite.NoError(err) - suite.Len(follows, 2) + suite.EqualValues([]string{"01F8MH5NBDF2MV7CTC4Q5128HF", "01F8MH17FWEB39HZJ76B6VXSKF"}, accountIDs) } func (suite *RelationshipTestSuite) TestCountAccountFollowedBy() { account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollowedBy(context.Background(), account.ID, false) + followsCount, err := suite.db.CountFollows(context.Background(), "", account.ID) suite.NoError(err) suite.Equal(2, followsCount) } -func (suite *RelationshipTestSuite) TestCountAccountFollowedByLocalOnly() { - account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollowedBy(context.Background(), account.ID, true) +func (suite *RelationshipTestSuite) TestUnfollowExisting() { + originAccount := suite.testAccounts["local_account_1"] + targetAccount := suite.testAccounts["admin_account"] + + uri, err := suite.db.Unfollow(context.Background(), originAccount.ID, targetAccount.ID) suite.NoError(err) - suite.Equal(2, followsCount) + suite.Equal("http://localhost:8080/users/the_mighty_zork/follow/01F8PY8RHWRQZV038T4E8T9YK8", uri) +} + +func (suite *RelationshipTestSuite) TestUnfollowNotExisting() { + originAccount := suite.testAccounts["local_account_1"] + targetAccountID := "01GTVD9N484CZ6AM90PGGNY7GQ" + + uri, err := suite.db.Unfollow(context.Background(), originAccount.ID, targetAccountID) + suite.NoError(err) + suite.Empty(uri) +} + +func (suite *RelationshipTestSuite) TestUnfollowRequestExisting() { + ctx := context.Background() + originAccount := suite.testAccounts["admin_account"] + targetAccount := suite.testAccounts["local_account_2"] + + followRequest := >smodel.FollowRequest{ + ID: "01GEF753FWHCHRDWR0QEHBXM8W", + URI: "http://localhost:8080/weeeeeeeeeeeeeeeee", + AccountID: originAccount.ID, + TargetAccountID: targetAccount.ID, + } + + if err := suite.db.Put(ctx, followRequest); err != nil { + suite.FailNow(err.Error()) + } + + uri, err := suite.db.UnfollowRequest(context.Background(), originAccount.ID, targetAccount.ID) + suite.NoError(err) + suite.Equal("http://localhost:8080/weeeeeeeeeeeeeeeee", uri) +} + +func (suite *RelationshipTestSuite) TestUnfollowRequestNotExisting() { + originAccount := suite.testAccounts["local_account_1"] + targetAccountID := "01GTVD9N484CZ6AM90PGGNY7GQ" + + uri, err := suite.db.UnfollowRequest(context.Background(), originAccount.ID, targetAccountID) + suite.NoError(err) + suite.Empty(uri) } func TestRelationshipTestSuite(t *testing.T) { diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index 2ea4600e2..deec9a118 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -46,15 +46,6 @@ func (s *statusDB) newStatusQ(status interface{}) *bun.SelectQuery { Relation("CreatedWithApplication") } -func (s *statusDB) newFaveQ(faves interface{}) *bun.SelectQuery { - return s.conn. - NewSelect(). - Model(faves). - Relation("Account"). - Relation("TargetAccount"). - Relation("Status") -} - func (s *statusDB) GetStatusByID(ctx context.Context, id string) (*gtsmodel.Status, db.Error) { return s.getStatus( ctx, @@ -542,20 +533,6 @@ func (s *statusDB) IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.St return s.conn.Exists(ctx, q) } -func (s *statusDB) GetStatusFaves(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.StatusFave, db.Error) { - faves := []*gtsmodel.StatusFave{} - - q := s. - newFaveQ(&faves). - Where("? = ?", bun.Ident("status_fave.status_id"), status.ID) - - if err := q.Scan(ctx); err != nil { - return nil, s.conn.ProcessError(err) - } - - return faves, nil -} - func (s *statusDB) GetStatusReblogs(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.Status, db.Error) { reblogs := []*gtsmodel.Status{} diff --git a/internal/db/bundb/statusbookmark.go b/internal/db/bundb/statusbookmark.go new file mode 100644 index 000000000..92567b89a --- /dev/null +++ b/internal/db/bundb/statusbookmark.go @@ -0,0 +1,198 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bundb + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/uptrace/bun" +) + +type statusBookmarkDB struct { + conn *DBConn + state *state.State +} + +func (s *statusBookmarkDB) GetStatusBookmark(ctx context.Context, id string) (*gtsmodel.StatusBookmark, db.Error) { + bookmark := new(gtsmodel.StatusBookmark) + + err := s.conn. + NewSelect(). + Model(bookmark). + Where("? = ?", bun.Ident("status_bookmark.ID"), id). + Scan(ctx) + if err != nil { + return nil, s.conn.ProcessError(err) + } + + bookmark.Account, err = s.state.DB.GetAccountByID(ctx, bookmark.AccountID) + if err != nil { + return nil, fmt.Errorf("error getting status bookmark account %q: %w", bookmark.AccountID, err) + } + + bookmark.TargetAccount, err = s.state.DB.GetAccountByID(ctx, bookmark.TargetAccountID) + if err != nil { + return nil, fmt.Errorf("error getting status bookmark target account %q: %w", bookmark.TargetAccountID, err) + } + + bookmark.Status, err = s.state.DB.GetStatusByID(ctx, bookmark.StatusID) + if err != nil { + return nil, fmt.Errorf("error getting status bookmark status %q: %w", bookmark.StatusID, err) + } + + return bookmark, nil +} + +func (s *statusBookmarkDB) GetStatusBookmarkID(ctx context.Context, accountID string, statusID string) (string, db.Error) { + var id string + + q := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Column("status_bookmark.id"). + Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID). + Where("? = ?", bun.Ident("status_bookmark.status_id"), statusID). + Limit(1) + + if err := q.Scan(ctx, &id); err != nil { + return "", s.conn.ProcessError(err) + } + + return id, nil +} + +func (s *statusBookmarkDB) GetStatusBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, db.Error) { + // Ensure reasonable + if limit < 0 { + limit = 0 + } + + // Guess size of IDs based on limit. + ids := make([]string, 0, limit) + + q := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Column("status_bookmark.id"). + Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID). + Order("status_bookmark.id DESC") + + if accountID == "" { + return nil, errors.New("must provide an account") + } + + if maxID != "" { + q = q.Where("? < ?", bun.Ident("status_bookmark.id"), maxID) + } + + if minID != "" { + q = q.Where("? > ?", bun.Ident("status_bookmark.id"), minID) + } + + if limit != 0 { + q = q.Limit(limit) + } + + if err := q.Scan(ctx, &ids); err != nil { + return nil, s.conn.ProcessError(err) + } + + bookmarks := make([]*gtsmodel.StatusBookmark, 0, len(ids)) + + for _, id := range ids { + bookmark, err := s.GetStatusBookmark(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting bookmark %q: %v", id, err) + continue + } + + bookmarks = append(bookmarks, bookmark) + } + + return bookmarks, nil +} + +func (s *statusBookmarkDB) PutStatusBookmark(ctx context.Context, statusBookmark *gtsmodel.StatusBookmark) db.Error { + _, err := s.conn. + NewInsert(). + Model(statusBookmark). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusBookmarkDB) DeleteStatusBookmark(ctx context.Context, id string) db.Error { + _, err := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Where("? = ?", bun.Ident("status_bookmark.id"), id). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusBookmarkDB) DeleteStatusBookmarks(ctx context.Context, targetAccountID string, originAccountID string) db.Error { + if targetAccountID == "" && originAccountID == "" { + return errors.New("DeleteBookmarks: one of targetAccountID or originAccountID must be set") + } + + // TODO: Capture bookmark IDs in a RETURNING + // statement (when bookmarks have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")) + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_bookmark.target_account_id"), targetAccountID) + } + + if originAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_bookmark.account_id"), originAccountID) + } + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} + +func (s *statusBookmarkDB) DeleteStatusBookmarksForStatus(ctx context.Context, statusID string) db.Error { + // TODO: Capture bookmark IDs in a RETURNING + // statement (when bookmarks have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Where("? = ?", bun.Ident("status_bookmark.status_id"), statusID) + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} diff --git a/internal/db/bundb/statusbookmark_test.go b/internal/db/bundb/statusbookmark_test.go new file mode 100644 index 000000000..35ed4a9da --- /dev/null +++ b/internal/db/bundb/statusbookmark_test.go @@ -0,0 +1,129 @@ +/* + GoToSocial + Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package bundb_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusBookmarkTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkIDOK() { + testBookmark := suite.testBookmarks["local_account_1_admin_account_status_1"] + + id, err := suite.db.GetStatusBookmarkID(context.Background(), testBookmark.AccountID, testBookmark.StatusID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testBookmark.ID, id) +} + +func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkIDNonexisting() { + id, err := suite.db.GetStatusBookmarkID(context.Background(), "01GVAVGD06YJ2FSB5GJSMF8M2K", "01GVAVGKGR1MK9ZN7JCJFYSFZV") + suite.Empty(id) + suite.ErrorIs(err, db.ErrNoEntries) +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksOriginatingFromAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusBookmarks(context.Background(), "", testAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + bookmarks := []*gtsmodel.StatusBookmark{} + if err := suite.db.GetAll(context.Background(), &bookmarks); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range bookmarks { + if b.AccountID == testAccount.ID { + suite.FailNowf("", "no StatusBookmarks with account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksTargetingAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusBookmarks(context.Background(), testAccount.ID, ""); err != nil { + suite.FailNow(err.Error()) + } + + bookmarks := []*gtsmodel.StatusBookmark{} + if err := suite.db.GetAll(context.Background(), &bookmarks); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range bookmarks { + if b.TargetAccountID == testAccount.ID { + suite.FailNowf("", "no StatusBookmarks with target account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksTargetingStatus() { + testStatus := suite.testStatuses["local_account_1_status_1"] + + if err := suite.db.DeleteStatusBookmarksForStatus(context.Background(), testStatus.ID); err != nil { + suite.FailNow(err.Error()) + } + + bookmarks := []*gtsmodel.StatusBookmark{} + if err := suite.db.GetAll(context.Background(), &bookmarks); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range bookmarks { + if b.StatusID == testStatus.ID { + suite.FailNowf("", "no StatusBookmarks with status id %s should remain", testStatus.ID) + } + } +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmark() { + testBookmark := suite.testBookmarks["local_account_1_admin_account_status_1"] + ctx := context.Background() + + if err := suite.db.DeleteStatusBookmark(ctx, testBookmark.ID); err != nil { + suite.FailNow(err.Error()) + } + + bookmark, err := suite.db.GetStatusBookmark(ctx, testBookmark.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(bookmark) +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarkNonExisting() { + err := suite.db.DeleteStatusBookmark(context.Background(), "01GVAV715K6Y2SG9ZKS9ZA8G7G") + suite.NoError(err) +} + +func TestStatusBookmarkTestSuite(t *testing.T) { + suite.Run(t, new(StatusBookmarkTestSuite)) +} diff --git a/internal/db/bundb/statusfave.go b/internal/db/bundb/statusfave.go new file mode 100644 index 000000000..c42ab249f --- /dev/null +++ b/internal/db/bundb/statusfave.go @@ -0,0 +1,172 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bundb + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/uptrace/bun" +) + +type statusFaveDB struct { + conn *DBConn + state *state.State +} + +func (s *statusFaveDB) GetStatusFave(ctx context.Context, id string) (*gtsmodel.StatusFave, db.Error) { + fave := new(gtsmodel.StatusFave) + + err := s.conn. + NewSelect(). + Model(fave). + Where("? = ?", bun.Ident("status_fave.ID"), id). + Scan(ctx) + if err != nil { + return nil, s.conn.ProcessError(err) + } + + fave.Account, err = s.state.DB.GetAccountByID(ctx, fave.AccountID) + if err != nil { + return nil, fmt.Errorf("error getting status fave account %q: %w", fave.AccountID, err) + } + + fave.TargetAccount, err = s.state.DB.GetAccountByID(ctx, fave.TargetAccountID) + if err != nil { + return nil, fmt.Errorf("error getting status fave target account %q: %w", fave.TargetAccountID, err) + } + + fave.Status, err = s.state.DB.GetStatusByID(ctx, fave.StatusID) + if err != nil { + return nil, fmt.Errorf("error getting status fave status %q: %w", fave.StatusID, err) + } + + return fave, nil +} + +func (s *statusFaveDB) GetStatusFaveByAccountID(ctx context.Context, accountID string, statusID string) (*gtsmodel.StatusFave, db.Error) { + var id string + + err := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Column("status_fave.id"). + Where("? = ?", bun.Ident("status_fave.account_id"), accountID). + Where("? = ?", bun.Ident("status_fave.status_id"), statusID). + Scan(ctx, &id) + if err != nil { + return nil, s.conn.ProcessError(err) + } + + return s.GetStatusFave(ctx, id) +} + +func (s *statusFaveDB) GetStatusFaves(ctx context.Context, statusID string) ([]*gtsmodel.StatusFave, db.Error) { + ids := []string{} + + if err := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Column("status_fave.id"). + Where("? = ?", bun.Ident("status_fave.status_id"), statusID). + Scan(ctx, &ids); err != nil { + return nil, s.conn.ProcessError(err) + } + + faves := make([]*gtsmodel.StatusFave, 0, len(ids)) + for _, id := range ids { + fave, err := s.GetStatusFave(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting status fave %q: %v", id, err) + continue + } + + faves = append(faves, fave) + } + + return faves, nil +} + +func (s *statusFaveDB) PutStatusFave(ctx context.Context, statusFave *gtsmodel.StatusFave) db.Error { + _, err := s.conn. + NewInsert(). + Model(statusFave). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusFaveDB) DeleteStatusFave(ctx context.Context, id string) db.Error { + _, err := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Where("? = ?", bun.Ident("status_fave.id"), id). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusFaveDB) DeleteStatusFaves(ctx context.Context, targetAccountID string, originAccountID string) db.Error { + if targetAccountID == "" && originAccountID == "" { + return errors.New("DeleteStatusFaves: one of targetAccountID or originAccountID must be set") + } + + // TODO: Capture fave IDs in a RETURNING + // statement (when faves have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")) + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_fave.target_account_id"), targetAccountID) + } + + if originAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_fave.account_id"), originAccountID) + } + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} + +func (s *statusFaveDB) DeleteStatusFavesForStatus(ctx context.Context, statusID string) db.Error { + // TODO: Capture fave IDs in a RETURNING + // statement (when faves have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Where("? = ?", bun.Ident("status_fave.status_id"), statusID) + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} diff --git a/internal/db/bundb/statusfave_test.go b/internal/db/bundb/statusfave_test.go new file mode 100644 index 000000000..98e495bf3 --- /dev/null +++ b/internal/db/bundb/statusfave_test.go @@ -0,0 +1,148 @@ +/* + GoToSocial + Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package bundb_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusFaveTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *StatusFaveTestSuite) TestGetStatusFaves() { + testStatus := suite.testStatuses["admin_account_status_1"] + + faves, err := suite.db.GetStatusFaves(context.Background(), testStatus.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.NotEmpty(faves) + for _, fave := range faves { + suite.NotNil(fave.Account) + suite.NotNil(fave.TargetAccount) + suite.NotNil(fave.Status) + } +} + +func (suite *StatusFaveTestSuite) TestGetStatusFavesNone() { + testStatus := suite.testStatuses["admin_account_status_4"] + + faves, err := suite.db.GetStatusFaves(context.Background(), testStatus.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Empty(faves) +} + +func (suite *StatusFaveTestSuite) TestGetStatusFaveByAccountID() { + testAccount := suite.testAccounts["local_account_1"] + testStatus := suite.testStatuses["admin_account_status_1"] + + fave, err := suite.db.GetStatusFaveByAccountID(context.Background(), testAccount.ID, testStatus.ID) + suite.NoError(err) + suite.NotNil(fave) +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFavesOriginatingFromAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusFaves(context.Background(), "", testAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + faves := []*gtsmodel.StatusFave{} + if err := suite.db.GetAll(context.Background(), &faves); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range faves { + if b.AccountID == testAccount.ID { + suite.FailNowf("", "no StatusFaves with account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFavesTargetingAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusFaves(context.Background(), testAccount.ID, ""); err != nil { + suite.FailNow(err.Error()) + } + + faves := []*gtsmodel.StatusFave{} + if err := suite.db.GetAll(context.Background(), &faves); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range faves { + if b.TargetAccountID == testAccount.ID { + suite.FailNowf("", "no StatusFaves with target account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFavesTargetingStatus() { + testStatus := suite.testStatuses["local_account_1_status_1"] + + if err := suite.db.DeleteStatusFavesForStatus(context.Background(), testStatus.ID); err != nil { + suite.FailNow(err.Error()) + } + + faves := []*gtsmodel.StatusFave{} + if err := suite.db.GetAll(context.Background(), &faves); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range faves { + if b.StatusID == testStatus.ID { + suite.FailNowf("", "no StatusFaves with status id %s should remain", testStatus.ID) + } + } +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFave() { + testFave := suite.testFaves["local_account_1_admin_account_status_1"] + ctx := context.Background() + + if err := suite.db.DeleteStatusFave(ctx, testFave.ID); err != nil { + suite.FailNow(err.Error()) + } + + fave, err := suite.db.GetStatusFave(ctx, testFave.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(fave) +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFaveNonExisting() { + err := suite.db.DeleteStatusFave(context.Background(), "01GVAV715K6Y2SG9ZKS9ZA8G7G") + suite.NoError(err) +} + +func TestStatusFaveTestSuite(t *testing.T) { + suite.Run(t, new(StatusFaveTestSuite)) +} diff --git a/internal/db/db.go b/internal/db/db.go index c4a283395..7b25b3dae 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -43,6 +43,8 @@ type DB interface { Report Session Status + StatusBookmark + StatusFave Timeline User Tombstone diff --git a/internal/db/notification.go b/internal/db/notification.go index 19f0f199d..18e40b4c1 100644 --- a/internal/db/notification.go +++ b/internal/db/notification.go @@ -29,8 +29,31 @@ type Notification interface { // // Returned notifications will be ordered ID descending (ie., highest/newest to lowest/oldest). GetNotifications(ctx context.Context, accountID string, excludeTypes []string, limit int, maxID string, sinceID string) ([]*gtsmodel.Notification, Error) + // GetNotification returns one notification according to its id. GetNotification(ctx context.Context, id string) (*gtsmodel.Notification, Error) - // ClearNotifications deletes every notification that pertain to the given accountID. - ClearNotifications(ctx context.Context, accountID string) Error + + // DeleteNotification deletes one notification according to its id, + // and removes that notification from the in-memory cache. + DeleteNotification(ctx context.Context, id string) Error + + // DeleteNotifications mass deletes notifications targeting targetAccountID + // and/or originating from originAccountID. + // + // If targetAccountID is set and originAccountID isn't, all notifications + // that target the given account will be deleted. + // + // If originAccountID is set and targetAccountID isn't, all notifications + // originating from the given account will be deleted. + // + // If both are set, then notifications that target targetAccountID and + // originate from originAccountID will be deleted. + // + // At least one parameter must not be an empty string. + DeleteNotifications(ctx context.Context, targetAccountID string, originAccountID string) Error + + // DeleteNotificationsForStatus deletes all notifications that relate to + // the given statusID. This function is useful when a status has been deleted, + // and so notifications relating to that status must also be deleted. + DeleteNotificationsForStatus(ctx context.Context, statusID string) Error } diff --git a/internal/db/relationship.go b/internal/db/relationship.go index 643a5696a..d13a73dea 100644 --- a/internal/db/relationship.go +++ b/internal/db/relationship.go @@ -73,22 +73,61 @@ type Relationship interface { // The deleted follow request will be returned so that further processing can be done on it. RejectFollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (*gtsmodel.FollowRequest, Error) - // GetAccountFollowRequests returns all follow requests targeting the given account. - GetAccountFollowRequests(ctx context.Context, accountID string) ([]*gtsmodel.FollowRequest, Error) - - // GetAccountFollows returns a slice of follows owned by the given accountID. - GetAccountFollows(ctx context.Context, accountID string) ([]*gtsmodel.Follow, Error) - - // CountAccountFollows returns the amount of accounts that the given accountID is following. + // GetFollows returns a slice of follows owned by the given accountID, and/or + // targeting the given account id. // - // If localOnly is set to true, then only follows from *this instance* will be returned. - CountAccountFollows(ctx context.Context, accountID string, localOnly bool) (int, Error) - - // GetAccountFollowedBy fetches follows that target given accountID. + // If accountID is set and targetAccountID isn't, then all follows created by + // accountID will be returned. // - // If localOnly is set to true, then only follows from *this instance* will be returned. - GetAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) ([]*gtsmodel.Follow, Error) + // If targetAccountID is set and accountID isn't, then all follows targeting + // targetAccountID will be returned. + // + // If both accountID and targetAccountID are set, then only 0 or 1 follows will + // be in the returned slice. + GetFollows(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.Follow, Error) - // CountAccountFollowedBy returns the amounts that the given ID is followed by. - CountAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) (int, Error) + // GetLocalFollowersIDs returns a list of local account IDs which follow the + // targetAccountID. The returned IDs are not guaranteed to be ordered in any + // particular way, so take care. + GetLocalFollowersIDs(ctx context.Context, targetAccountID string) ([]string, Error) + + // CountFollows is like GetFollows, but just counts rather than returning. + CountFollows(ctx context.Context, accountID string, targetAccountID string) (int, Error) + + // GetFollowRequests returns a slice of follows requests owned by the given + // accountID, and/or targeting the given account id. + // + // If accountID is set and targetAccountID isn't, then all requests created by + // accountID will be returned. + // + // If targetAccountID is set and accountID isn't, then all requests targeting + // targetAccountID will be returned. + // + // If both accountID and targetAccountID are set, then only 0 or 1 requests will + // be in the returned slice. + GetFollowRequests(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.FollowRequest, Error) + + // CountFollowRequests is like GetFollowRequests, but just counts rather than returning. + CountFollowRequests(ctx context.Context, accountID string, targetAccountID string) (int, Error) + + // Unfollow removes a follow targeting targetAccountID and originating + // from originAccountID. + // + // If a follow was removed this way, the AP URI of the follow will be + // returned to the caller, so that further processing can take place + // if necessary. + // + // If no follow was removed this way, the returned string will be empty. + Unfollow(ctx context.Context, originAccountID string, targetAccountID string) (string, Error) + + // UnfollowRequest removes a follow request targeting targetAccountID + // and originating from originAccountID. + // + // If a follow request was removed this way, the AP URI of the follow + // request will be returned to the caller, so that further processing + // can take place if necessary. + // + // If no follow request was removed this way, the returned string will + // be empty. + UnfollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (string, Error) } diff --git a/internal/db/status.go b/internal/db/status.go index 7744570b2..16728983a 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -77,10 +77,6 @@ type Status interface { // IsStatusBookmarkedBy checks if a given status has been bookmarked by a given account ID IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, Error) - // GetStatusFaves returns a slice of faves/likes of the given status. - // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. - GetStatusFaves(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.StatusFave, Error) - // GetStatusReblogs returns a slice of statuses that are a boost/reblog of the given status. // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. GetStatusReblogs(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.Status, Error) diff --git a/internal/db/statusbookmark.go b/internal/db/statusbookmark.go new file mode 100644 index 000000000..e47bfc54d --- /dev/null +++ b/internal/db/statusbookmark.go @@ -0,0 +1,66 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package db + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusBookmark interface { + // GetStatusBookmark gets one status bookmark with the given ID. + GetStatusBookmark(ctx context.Context, id string) (*gtsmodel.StatusBookmark, Error) + + // GetStatusBookmarkID is a shortcut function for returning just the database ID + // of a status bookmark created by the given accountID, targeting the given statusID. + GetStatusBookmarkID(ctx context.Context, accountID string, statusID string) (string, Error) + + // GetStatusBookmarks retrieves status bookmarks created by the given accountID, + // and using the provided parameters. If limit is < 0 then no limit will be set. + // + // This function is primarily useful for paging through bookmarks in a sort of + // timeline view. + GetStatusBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, Error) + + // PutStatusBookmark inserts the given statusBookmark into the database. + PutStatusBookmark(ctx context.Context, statusBookmark *gtsmodel.StatusBookmark) Error + + // DeleteStatusBookmark deletes one status bookmark with the given ID. + DeleteStatusBookmark(ctx context.Context, id string) Error + + // DeleteStatusBookmarks mass deletes status bookmarks targeting targetAccountID + // and/or originating from originAccountID and/or bookmarking statusID. + // + // If targetAccountID is set and originAccountID isn't, all status bookmarks + // that target the given account will be deleted. + // + // If originAccountID is set and targetAccountID isn't, all status bookmarks + // originating from the given account will be deleted. + // + // If both are set, then status bookmarks that target targetAccountID and + // originate from originAccountID will be deleted. + // + // At least one parameter must not be an empty string. + DeleteStatusBookmarks(ctx context.Context, targetAccountID string, originAccountID string) Error + + // DeleteStatusBookmarksForStatus deletes all status bookmarks that target the + // given status ID. This is useful when a status has been deleted, and you need + // to clean up after it. + DeleteStatusBookmarksForStatus(ctx context.Context, statusID string) Error +} diff --git a/internal/db/statusfave.go b/internal/db/statusfave.go new file mode 100644 index 000000000..2d55592aa --- /dev/null +++ b/internal/db/statusfave.go @@ -0,0 +1,63 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package db + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusFave interface { + // GetStatusFave returns one status fave with the given id. + GetStatusFave(ctx context.Context, id string) (*gtsmodel.StatusFave, Error) + + // GetStatusFaveByAccountID gets one status fave created by the given + // accountID, targeting the given statusID. + GetStatusFaveByAccountID(ctx context.Context, accountID string, statusID string) (*gtsmodel.StatusFave, Error) + + // GetStatusFaves returns a slice of faves/likes of the given status. + // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. + GetStatusFaves(ctx context.Context, statusID string) ([]*gtsmodel.StatusFave, Error) + + // PutStatusFave inserts the given statusFave into the database. + PutStatusFave(ctx context.Context, statusFave *gtsmodel.StatusFave) Error + + // DeleteStatusFave deletes one status fave with the given id. + DeleteStatusFave(ctx context.Context, id string) Error + + // DeleteStatusFaves mass deletes status faves targeting targetAccountID + // and/or originating from originAccountID and/or faving statusID. + // + // If targetAccountID is set and originAccountID isn't, all status faves + // that target the given account will be deleted. + // + // If originAccountID is set and targetAccountID isn't, all status faves + // originating from the given account will be deleted. + // + // If both are set, then status faves that target targetAccountID and + // originate from originAccountID will be deleted. + // + // At least one parameter must not be an empty string. + DeleteStatusFaves(ctx context.Context, targetAccountID string, originAccountID string) Error + + // DeleteStatusFavesForStatus deletes all status faves that target the + // given status ID. This is useful when a status has been deleted, and you need + // to clean up after it. + DeleteStatusFavesForStatus(ctx context.Context, statusID string) Error +} diff --git a/internal/federation/federatingdb/federatingdb_test.go b/internal/federation/federatingdb/federatingdb_test.go index 99935ef55..32495a2f8 100644 --- a/internal/federation/federatingdb/federatingdb_test.go +++ b/internal/federation/federatingdb/federatingdb_test.go @@ -69,8 +69,10 @@ func (suite *FederatingDBTestSuite) SetupTest() { testrig.StartWorkers(&suite.state) suite.fromFederator = make(chan messages.FromFederator, 10) - suite.state.Workers.EnqueueFederator = func(ctx context.Context, msg messages.FromFederator) { - suite.fromFederator <- msg + suite.state.Workers.EnqueueFederator = func(ctx context.Context, msgs ...messages.FromFederator) { + for _, msg := range msgs { + suite.fromFederator <- msg + } } suite.db = testrig.NewTestDB(&suite.state) diff --git a/internal/federation/federatingdb/followers.go b/internal/federation/federatingdb/followers.go index d9a359227..d829a304c 100644 --- a/internal/federation/federatingdb/followers.go +++ b/internal/federation/federatingdb/followers.go @@ -22,9 +22,7 @@ "fmt" "net/url" - "codeberg.org/gruf/go-kv" "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -35,43 +33,30 @@ // // The library makes this call only after acquiring a lock first. func (f *federatingDB) Followers(ctx context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - l := log.WithContext(ctx). - WithFields(kv.Fields{ - {"id", actorIRI}, - }...) - l.Debug("entering Followers") - acct, err := f.getAccountForIRI(ctx, actorIRI) if err != nil { return nil, err } - acctFollowers, err := f.state.DB.GetAccountFollowedBy(ctx, acct.ID, false) + follows, err := f.state.DB.GetFollows(ctx, "", acct.ID) if err != nil { return nil, fmt.Errorf("Followers: db error getting followers for account id %s: %s", acct.ID, err) } - iris := []*url.URL{} - for _, follow := range acctFollowers { + iris := make([]*url.URL, 0, len(follows)) + for _, follow := range follows { if follow.Account == nil { - a, err := f.state.DB.GetAccountByID(ctx, follow.AccountID) - if err != nil { - errWrapped := fmt.Errorf("Followers: db error getting account id %s: %s", follow.AccountID, err) - if err == db.ErrNoEntries { - // no entry for this account id so it's probably been deleted and we haven't caught up yet - l.Error(errWrapped) - continue - } else { - // proper error - return nil, errWrapped - } - } - follow.Account = a + // Follow account no longer exists, + // for some reason. Skip this one. + log.WithContext(ctx).WithField("follow", follow).Warnf("follow missing account %s", follow.AccountID) + continue } + u, err := url.Parse(follow.Account.URI) if err != nil { return nil, err } + iris = append(iris, u) } diff --git a/internal/federation/federatingdb/following.go b/internal/federation/federatingdb/following.go index e42f90ad1..7b0db5660 100644 --- a/internal/federation/federatingdb/following.go +++ b/internal/federation/federatingdb/following.go @@ -22,9 +22,7 @@ "fmt" "net/url" - "codeberg.org/gruf/go-kv" "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -35,39 +33,25 @@ // // The library makes this call only after acquiring a lock first. func (f *federatingDB) Following(ctx context.Context, actorIRI *url.URL) (following vocab.ActivityStreamsCollection, err error) { - l := log.WithContext(ctx). - WithFields(kv.Fields{ - {"id", actorIRI}, - }...) - l.Debug("entering Following") - acct, err := f.getAccountForIRI(ctx, actorIRI) if err != nil { return nil, err } - acctFollowing, err := f.state.DB.GetAccountFollows(ctx, acct.ID) + follows, err := f.state.DB.GetFollows(ctx, acct.ID, "") if err != nil { - return nil, fmt.Errorf("Following: db error getting following for account id %s: %s", acct.ID, err) + return nil, fmt.Errorf("Following: db error getting following for account id %s: %w", acct.ID, err) } - iris := []*url.URL{} - for _, follow := range acctFollowing { + iris := make([]*url.URL, 0, len(follows)) + for _, follow := range follows { if follow.TargetAccount == nil { - a, err := f.state.DB.GetAccountByID(ctx, follow.TargetAccountID) - if err != nil { - errWrapped := fmt.Errorf("Following: db error getting account id %s: %s", follow.TargetAccountID, err) - if err == db.ErrNoEntries { - // no entry for this account id so it's probably been deleted and we haven't caught up yet - l.Error(errWrapped) - continue - } else { - // proper error - return nil, errWrapped - } - } - follow.TargetAccount = a + // Follow target account no longer exists, + // for some reason. Skip this one. + log.WithContext(ctx).WithField("follow", follow).Warnf("follow missing target account %s", follow.TargetAccountID) + continue } + u, err := url.Parse(follow.TargetAccount.URI) if err != nil { return nil, err diff --git a/internal/federation/federatingdb/inbox.go b/internal/federation/federatingdb/inbox.go index 28664cbaf..954a192e4 100644 --- a/internal/federation/federatingdb/inbox.go +++ b/internal/federation/federatingdb/inbox.go @@ -89,22 +89,16 @@ func (f *federatingDB) InboxesForIRI(c context.Context, iri *url.URL) (inboxIRIs return nil, fmt.Errorf("couldn't find local account with username %s: %s", localAccountUsername, err) } - follows, err := f.state.DB.GetAccountFollowedBy(c, account.ID, false) + follows, err := f.state.DB.GetFollows(c, "", account.ID) if err != nil { return nil, fmt.Errorf("couldn't get followers of local account %s: %s", localAccountUsername, err) } for _, follow := range follows { - // make sure we retrieved the following account from the db if follow.Account == nil { - followingAccount, err := f.state.DB.GetAccountByID(c, follow.AccountID) - if err != nil { - if err == db.ErrNoEntries { - continue - } - return nil, fmt.Errorf("error retrieving account with id %s: %s", follow.AccountID, err) - } - follow.Account = followingAccount + // No account exists for this follow, + // for some reason. Just skip it. + continue } // deliver to a shared inbox if we have that option diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 667ad793f..0898e707b 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -91,8 +91,10 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.fromClientAPIChan = make(chan messages.FromClientAPI, 100) - suite.state.Workers.EnqueueClientAPI = func(ctx context.Context, msg messages.FromClientAPI) { - suite.fromClientAPIChan <- msg + suite.state.Workers.EnqueueClientAPI = func(ctx context.Context, msgs ...messages.FromClientAPI) { + for _, msg := range msgs { + suite.fromClientAPIChan <- msg + } } suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) diff --git a/internal/processing/account/block.go b/internal/processing/account/block.go index 5564bb8c9..1ec31a753 100644 --- a/internal/processing/account/block.go +++ b/internal/processing/account/block.go @@ -34,118 +34,53 @@ // BlockCreate handles the creation of a block from requestingAccount to targetAccountID, either remote or local. func (p *Processor) BlockCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { - // make sure the target account actually exists in our db - targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("BlockCreate: error getting account %s from the db: %s", targetAccountID, err)) + targetAccount, existingBlock, errWithCode := p.getBlockTarget(ctx, requestingAccount, targetAccountID) + if errWithCode != nil { + return nil, errWithCode } - // if requestingAccount already blocks target account, we don't need to do anything - if blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, targetAccountID, false); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error checking existence of block: %s", err)) - } else if blocked { + if existingBlock != nil { + // Block already exists, nothing to do. return p.RelationshipGet(ctx, requestingAccount, targetAccountID) } - // don't block yourself, silly - if requestingAccount.ID == targetAccountID { - return nil, gtserror.NewErrorNotAcceptable(fmt.Errorf("BlockCreate: account %s cannot block itself", requestingAccount.ID)) + // Create and store a new block. + blockID := id.NewULID() + blockURI := uris.GenerateURIForBlock(requestingAccount.Username, blockID) + block := >smodel.Block{ + ID: blockID, + URI: blockURI, + AccountID: requestingAccount.ID, + Account: requestingAccount, + TargetAccountID: targetAccountID, + TargetAccount: targetAccount, } - // make the block - block := >smodel.Block{} - newBlockID := id.NewULID() - block.ID = newBlockID - block.AccountID = requestingAccount.ID - block.Account = requestingAccount - block.TargetAccountID = targetAccountID - block.TargetAccount = targetAccount - block.URI = uris.GenerateURIForBlock(requestingAccount.Username, newBlockID) - - // whack it in the database if err := p.state.DB.PutBlock(ctx, block); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error creating block in db: %s", err)) + err = fmt.Errorf("BlockCreate: error creating block in db: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // clear any follows or follow requests from the blocked account to the target account -- this is a simple delete - if err := p.state.DB.DeleteWhere(ctx, []db.Where{ - {Key: "account_id", Value: targetAccountID}, - {Key: "target_account_id", Value: requestingAccount.ID}, - }, >smodel.Follow{}); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow in db: %s", err)) - } - if err := p.state.DB.DeleteWhere(ctx, []db.Where{ - {Key: "account_id", Value: targetAccountID}, - {Key: "target_account_id", Value: requestingAccount.ID}, - }, >smodel.FollowRequest{}); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow in db: %s", err)) + // Ensure each account unfollows the other. + // We only care about processing unfollow side + // effects from requesting account -> target + // account, since requesting account is ours, + // and target account might not be. + msgs, err := p.unfollow(ctx, requestingAccount, targetAccount) + if err != nil { + err = fmt.Errorf("BlockCreate: error unfollowing: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // clear any follows or follow requests from the requesting account to the target account -- - // this might require federation so we need to pass some messages around - - // check if a follow request exists from the requesting account to the target account, and remove it if it does (storing the URI for later) - var frChanged bool - var frURI string - fr := >smodel.FollowRequest{} - if err := p.state.DB.GetWhere(ctx, []db.Where{ - {Key: "account_id", Value: requestingAccount.ID}, - {Key: "target_account_id", Value: targetAccountID}, - }, fr); err == nil { - frURI = fr.URI - if err := p.state.DB.DeleteByID(ctx, fr.ID, fr); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow request from db: %s", err)) - } - frChanged = true + // Ensure unfollowed in other direction; + // ignore/don't process returned messages. + if _, err := p.unfollow(ctx, targetAccount, requestingAccount); err != nil { + err = fmt.Errorf("BlockCreate: error unfollowing: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // now do the same thing for any existing follow - var fChanged bool - var fURI string - f := >smodel.Follow{} - if err := p.state.DB.GetWhere(ctx, []db.Where{ - {Key: "account_id", Value: requestingAccount.ID}, - {Key: "target_account_id", Value: targetAccountID}, - }, f); err == nil { - fURI = f.URI - if err := p.state.DB.DeleteByID(ctx, f.ID, f); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow from db: %s", err)) - } - fChanged = true - } - - // follow request status changed so send the UNDO activity to the channel for async processing - if frChanged { - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityUndo, - GTSModel: >smodel.Follow{ - AccountID: requestingAccount.ID, - TargetAccountID: targetAccountID, - URI: frURI, - }, - OriginAccount: requestingAccount, - TargetAccount: targetAccount, - }) - } - - // follow status changed so send the UNDO activity to the channel for async processing - if fChanged { - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityUndo, - GTSModel: >smodel.Follow{ - AccountID: requestingAccount.ID, - TargetAccountID: targetAccountID, - URI: fURI, - }, - OriginAccount: requestingAccount, - TargetAccount: targetAccount, - }) - } - - // handle the rest of the block process asynchronously - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + // Process block side effects (federation etc). + msgs = append(msgs, messages.FromClientAPI{ APObjectType: ap.ActivityBlock, APActivityType: ap.ActivityCreate, GTSModel: block, @@ -153,39 +88,72 @@ func (p *Processor) BlockCreate(ctx context.Context, requestingAccount *gtsmodel TargetAccount: targetAccount, }) + // Batch queue accreted client api messages. + p.state.Workers.EnqueueClientAPI(ctx, msgs...) + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) } // BlockRemove handles the removal of a block from requestingAccount to targetAccountID, either remote or local. func (p *Processor) BlockRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { - // make sure the target account actually exists in our db - targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("BlockCreate: error getting account %s from the db: %s", targetAccountID, err)) + targetAccount, existingBlock, errWithCode := p.getBlockTarget(ctx, requestingAccount, targetAccountID) + if errWithCode != nil { + return nil, errWithCode } - // check if a block exists, and remove it if it does - block, err := p.state.DB.GetBlock(ctx, requestingAccount.ID, targetAccountID) - if err == nil { - // we got a block, remove it - block.Account = requestingAccount - block.TargetAccount = targetAccount - if err := p.state.DB.DeleteBlockByID(ctx, block.ID); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockRemove: error removing block from db: %s", err)) - } - - // send the UNDO activity to the client worker for async processing - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ActivityBlock, - APActivityType: ap.ActivityUndo, - GTSModel: block, - OriginAccount: requestingAccount, - TargetAccount: targetAccount, - }) - } else if !errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockRemove: error getting possible block from db: %s", err)) + if existingBlock == nil { + // Already not blocked, nothing to do. + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) } - // return whatever relationship results from all this + // We got a block, remove it from the db. + if err := p.state.DB.DeleteBlockByID(ctx, existingBlock.ID); err != nil { + err := fmt.Errorf("BlockRemove: error removing block from db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Populate account fields for convenience. + existingBlock.Account = requestingAccount + existingBlock.TargetAccount = targetAccount + + // Process block removal side effects (federation etc). + p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + APObjectType: ap.ActivityBlock, + APActivityType: ap.ActivityUndo, + GTSModel: existingBlock, + OriginAccount: requestingAccount, + TargetAccount: targetAccount, + }) + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) } + +func (p *Processor) getBlockTarget(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*gtsmodel.Account, *gtsmodel.Block, gtserror.WithCode) { + // Account should not block or unblock itself. + if requestingAccount.ID == targetAccountID { + err := fmt.Errorf("getBlockTarget: account %s cannot block or unblock itself", requestingAccount.ID) + return nil, nil, gtserror.NewErrorNotAcceptable(err, err.Error()) + } + + // Ensure target account retrievable. + targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real db error. + err = fmt.Errorf("getBlockTarget: db error looking for target account %s: %w", targetAccountID, err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + // Account not found. + err = fmt.Errorf("getBlockTarget: target account %s not found in the db", targetAccountID) + return nil, nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // Check if currently blocked. + block, err := p.state.DB.GetBlock(ctx, requestingAccount.ID, targetAccountID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("getBlockTarget: db error checking existing block: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + return targetAccount, block, nil +} diff --git a/internal/processing/account/bookmarks.go b/internal/processing/account/bookmarks.go index add3d8d8d..56f0fc9e0 100644 --- a/internal/processing/account/bookmarks.go +++ b/internal/processing/account/bookmarks.go @@ -33,7 +33,7 @@ // BookmarksGet returns a pageable response of statuses that are bookmarked by requestingAccount. // Paging for this response is done based on bookmark ID rather than status ID. func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmodel.Account, limit int, maxID string, minID string) (*apimodel.PageableResponse, gtserror.WithCode) { - bookmarks, err := p.state.DB.GetBookmarks(ctx, requestingAccount.ID, limit, maxID, minID) + bookmarks, err := p.state.DB.GetStatusBookmarks(ctx, requestingAccount.ID, limit, maxID, minID) if err != nil { return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index 6419033ec..9c59e1b99 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -20,135 +20,322 @@ import ( "context" "errors" + "fmt" "time" "codeberg.org/gruf/go-kv" "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/log" "github.com/superseriousbusiness/gotosocial/internal/messages" - "golang.org/x/crypto/bcrypt" ) +const deleteSelectLimit = 50 + // 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 { - fields := kv.Fields{{"username", account.Username}} - - if account.Domain != "" { - fields = append(fields, kv.Field{ - "domain", account.Domain, - }) - } - - l := log.WithContext(ctx).WithFields(fields...) + l := log.WithContext(ctx).WithFields(kv.Fields{ + {"username", account.Username}, + {"domain", account.Domain}, + }...) l.Trace("beginning account delete process") - // 1. Delete account's application(s), clients, and oauth tokens - // we only need to do this step for local account since remote ones won't have any tokens or applications on our server - var user *gtsmodel.User - if account.Domain == "" { - // see if we can get a user for this account - var err error - if user, err = p.state.DB.GetUserByAccountID(ctx, account.ID); err == nil { - // we got one! select all tokens with the user's ID - tokens := []*gtsmodel.Token{} - if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "user_id", Value: user.ID}}, &tokens); err == nil { - // we have some tokens to delete - for _, t := range tokens { - // delete client(s) associated with this token - if err := p.state.DB.DeleteByID(ctx, t.ClientID, >smodel.Client{}); err != nil { - l.Errorf("error deleting oauth client: %s", err) - } - // delete application(s) associated with this token - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "client_id", Value: t.ClientID}}, >smodel.Application{}); err != nil { - l.Errorf("error deleting application: %s", err) - } - // delete the token itself - if err := p.state.DB.DeleteByID(ctx, t.ID, t); err != nil { - l.Errorf("error deleting oauth token: %s", err) - } - } - } + if account.IsLocal() { + if err := p.deleteUserAndTokensForAccount(ctx, account); err != nil { + return gtserror.NewErrorInternalError(err) } } - // 2. Delete account's blocks - l.Trace("deleting account blocks") - // first delete any blocks that this account created - if err := p.state.DB.DeleteBlocksByOriginAccountID(ctx, account.ID); err != nil { - l.Errorf("error deleting blocks created by account: %s", err) + if err := p.deleteAccountFollows(ctx, account); err != nil { + return gtserror.NewErrorInternalError(err) } - // now delete any blocks that target this account - if err := p.state.DB.DeleteBlocksByTargetAccountID(ctx, account.ID); err != nil { - l.Errorf("error deleting blocks targeting account: %s", err) + if err := p.deleteAccountBlocks(ctx, account); err != nil { + return gtserror.NewErrorInternalError(err) } - // 3. Delete account's emoji - // nothing to do here - - // 4. Delete account's follow requests - // TODO: federate these if necessary - l.Trace("deleting account follow requests") - // first delete any follow requests that this account created - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.FollowRequest{}); err != nil { - l.Errorf("error deleting follow requests created by account: %s", err) + if err := p.deleteAccountStatuses(ctx, account); err != nil { + return gtserror.NewErrorInternalError(err) } - // now delete any follow requests that target this account - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "target_account_id", Value: account.ID}}, &[]*gtsmodel.FollowRequest{}); err != nil { - l.Errorf("error deleting follow requests targeting account: %s", err) + if err := p.deleteAccountNotifications(ctx, account); err != nil { + return gtserror.NewErrorInternalError(err) } - // 5. Delete account's follows - // TODO: federate these if necessary - l.Trace("deleting account follows") - // first delete any follows that this account created - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.Follow{}); err != nil { - l.Errorf("error deleting follows created by account: %s", err) + if err := p.deleteAccountPeripheral(ctx, account); err != nil { + return gtserror.NewErrorInternalError(err) } - // now delete any follows that target this account - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "target_account_id", Value: account.ID}}, &[]*gtsmodel.Follow{}); err != nil { - l.Errorf("error deleting follows targeting account: %s", 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. + columns := stubbifyAccount(account, origin) + if err := p.state.DB.UpdateAccount(ctx, account, columns...); err != nil { + return gtserror.NewErrorInternalError(err) } - var maxID string + l.Info("account deleted") + return nil +} - // 6. Delete account's statuses - l.Trace("deleting account statuses") +// DeleteSelf is like Delete, but specifically for local accounts deleting themselves. +// +// Calling DeleteSelf results in a delete message being enqueued in the processor, +// which causes side effects to occur: delete will be federated out to other instances, +// and the above Delete function will be called afterwards from the processor, to clear +// out the account's bits and bobs, and stubbify it. +func (p *Processor) DeleteSelf(ctx context.Context, account *gtsmodel.Account) gtserror.WithCode { + fromClientAPIMessage := messages.FromClientAPI{ + APObjectType: ap.ActorPerson, + APActivityType: ap.ActivityDelete, + OriginAccount: account, + TargetAccount: account, + } - // we'll select statuses 20 at a time so we don't wreck the db, and pass them through to the client api channel - // Deleting the statuses in this way also handles 7. Delete account's media attachments, 8. Delete account's mentions, and 9. Delete account's polls, - // since these are all attached to statuses. + // Process the delete side effects asynchronously. + p.state.Workers.EnqueueClientAPI(ctx, fromClientAPIMessage) - for { - // Fetch next block of account statuses from database - statuses, err := p.state.DB.GetAccountStatuses(ctx, account.ID, 20, false, false, maxID, "", false, false) + return nil +} + +// deleteUserAndTokensForAccount deletes the gtsmodel.User and +// any OAuth tokens and applications 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 { + user, err := p.state.DB.GetUserByAccountID(ctx, account.ID) + if err != nil { + return fmt.Errorf("deleteUserAndTokensForAccount: db error getting user: %w", err) + } + + tokens := []*gtsmodel.Token{} + if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "user_id", Value: user.ID}}, &tokens); err != nil { + return fmt.Errorf("deleteUserAndTokensForAccount: db error getting tokens: %w", 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 fmt.Errorf("deleteUserAndTokensForAccount: db error deleting client: %w", err) + } + + // Delete any OAuth applications associated with this token. + if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "client_id", Value: t.ClientID}}, &[]*gtsmodel.Application{}); err != nil { + return fmt.Errorf("deleteUserAndTokensForAccount: db error deleting application: %w", err) + } + + // Delete the token itself. + if err := p.state.DB.DeleteByID(ctx, t.ID, t); err != nil { + return fmt.Errorf("deleteUserAndTokensForAccount: db error deleting token: %w", err) + } + } + + if err := p.state.DB.DeleteUserByID(ctx, user.ID); err != nil { + return fmt.Errorf("deleteUserAndTokensForAccount: db error deleting 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.GetFollows(ctx, "", account.ID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return fmt.Errorf("deleteAccountFollows: db error getting follows targeting account %s: %w", account.ID, err) + } + + for _, follow := range followedBy { + if _, err := p.state.DB.Unfollow(ctx, follow.AccountID, account.ID); err != nil { + return fmt.Errorf("deleteAccountFollows: db error unfollowing account followedBy: %w", err) + } + } + + // Delete follow requests targeting this account. + followRequestedBy, err := p.state.DB.GetFollowRequests(ctx, "", account.ID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return fmt.Errorf("deleteAccountFollows: db error getting follow requests targeting account %s: %w", account.ID, err) + } + + for _, followRequest := range followRequestedBy { + if _, err := p.state.DB.UnfollowRequest(ctx, followRequest.AccountID, account.ID); err != nil { + return fmt.Errorf("deleteAccountFollows: db error unfollowing account followRequestedBy: %w", 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) + ) + + // Delete follows originating from this account. + following, err := p.state.DB.GetFollows(ctx, account.ID, "") + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return fmt.Errorf("deleteAccountFollows: db error getting follows owned by account %s: %w", account.ID, err) + } + + // For each follow owned by this account, unfollow + // and process side effects (noop if remote account). + for _, follow := range following { + if uri, err := p.state.DB.Unfollow(ctx, account.ID, follow.TargetAccountID); err != nil { + return fmt.Errorf("deleteAccountFollows: db error unfollowing account: %w", err) + } else if uri == "" { + // There was no follow after all. + // Some race condition? Skip. + log.WithContext(ctx).WithField("follow", follow).Warn("Unfollow did not return uri, likely race condition") + continue + } + + if msg := unfollowSideEffects(ctx, account, follow); msg != nil { + // There was a side effect to process. + msgs = append(msgs, *msg) + } + } + + // Delete follow requests originating from this account. + followRequesting, err := p.state.DB.GetFollowRequests(ctx, account.ID, "") + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return fmt.Errorf("deleteAccountFollows: db error getting follow requests owned by account %s: %w", account.ID, err) + } + + // For each follow owned by this account, unfollow + // and process side effects (noop if remote account). + for _, followRequest := range followRequesting { + uri, err := p.state.DB.UnfollowRequest(ctx, account.ID, followRequest.TargetAccountID) if err != nil { - if !errors.Is(err, db.ErrNoEntries) { - // an actual error has occurred - l.Errorf("Delete: db error selecting statuses for account %s: %s", account.Username, err) - } - break + return fmt.Errorf("deleteAccountFollows: db error unfollowRequesting account: %w", err) + } + + if uri == "" { + // There was no follow request after all. + // Some race condition? Skip. + log.WithContext(ctx).WithField("followRequest", followRequest).Warn("UnfollowRequest did not return uri, likely race condition") + continue + } + + // 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: uri, + AccountID: followRequest.AccountID, + Account: followRequest.Account, + TargetAccountID: followRequest.TargetAccountID, + TargetAccount: followRequest.TargetAccount, + } + + if msg := unfollowSideEffects(ctx, account, follow); msg != nil { + // There was a side effect to process. + msgs = append(msgs, *msg) + } + } + + // Process accreted messages asynchronously. + p.state.Workers.EnqueueClientAPI(ctx, msgs...) + + return nil +} + +func (p *Processor) unfollowSideEffectsFunc(deletedAccount *gtsmodel.Account) func(ctx context.Context, account *gtsmodel.Account, follow *gtsmodel.Follow) *messages.FromClientAPI { + if !deletedAccount.IsLocal() { + // Don't try to process side effects + // for accounts that aren't local. + return func(ctx context.Context, account *gtsmodel.Account, follow *gtsmodel.Follow) *messages.FromClientAPI { + return nil // noop + } + } + + 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 follow.TargetAccount.IsLocal() { + // No side effects for local unfollows. + return nil + } + + // There was a follow, process side effects. + return &messages.FromClientAPI{ + APObjectType: ap.ActivityFollow, + APActivityType: ap.ActivityUndo, + GTSModel: follow, + OriginAccount: account, + TargetAccount: follow.TargetAccount, + } + } +} + +func (p *Processor) deleteAccountBlocks(ctx context.Context, account *gtsmodel.Account) error { + // Delete blocks created by this account. + if err := p.state.DB.DeleteBlocksByOriginAccountID(ctx, account.ID); err != nil { + return fmt.Errorf("deleteAccountBlocks: db error deleting blocks created by account %s: %w", account.ID, err) + } + + // Delete blocks targeting this account. + if err := p.state.DB.DeleteBlocksByTargetAccountID(ctx, account.ID); err != nil { + return fmt.Errorf("deleteAccountBlocks: db error deleting blocks targeting account %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 async processing. +func (p *Processor) deleteAccountStatuses(ctx context.Context, 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 ( + statuses []*gtsmodel.Status + err error + maxID string + msgs = []messages.FromClientAPI{} + ) + +statusLoop: + for { + // Page through account's statuses. + statuses, err = p.state.DB.GetAccountStatuses(ctx, account.ID, deleteSelectLimit, false, false, maxID, "", false, false) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + // Make sure we don't have a real error. + return err } if len(statuses) == 0 { - break // reached end + break statusLoop } + // Update next maxID from last status. + maxID = statuses[len(statuses)-1].ID + for _, status := range statuses { - // Ensure account is set - status.Account = account + status.Account = account // ensure account is set - l.Tracef("queue client API status delete: %s", status.ID) - - // pass the status delete through the client api channel for processing - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + // Pass the status delete through the client api worker for processing. + msgs = append(msgs, messages.FromClientAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityDelete, GTSModel: status, @@ -156,30 +343,32 @@ func (p *Processor) Delete(ctx context.Context, account *gtsmodel.Account, origi TargetAccount: account, }) - // Look for any boosts of this status in DB + // Look for any boosts of this status in DB. boosts, err := p.state.DB.GetStatusReblogs(ctx, status) if err != nil && !errors.Is(err, db.ErrNoEntries) { - l.Errorf("error fetching status reblogs for %q: %v", status.ID, err) - continue + return fmt.Errorf("deleteAccountStatuses: error fetching status reblogs for %s: %w", status.ID, err) } for _, boost := range boosts { if boost.Account == nil { - // Fetch the relevant account for this status boost + // Fetch the relevant account for this status boost. boostAcc, err := p.state.DB.GetAccountByID(ctx, boost.AccountID) if err != nil { - l.Errorf("error fetching boosted status account for %q: %v", boost.AccountID, err) - continue + if errors.Is(err, db.ErrNoEntries) { + // We don't have an account for this boost + // for some reason, so just skip processing. + log.WithContext(ctx).WithField("boost", boost).Warnf("no account found with id %s for boost %s", boost.AccountID, boost.ID) + continue + } + return fmt.Errorf("deleteAccountStatuses: error fetching boosted status account for %s: %w", boost.AccountID, err) } // Set account model boost.Account = boostAcc } - l.Tracef("queue client API boost delete: %s", status.ID) - - // pass the boost delete through the client api channel for processing - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + // Pass the boost delete through the client api worker for processing. + msgs = append(msgs, messages.FromClientAPI{ APObjectType: ap.ActivityAnnounce, APActivityType: ap.ActivityUndo, GTSModel: status, @@ -188,128 +377,120 @@ func (p *Processor) Delete(ctx context.Context, account *gtsmodel.Account, origi }) } } - - // Update next maxID from last status - maxID = statuses[len(statuses)-1].ID } - // 10. Delete account's notifications - l.Trace("deleting account notifications") - // first notifications created by account - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "origin_account_id", Value: account.ID}}, &[]*gtsmodel.Notification{}); err != nil { - l.Errorf("error deleting notifications created by account: %s", err) + // Batch process all accreted messages. + p.state.Workers.EnqueueClientAPI(ctx, msgs...) + + return nil +} + +func (p *Processor) deleteAccountNotifications(ctx context.Context, account *gtsmodel.Account) error { + // Delete all notifications targeting given account. + if err := p.state.DB.DeleteNotifications(ctx, account.ID, ""); err != nil && !errors.Is(err, db.ErrNoEntries) { + return err } - // now notifications targeting account - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "target_account_id", Value: account.ID}}, &[]*gtsmodel.Notification{}); err != nil { - l.Errorf("error deleting notifications targeting account: %s", err) + // Delete all notifications originating from given account. + if err := p.state.DB.DeleteNotifications(ctx, "", account.ID); err != nil && !errors.Is(err, db.ErrNoEntries) { + return err } - // 11. Delete account's bookmarks - l.Trace("deleting account bookmarks") - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.StatusBookmark{}); err != nil { - l.Errorf("error deleting bookmarks created by account: %s", err) + return nil +} + +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 err } - // 12. Delete account's faves - // TODO: federate these if necessary - l.Trace("deleting account faves") - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.StatusFave{}); err != nil { - l.Errorf("error deleting faves created by account: %s", 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 err } - // 13. Delete account's mutes - l.Trace("deleting account mutes") - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.StatusMute{}); err != nil { - l.Errorf("error deleting status mutes created by account: %s", 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 err } - // 14. Delete account's streams - // TODO - - // 15. Delete account's tags - // TODO - - // 16. Delete account's user - if user != nil { - l.Trace("deleting account user") - if err := p.state.DB.DeleteUserByID(ctx, user.ID); err != nil { - return gtserror.NewErrorInternalError(err) - } + // Delete all faves targeting given account. + if err := p.state.DB.DeleteStatusFaves(ctx, "", account.ID); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return err } - // 17. Delete account's timeline - // TODO + // TODO: add status mutes here when they're implemented. - // 18. Delete account itself - // to prevent the account being created again, set all these fields and update it in the db - // the account won't actually be *removed* from the database but it will be set to just a stub - account.Note = "" - account.DisplayName = "" + return nil +} + +// stubbifyAccount renders the given account as a stub, +// removing most information from it and marking it as +// suspended. +// +// The origin parameter refers to the origin of the +// suspension action; should be an account ID or domain +// block ID. +// +// For caller's convenience, this function returns the db +// names of all columns that are updated by it. +func stubbifyAccount(account *gtsmodel.Account, origin string) []string { + var ( + falseBool = func() *bool { b := false; return &b } + trueBool = func() *bool { b := true; return &b } + now = time.Now() + never = time.Time{} + ) + + account.FetchedAt = never account.AvatarMediaAttachmentID = "" account.AvatarRemoteURL = "" account.HeaderMediaAttachmentID = "" account.HeaderRemoteURL = "" + account.DisplayName = "" + account.EmojiIDs = nil + account.Emojis = nil + account.Fields = nil + account.Note = "" + account.NoteRaw = "" + account.Memorial = falseBool() + account.AlsoKnownAs = "" + account.MovedToAccountID = "" account.Reason = "" - account.Emojis = []*gtsmodel.Emoji{} - account.EmojiIDs = []string{} - account.Fields = []gtsmodel.Field{} - hideCollections := true - account.HideCollections = &hideCollections - discoverable := false - account.Discoverable = &discoverable - account.SuspendedAt = time.Now() + account.Discoverable = falseBool() + account.StatusContentType = "" + account.CustomCSS = "" + account.SuspendedAt = now account.SuspensionOrigin = origin - err := p.state.DB.UpdateAccount(ctx, account) - if err != nil { - return gtserror.NewErrorInternalError(err) - } + account.HideCollections = trueBool() + account.EnableRSS = falseBool() - l.Infof("deleted account with username %s from domain %s", account.Username, account.Domain) - return nil -} - -// DeleteLocal is like Delete, but specifically for deletion of local accounts rather than federated ones. -// Unlike Delete, it will propagate the deletion out across the federating API to other instances. -func (p *Processor) DeleteLocal(ctx context.Context, account *gtsmodel.Account, form *apimodel.AccountDeleteRequest) gtserror.WithCode { - fromClientAPIMessage := messages.FromClientAPI{ - APObjectType: ap.ActorPerson, - APActivityType: ap.ActivityDelete, - TargetAccount: account, - } - - if form.DeleteOriginID == account.ID { - // the account owner themself has requested deletion via the API, get their user from the db - user, err := p.state.DB.GetUserByAccountID(ctx, account.ID) - if err != nil { - return gtserror.NewErrorInternalError(err) - } - - // now check that the password they supplied is correct - // make sure a password is actually set and bail if not - if user.EncryptedPassword == "" { - return gtserror.NewErrorForbidden(errors.New("user password was not set")) - } - - // compare the provided password with the encrypted one from the db, bail if they don't match - if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(form.Password)); err != nil { - return gtserror.NewErrorForbidden(errors.New("invalid password")) - } - - fromClientAPIMessage.OriginAccount = account - } else { - // the delete has been requested by some other account, grab it; - // if we've reached this point we know it has permission already - requestingAccount, err := p.state.DB.GetAccountByID(ctx, form.DeleteOriginID) - if err != nil { - return gtserror.NewErrorInternalError(err) - } - - fromClientAPIMessage.OriginAccount = requestingAccount - } - - // put the delete in the processor queue to handle the rest of it asynchronously - p.state.Workers.EnqueueClientAPI(ctx, fromClientAPIMessage) - - return nil + return []string{ + "fetched_at", + "avatar_media_attachment_id", + "avatar_remote_url", + "header_media_attachment_id", + "header_remote_url", + "display_name", + "emojis", + "fields", + "note", + "note_raw", + "memorial", + "also_known_as", + "moved_to_account_id", + "reason", + "discoverable", + "status_content_type", + "custom_css", + "suspended_at", + "suspension_origin", + "hide_collections", + "enable_rss", + } } diff --git a/internal/processing/account/follow.go b/internal/processing/account/follow.go index 191b1f02d..8d053e92a 100644 --- a/internal/processing/account/follow.go +++ b/internal/processing/account/follow.go @@ -19,6 +19,7 @@ import ( "context" + "errors" "fmt" "github.com/superseriousbusiness/gotosocial/internal/ap" @@ -33,172 +34,182 @@ // FollowCreate handles a follow request to an account, either remote or local. func (p *Processor) FollowCreate(ctx context.Context, requestingAccount *gtsmodel.Account, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) { - // if there's a block between the accounts we shouldn't create the request ofc - if blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, form.ID, true); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } else if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) + targetAccount, errWithCode := p.getFollowTarget(ctx, requestingAccount.ID, form.ID) + if errWithCode != nil { + return nil, errWithCode } - // make sure the target account actually exists in our db - targetAcct, err := p.state.DB.GetAccountByID(ctx, form.ID) - if err != nil { - if err == db.ErrNoEntries { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("accountfollowcreate: account %s not found in the db: %s", form.ID, err)) - } + // Check if a follow exists already. + if follows, err := p.state.DB.IsFollowing(ctx, requestingAccount, targetAccount); err != nil { + err = fmt.Errorf("FollowCreate: db error checking follow: %w", err) return nil, gtserror.NewErrorInternalError(err) - } - - // check if a follow exists already - if follows, err := p.state.DB.IsFollowing(ctx, requestingAccount, targetAcct); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow in db: %s", err)) } else if follows { - // already follows so just return the relationship + // Already follows, just return current relationship. return p.RelationshipGet(ctx, requestingAccount, form.ID) } - // check if a follow request exists already - if followRequested, err := p.state.DB.IsFollowRequested(ctx, requestingAccount, targetAcct); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow request in db: %s", err)) + // Check if a follow request exists already. + if followRequested, err := p.state.DB.IsFollowRequested(ctx, requestingAccount, targetAccount); err != nil { + err = fmt.Errorf("FollowCreate: db error checking follow request: %w", err) + return nil, gtserror.NewErrorInternalError(err) } else if followRequested { - // already follow requested so just return the relationship + // Already follow requested, just return current relationship. return p.RelationshipGet(ctx, requestingAccount, form.ID) } - // check for attempt to follow self - if requestingAccount.ID == targetAcct.ID { - return nil, gtserror.NewErrorNotAcceptable(fmt.Errorf("accountfollowcreate: account %s cannot follow itself", requestingAccount.ID)) - } - - // make the follow request - newFollowID, err := id.NewRandomULID() + // Create and store a new follow request. + followID, err := id.NewRandomULID() if err != nil { return nil, gtserror.NewErrorInternalError(err) } + followURI := uris.GenerateURIForFollow(requestingAccount.Username, followID) - showReblogs := true - notify := false fr := >smodel.FollowRequest{ - ID: newFollowID, + ID: followID, + URI: followURI, AccountID: requestingAccount.ID, + Account: requestingAccount, TargetAccountID: form.ID, - ShowReblogs: &showReblogs, - URI: uris.GenerateURIForFollow(requestingAccount.Username, newFollowID), - Notify: ¬ify, - } - if form.Reblogs != nil { - fr.ShowReblogs = form.Reblogs - } - if form.Notify != nil { - fr.Notify = form.Notify + TargetAccount: targetAccount, + ShowReblogs: form.Reblogs, + Notify: form.Notify, } - // whack it in the database if err := p.state.DB.Put(ctx, fr); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error creating follow request in db: %s", err)) + err = fmt.Errorf("FollowCreate: error creating follow request in db: %s", err) + return nil, gtserror.NewErrorInternalError(err) } - // if it's a local account that's not locked we can just straight up accept the follow request - if !*targetAcct.Locked && targetAcct.Domain == "" { + if targetAccount.IsLocal() && !*targetAccount.Locked { + // If the target account is local and not locked, + // we can already accept the follow request and + // skip any further processing. + // + // Because we know the requestingAccount is also + // local, we don't need to federate the accept out. if _, err := p.state.DB.AcceptFollowRequest(ctx, requestingAccount.ID, form.ID); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error accepting folow request for local unlocked account: %s", err)) + err = fmt.Errorf("FollowCreate: error accepting follow request for local unlocked account: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // return the new relationship - return p.RelationshipGet(ctx, requestingAccount, form.ID) + } else if targetAccount.IsRemote() { + // Otherwise we leave the follow request as it is, + // and we handle the rest of the process async. + p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + APObjectType: ap.ActivityFollow, + APActivityType: ap.ActivityCreate, + GTSModel: fr, + OriginAccount: requestingAccount, + TargetAccount: targetAccount, + }) } - // otherwise we leave the follow request as it is and we handle the rest of the process asynchronously - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityCreate, - GTSModel: fr, - OriginAccount: requestingAccount, - TargetAccount: targetAcct, - }) - - // return whatever relationship results from this return p.RelationshipGet(ctx, requestingAccount, form.ID) } // FollowRemove handles the removal of a follow/follow request to an account, either remote or local. func (p *Processor) FollowRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { - // if there's a block between the accounts we shouldn't do anything - blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true) + targetAccount, errWithCode := p.getFollowTarget(ctx, requestingAccount.ID, targetAccountID) + if errWithCode != nil { + return nil, errWithCode + } + + // Unfollow and deal with side effects. + msgs, err := p.unfollow(ctx, requestingAccount, targetAccount) if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("FollowRemove: account %s not found in the db: %s", targetAccountID, err)) + } + + // Batch queue accreted client api messages. + p.state.Workers.EnqueueClientAPI(ctx, msgs...) + + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) +} + +/* + Utility functions. +*/ + +// getFollowTarget is a convenience function which: +// - Checks if account is trying to follow/unfollow itself. +// - Returns not found if there's a block in place between accounts. +// - Returns target account according to its id. +func (p *Processor) getFollowTarget(ctx context.Context, requestingAccountID string, targetAccountID string) (*gtsmodel.Account, gtserror.WithCode) { + // Account can't follow or unfollow itself. + if requestingAccountID == targetAccountID { + err := errors.New("account can't follow or unfollow itself") + return nil, gtserror.NewErrorNotAcceptable(err) + } + + // Do nothing if a block exists in either direction between accounts. + if blocked, err := p.state.DB.IsBlocked(ctx, requestingAccountID, targetAccountID, true); err != nil { + err = fmt.Errorf("db error checking block between accounts: %w", err) return nil, gtserror.NewErrorInternalError(err) - } - if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: block exists between accounts")) + } else if blocked { + err = errors.New("block exists between accounts") + return nil, gtserror.NewErrorNotFound(err) } - // make sure the target account actually exists in our db - targetAcct, err := p.state.DB.GetAccountByID(ctx, targetAccountID) + // Ensure target account retrievable. + targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID) if err != nil { - if err == db.ErrNoEntries { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: account %s not found in the db: %s", targetAccountID, err)) + if !errors.Is(err, db.ErrNoEntries) { + // Real db error. + err = fmt.Errorf("db error looking for target account %s: %w", targetAccountID, err) + return nil, gtserror.NewErrorInternalError(err) } + // Account not found. + err = fmt.Errorf("target account %s not found in the db", targetAccountID) + return nil, gtserror.NewErrorNotFound(err, err.Error()) } - // check if a follow request exists, and remove it if it does (storing the URI for later) - var frChanged bool - var frURI string - fr := >smodel.FollowRequest{} - if err := p.state.DB.GetWhere(ctx, []db.Where{ - {Key: "account_id", Value: requestingAccount.ID}, - {Key: "target_account_id", Value: targetAccountID}, - }, fr); err == nil { - frURI = fr.URI - if err := p.state.DB.DeleteByID(ctx, fr.ID, fr); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow request from db: %s", err)) - } - frChanged = true - } + return targetAccount, nil +} - // now do the same thing for any existing follow - var fChanged bool - var fURI string - f := >smodel.Follow{} - if err := p.state.DB.GetWhere(ctx, []db.Where{ - {Key: "account_id", Value: requestingAccount.ID}, - {Key: "target_account_id", Value: targetAccountID}, - }, f); err == nil { - fURI = f.URI - if err := p.state.DB.DeleteByID(ctx, f.ID, f); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow from db: %s", err)) - } - fChanged = true - } +// unfollow is a convenience function for having requesting account +// unfollow (and un follow request) target account, if follows and/or +// follow requests exist. +// +// If a follow and/or follow request was removed this way, one or two +// messages will be returned which should then be processed by a client +// api worker. +func (p *Processor) unfollow(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) ([]messages.FromClientAPI, error) { + msgs := []messages.FromClientAPI{} - // follow request status changed so send the UNDO activity to the channel for async processing - if frChanged { - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + if fURI, err := p.state.DB.Unfollow(ctx, requestingAccount.ID, targetAccount.ID); err != nil { + err = fmt.Errorf("unfollow: error deleting follow from %s targeting %s: %w", requestingAccount.ID, targetAccount.ID, err) + return nil, err + } else if fURI != "" { + // Follow status changed, process side effects. + msgs = append(msgs, messages.FromClientAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityUndo, GTSModel: >smodel.Follow{ AccountID: requestingAccount.ID, - TargetAccountID: targetAccountID, - URI: frURI, - }, - OriginAccount: requestingAccount, - TargetAccount: targetAcct, - }) - } - - // follow status changed so send the UNDO activity to the channel for async processing - if fChanged { - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityUndo, - GTSModel: >smodel.Follow{ - AccountID: requestingAccount.ID, - TargetAccountID: targetAccountID, + TargetAccountID: targetAccount.ID, URI: fURI, }, OriginAccount: requestingAccount, - TargetAccount: targetAcct, + TargetAccount: targetAccount, }) } - // return whatever relationship results from all this - return p.RelationshipGet(ctx, requestingAccount, targetAccountID) + if frURI, err := p.state.DB.UnfollowRequest(ctx, requestingAccount.ID, targetAccount.ID); err != nil { + err = fmt.Errorf("unfollow: error deleting follow request from %s targeting %s: %w", requestingAccount.ID, targetAccount.ID, err) + return nil, err + } else if frURI != "" { + // Follow request status changed, process side effects. + msgs = append(msgs, messages.FromClientAPI{ + APObjectType: ap.ActivityFollow, + APActivityType: ap.ActivityUndo, + GTSModel: >smodel.Follow{ + AccountID: requestingAccount.ID, + TargetAccountID: targetAccount.ID, + URI: frURI, + }, + OriginAccount: requestingAccount, + TargetAccount: targetAccount, + }) + } + + return msgs, nil } diff --git a/internal/processing/account/relationships.go b/internal/processing/account/relationships.go index 54a0b82ab..4afe36afe 100644 --- a/internal/processing/account/relationships.go +++ b/internal/processing/account/relationships.go @@ -26,98 +26,51 @@ "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" ) // FollowersGet fetches a list of the target account's followers. func (p *Processor) FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { if blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil { + err = fmt.Errorf("FollowersGet: db error checking block: %w", err) return nil, gtserror.NewErrorInternalError(err) } else if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) + err = errors.New("FollowersGet: block exists between accounts") + return nil, gtserror.NewErrorNotFound(err) } - accounts := []apimodel.Account{} - follows, err := p.state.DB.GetAccountFollowedBy(ctx, targetAccountID, false) + follows, err := p.state.DB.GetFollows(ctx, "", targetAccountID) if err != nil { - if err == db.ErrNoEntries { - return accounts, nil - } - return nil, gtserror.NewErrorInternalError(err) - } - - for _, f := range follows { - blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, f.AccountID, true) - if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("FollowersGet: db error getting followers: %w", err) return nil, gtserror.NewErrorInternalError(err) } - if blocked { - continue - } - - if f.Account == nil { - a, err := p.state.DB.GetAccountByID(ctx, f.AccountID) - if err != nil { - if err == db.ErrNoEntries { - continue - } - return nil, gtserror.NewErrorInternalError(err) - } - f.Account = a - } - - account, err := p.tc.AccountToAPIAccountPublic(ctx, f.Account) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - accounts = append(accounts, *account) + return []apimodel.Account{}, nil } - return accounts, nil + + return p.accountsFromFollows(ctx, follows, requestingAccount.ID) } // FollowingGet fetches a list of the accounts that target account is following. func (p *Processor) FollowingGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { if blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil { + err = fmt.Errorf("FollowingGet: db error checking block: %w", err) return nil, gtserror.NewErrorInternalError(err) } else if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) + err = errors.New("FollowingGet: block exists between accounts") + return nil, gtserror.NewErrorNotFound(err) } - accounts := []apimodel.Account{} - follows, err := p.state.DB.GetAccountFollows(ctx, targetAccountID) + follows, err := p.state.DB.GetFollows(ctx, targetAccountID, "") if err != nil { - if err == db.ErrNoEntries { - return accounts, nil - } - return nil, gtserror.NewErrorInternalError(err) - } - - for _, f := range follows { - blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, f.AccountID, true) - if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("FollowingGet: db error getting followers: %w", err) return nil, gtserror.NewErrorInternalError(err) } - if blocked { - continue - } - - if f.TargetAccount == nil { - a, err := p.state.DB.GetAccountByID(ctx, f.TargetAccountID) - if err != nil { - if err == db.ErrNoEntries { - continue - } - return nil, gtserror.NewErrorInternalError(err) - } - f.TargetAccount = a - } - - account, err := p.tc.AccountToAPIAccountPublic(ctx, f.TargetAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - accounts = append(accounts, *account) + return []apimodel.Account{}, nil } - return accounts, nil + + return p.accountsFromFollows(ctx, follows, requestingAccount.ID) } // RelationshipGet returns a relationship model describing the relationship of the targetAccount to the Authed account. @@ -138,3 +91,30 @@ func (p *Processor) RelationshipGet(ctx context.Context, requestingAccount *gtsm return r, nil } + +func (p *Processor) accountsFromFollows(ctx context.Context, follows []*gtsmodel.Follow, requestingAccountID string) ([]apimodel.Account, gtserror.WithCode) { + accounts := make([]apimodel.Account, 0, len(follows)) + for _, follow := range follows { + if follow.Account == nil { + // No account set for some reason; just skip. + log.WithContext(ctx).WithField("follow", follow).Warn("follow had no associated account") + continue + } + + if blocked, err := p.state.DB.IsBlocked(ctx, requestingAccountID, follow.AccountID, true); err != nil { + err = fmt.Errorf("accountsFromFollows: db error checking block: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } else if blocked { + continue + } + + account, err := p.tc.AccountToAPIAccountPublic(ctx, follow.Account) + if err != nil { + err = fmt.Errorf("accountsFromFollows: error converting account to api account: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + accounts = append(accounts, *account) + } + + return accounts, nil +} diff --git a/internal/processing/account_test.go b/internal/processing/account_test.go index 819e1baea..fb66d7d0a 100644 --- a/internal/processing/account_test.go +++ b/internal/processing/account_test.go @@ -26,7 +26,6 @@ "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/activity/pub" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -52,10 +51,7 @@ func (suite *AccountTestSuite) TestAccountDeleteLocal() { err := suite.db.Put(ctx, follow) suite.NoError(err) - errWithCode := suite.processor.Account().DeleteLocal(ctx, suite.testAccounts["local_account_1"], &apimodel.AccountDeleteRequest{ - Password: "password", - DeleteOriginID: deletingAccount.ID, - }) + errWithCode := suite.processor.Account().DeleteSelf(ctx, suite.testAccounts["local_account_1"]) suite.NoError(errWithCode) // the delete should be federated outwards to the following account's inbox diff --git a/internal/processing/followrequest.go b/internal/processing/followrequest.go index 2b45108c6..7b8c36050 100644 --- a/internal/processing/followrequest.go +++ b/internal/processing/followrequest.go @@ -19,34 +19,33 @@ 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/log" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) func (p *Processor) FollowRequestsGet(ctx context.Context, auth *oauth.Auth) ([]apimodel.Account, gtserror.WithCode) { - frs, err := p.state.DB.GetAccountFollowRequests(ctx, auth.Account.ID) - if err != nil { - if err != db.ErrNoEntries { - return nil, gtserror.NewErrorInternalError(err) - } + followRequests, err := p.state.DB.GetFollowRequests(ctx, "", auth.Account.ID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError(err) } - accts := []apimodel.Account{} - for _, fr := range frs { - if fr.Account == nil { - frAcct, err := p.state.DB.GetAccountByID(ctx, fr.AccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - fr.Account = frAcct + accts := make([]apimodel.Account, 0, len(followRequests)) + for _, followRequest := range followRequests { + if followRequest.Account == nil { + // The creator of the follow doesn't exist, + // just skip this one. + log.WithContext(ctx).WithField("followRequest", followRequest).Warn("follow request had no associated account") + continue } - apiAcct, err := p.tc.AccountToAPIAccountPublic(ctx, fr.Account) + apiAcct, err := p.tc.AccountToAPIAccountPublic(ctx, followRequest.Account) if err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -62,19 +61,10 @@ func (p *Processor) FollowRequestAccept(ctx context.Context, auth *oauth.Auth, a } if follow.Account == nil { - followAccount, err := p.state.DB.GetAccountByID(ctx, follow.AccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - follow.Account = followAccount - } - - if follow.TargetAccount == nil { - followTargetAccount, err := p.state.DB.GetAccountByID(ctx, follow.TargetAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - follow.TargetAccount = followTargetAccount + // The creator of the follow doesn't exist, + // so we can't do further processing. + log.WithContext(ctx).WithField("follow", follow).Warn("follow had no associated account") + return p.relationship(ctx, auth.Account.ID, accountID) } p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ @@ -85,17 +75,7 @@ func (p *Processor) FollowRequestAccept(ctx context.Context, auth *oauth.Auth, a TargetAccount: follow.TargetAccount, }) - gtsR, err := p.state.DB.GetRelationship(ctx, auth.Account.ID, accountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - r, err := p.tc.RelationshipToAPIRelationship(ctx, gtsR) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return r, nil + return p.relationship(ctx, auth.Account.ID, accountID) } func (p *Processor) FollowRequestReject(ctx context.Context, auth *oauth.Auth, accountID string) (*apimodel.Relationship, gtserror.WithCode) { @@ -105,19 +85,9 @@ func (p *Processor) FollowRequestReject(ctx context.Context, auth *oauth.Auth, a } if followRequest.Account == nil { - a, err := p.state.DB.GetAccountByID(ctx, followRequest.AccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - followRequest.Account = a - } - - if followRequest.TargetAccount == nil { - a, err := p.state.DB.GetAccountByID(ctx, followRequest.TargetAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - followRequest.TargetAccount = a + // The creator of the request doesn't exist, + // so we can't do further processing. + return p.relationship(ctx, auth.Account.ID, accountID) } p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ @@ -128,15 +98,19 @@ func (p *Processor) FollowRequestReject(ctx context.Context, auth *oauth.Auth, a TargetAccount: followRequest.TargetAccount, }) - gtsR, err := p.state.DB.GetRelationship(ctx, auth.Account.ID, accountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - r, err := p.tc.RelationshipToAPIRelationship(ctx, gtsR) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return r, nil + return p.relationship(ctx, auth.Account.ID, accountID) +} + +func (p *Processor) relationship(ctx context.Context, accountID string, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { + relationship, err := p.state.DB.GetRelationship(ctx, accountID, targetAccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + apiRelationship, err := p.tc.RelationshipToAPIRelationship(ctx, relationship) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return apiRelationship, nil } diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index a4d4521ce..490fc7d34 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -139,8 +139,8 @@ func (p *Processor) processCreateAccountFromClientAPI(ctx context.Context, clien return errors.New("account was not parseable as *gtsmodel.Account") } - // return if the account isn't from this domain - if account.Domain != "" { + // Do nothing if this isn't our activity. + if !account.IsLocal() { return nil } @@ -383,8 +383,8 @@ func (p *Processor) processReportAccountFromClientAPI(ctx context.Context, clien // TODO: move all the below functions into federation.Federator func (p *Processor) federateAccountDelete(ctx context.Context, account *gtsmodel.Account) error { - // do nothing if this isn't our account - if account.Domain != "" { + // Do nothing if this isn't our activity. + if !account.IsLocal() { return nil } @@ -449,8 +449,8 @@ func (p *Processor) federateStatus(ctx context.Context, status *gtsmodel.Status) status.Account = statusAccount } - // do nothing if this isn't our status - if status.Account.Domain != "" { + // Do nothing if this isn't our activity. + if !status.Account.IsLocal() { return nil } @@ -482,8 +482,8 @@ func (p *Processor) federateStatusDelete(ctx context.Context, status *gtsmodel.S status.Account = statusAccount } - // do nothing if this isn't our status - if status.Account.Domain != "" { + // Do nothing if this isn't our activity. + if !status.Account.IsLocal() { return nil } @@ -502,8 +502,8 @@ func (p *Processor) federateStatusDelete(ctx context.Context, status *gtsmodel.S } func (p *Processor) federateFollow(ctx context.Context, followRequest *gtsmodel.FollowRequest, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // if both accounts are local there's nothing to do here - if originAccount.Domain == "" && targetAccount.Domain == "" { + // Do nothing if both accounts are local. + if originAccount.IsLocal() && targetAccount.IsLocal() { return nil } @@ -524,8 +524,8 @@ func (p *Processor) federateFollow(ctx context.Context, followRequest *gtsmodel. } func (p *Processor) federateUnfollow(ctx context.Context, follow *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // if both accounts are local there's nothing to do here - if originAccount.Domain == "" && targetAccount.Domain == "" { + // Do nothing if both accounts are local. + if originAccount.IsLocal() && targetAccount.IsLocal() { return nil } @@ -565,8 +565,8 @@ func (p *Processor) federateUnfollow(ctx context.Context, follow *gtsmodel.Follo } func (p *Processor) federateUnfave(ctx context.Context, fave *gtsmodel.StatusFave, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // if both accounts are local there's nothing to do here - if originAccount.Domain == "" && targetAccount.Domain == "" { + // Do nothing if both accounts are local. + if originAccount.IsLocal() && targetAccount.IsLocal() { return nil } @@ -604,8 +604,8 @@ func (p *Processor) federateUnfave(ctx context.Context, fave *gtsmodel.StatusFav } func (p *Processor) federateUnannounce(ctx context.Context, boost *gtsmodel.Status, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - if originAccount.Domain != "" { - // nothing to do here + // Do nothing if this isn't our activity. + if !originAccount.IsLocal() { return nil } @@ -657,13 +657,9 @@ func (p *Processor) federateAcceptFollowRequest(ctx context.Context, follow *gts } targetAccount := follow.TargetAccount - // if target account isn't from our domain we shouldn't do anything - if targetAccount.Domain != "" { - return nil - } - - // if both accounts are local there's nothing to do here - if originAccount.Domain == "" && targetAccount.Domain == "" { + // Do nothing if target account *isn't* local, + // or both origin + target *are* local. + if targetAccount.IsRemote() || originAccount.IsLocal() { return nil } @@ -730,13 +726,9 @@ func (p *Processor) federateRejectFollowRequest(ctx context.Context, followReque } targetAccount := followRequest.TargetAccount - // if target account isn't from our domain we shouldn't do anything - if targetAccount.Domain != "" { - return nil - } - - // if both accounts are local there's nothing to do here - if originAccount.Domain == "" && targetAccount.Domain == "" { + // Do nothing if target account *isn't* local, + // or both origin + target *are* local. + if targetAccount.IsRemote() || originAccount.IsLocal() { return nil } @@ -786,8 +778,8 @@ func (p *Processor) federateRejectFollowRequest(ctx context.Context, followReque } func (p *Processor) federateFave(ctx context.Context, fave *gtsmodel.StatusFave, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // if both accounts are local there's nothing to do here - if originAccount.Domain == "" && targetAccount.Domain == "" { + // Do nothing if both accounts are local. + if originAccount.IsLocal() && targetAccount.IsLocal() { return nil } @@ -857,8 +849,8 @@ func (p *Processor) federateBlock(ctx context.Context, block *gtsmodel.Block) er block.TargetAccount = blockTargetAccount } - // if both accounts are local there's nothing to do here - if block.Account.Domain == "" && block.TargetAccount.Domain == "" { + // Do nothing if both accounts are local. + if block.Account.IsLocal() && block.TargetAccount.IsLocal() { return nil } @@ -893,8 +885,8 @@ func (p *Processor) federateUnblock(ctx context.Context, block *gtsmodel.Block) block.TargetAccount = blockTargetAccount } - // if both accounts are local there's nothing to do here - if block.Account.Domain == "" && block.TargetAccount.Domain == "" { + // Do nothing if both accounts are local. + if block.Account.IsLocal() && block.TargetAccount.IsLocal() { return nil } diff --git a/internal/processing/fromcommon.go b/internal/processing/fromcommon.go index c29ada5ba..49a05da5d 100644 --- a/internal/processing/fromcommon.go +++ b/internal/processing/fromcommon.go @@ -21,12 +21,11 @@ "context" "errors" "fmt" - "strings" - "sync" "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/id" "github.com/superseriousbusiness/gotosocial/internal/stream" @@ -413,45 +412,30 @@ func (p *Processor) timelineStatus(ctx context.Context, status *gtsmodel.Status) status.Account = a } - // get local followers of the account that posted the status - follows, err := p.state.DB.GetAccountFollowedBy(ctx, status.AccountID, true) + // Get LOCAL followers of the account that posted the status; + // we know that remote accounts don't have timelines on this + // instance, so there's no point selecting them too. + accountIDs, err := p.state.DB.GetLocalFollowersIDs(ctx, status.AccountID) if err != nil { return fmt.Errorf("timelineStatus: error getting followers for account id %s: %s", status.AccountID, err) } - // if the poster is local, add a fake entry for them to the followers list so they can see their own status in their timeline - if status.Account.Domain == "" { - follows = append(follows, >smodel.Follow{ - AccountID: status.AccountID, - Account: status.Account, - }) + // If the poster is also local, add a fake entry for them + // so they can see their own status in their timeline. + if status.Account.IsLocal() { + accountIDs = append(accountIDs, status.AccountID) } - wg := sync.WaitGroup{} - wg.Add(len(follows)) - errors := make(chan error, len(follows)) - - for _, f := range follows { - go p.timelineStatusForAccount(ctx, status, f.AccountID, errors, &wg) - } - - // read any errors that come in from the async functions - errs := []string{} - go func(errs []string) { - for range errors { - if e := <-errors; e != nil { - errs = append(errs, e.Error()) - } + // Timeline the status for each local following account. + errors := gtserror.MultiError{} + for _, accountID := range accountIDs { + if err := p.timelineStatusForAccount(ctx, status, accountID); err != nil { + errors.Append(err) } - }(errs) + } - // wait til all functions have returned and then close the error channel - wg.Wait() - close(errors) - - if len(errs) != 0 { - // we have at least one error - return fmt.Errorf("timelineStatus: one or more errors timelining statuses: %s", strings.Join(errs, ";")) + if len(errors) != 0 { + return fmt.Errorf("timelineStatus: one or more errors timelining statuses: %w", errors.Combine()) } return nil @@ -462,46 +446,38 @@ func (p *Processor) timelineStatus(ctx context.Context, status *gtsmodel.Status) // // If the status was inserted into the home timeline of the given account, // it will also be streamed via websockets to the user. -func (p *Processor) timelineStatusForAccount(ctx context.Context, status *gtsmodel.Status, accountID string, errors chan error, wg *sync.WaitGroup) { - defer wg.Done() - +func (p *Processor) timelineStatusForAccount(ctx context.Context, status *gtsmodel.Status, accountID string) error { // get the timeline owner account timelineAccount, err := p.state.DB.GetAccountByID(ctx, accountID) if err != nil { - errors <- fmt.Errorf("timelineStatusForAccount: error getting account for timeline with id %s: %s", accountID, err) - return + return fmt.Errorf("timelineStatusForAccount: error getting account for timeline with id %s: %w", accountID, err) } // make sure the status is timelineable - timelineable, err := p.filter.StatusHometimelineable(ctx, status, timelineAccount) - if err != nil { - errors <- fmt.Errorf("timelineStatusForAccount: error getting timelineability for status for timeline with id %s: %s", accountID, err) - return - } - - if !timelineable { - return + if timelineable, err := p.filter.StatusHometimelineable(ctx, status, timelineAccount); err != nil { + return fmt.Errorf("timelineStatusForAccount: error getting timelineability for status for timeline with id %s: %w", accountID, err) + } else if !timelineable { + return nil } // stick the status in the timeline for the account and then immediately prepare it so they can see it right away - inserted, err := p.statusTimelines.IngestAndPrepare(ctx, status, timelineAccount.ID) - if err != nil { - errors <- fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %s", status.ID, err) - return + if inserted, err := p.statusTimelines.IngestAndPrepare(ctx, status, timelineAccount.ID); err != nil { + return fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %w", status.ID, err) + } else if !inserted { + return nil } // the status was inserted so stream it to the user - if inserted { - apiStatus, err := p.tc.StatusToAPIStatus(ctx, status, timelineAccount) - if err != nil { - errors <- fmt.Errorf("timelineStatusForAccount: error converting status %s to frontend representation: %s", status.ID, err) - return - } - - if err := p.stream.Update(apiStatus, timelineAccount, stream.TimelineHome); err != nil { - errors <- fmt.Errorf("timelineStatusForAccount: error streaming status %s: %s", status.ID, err) - } + apiStatus, err := p.tc.StatusToAPIStatus(ctx, status, timelineAccount) + if err != nil { + return fmt.Errorf("timelineStatusForAccount: error converting status %s to frontend representation: %w", status.ID, err) } + + if err := p.stream.Update(apiStatus, timelineAccount, stream.TimelineHome); err != nil { + return fmt.Errorf("timelineStatusForAccount: error streaming update for status %s: %w", status.ID, err) + } + + return nil } // deleteStatusFromTimelines completely removes the given status from all timelines. @@ -544,12 +520,17 @@ func (p *Processor) wipeStatus(ctx context.Context, statusToDelete *gtsmodel.Sta } // delete all notification entries generated by this status - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: statusToDelete.ID}}, &[]*gtsmodel.Notification{}); err != nil { + if err := p.state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil { return err } // delete all bookmarks that point to this status - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: statusToDelete.ID}}, &[]*gtsmodel.StatusBookmark{}); err != nil { + if err := p.state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil { + return err + } + + // delete all faves of this status + if err := p.state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil { return err } diff --git a/internal/processing/notification.go b/internal/processing/notification.go index 3f88d5441..aa81d863a 100644 --- a/internal/processing/notification.go +++ b/internal/processing/notification.go @@ -19,8 +19,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/log" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -39,7 +41,7 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ex return util.EmptyPageableResponse(), nil } - items := []interface{}{} + items := make([]interface{}, 0, count) nextMaxIDValue := "" prevMinIDValue := "" for i, n := range notifs { @@ -71,8 +73,8 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ex } func (p *Processor) NotificationsClear(ctx context.Context, authed *oauth.Auth) gtserror.WithCode { - err := p.state.DB.ClearNotifications(ctx, authed.Account.ID) - if err != nil { + // Delete all notifications that target the authorized account. + if err := p.state.DB.DeleteNotifications(ctx, authed.Account.ID, ""); err != nil && !errors.Is(err, db.ErrNoEntries) { return gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/processor.go b/internal/processing/processor.go index ad485b9ae..3e3854f69 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -138,20 +138,24 @@ func NewProcessor( return processor } -func (p *Processor) EnqueueClientAPI(ctx context.Context, msg messages.FromClientAPI) { - log.WithContext(ctx).WithField("msg", msg).Trace("enqueuing client API") +func (p *Processor) EnqueueClientAPI(ctx context.Context, msgs ...messages.FromClientAPI) { + log.Trace(ctx, "enqueuing client API") _ = p.state.Workers.ClientAPI.MustEnqueueCtx(ctx, func(ctx context.Context) { - if err := p.ProcessFromClientAPI(ctx, msg); err != nil { - log.Errorf(ctx, "error processing client API message: %v", err) + for _, msg := range msgs { + if err := p.ProcessFromClientAPI(ctx, msg); err != nil { + log.WithContext(ctx).WithField("msg", msg).Errorf("error processing client API message: %v", err) + } } }) } -func (p *Processor) EnqueueFederator(ctx context.Context, msg messages.FromFederator) { - log.WithContext(ctx).WithField("msg", msg).Trace("enqueuing federator") +func (p *Processor) EnqueueFederator(ctx context.Context, msgs ...messages.FromFederator) { + log.Trace(ctx, "enqueuing federator") _ = p.state.Workers.Federator.MustEnqueueCtx(ctx, func(ctx context.Context) { - if err := p.ProcessFromFederator(ctx, msg); err != nil { - log.Errorf(ctx, "error processing federator message: %v", err) + for _, msg := range msgs { + if err := p.ProcessFromFederator(ctx, msg); err != nil { + log.WithContext(ctx).WithField("msg", msg).Errorf("error processing federator message: %v", err) + } } }) } diff --git a/internal/processing/status/bookmark.go b/internal/processing/status/bookmark.go index 3324273d7..ea386b183 100644 --- a/internal/processing/status/bookmark.go +++ b/internal/processing/status/bookmark.go @@ -31,91 +31,67 @@ // BookmarkCreate adds a bookmark for the requestingAccount, targeting the given status (no-op if bookmark already exists). func (p *Processor) BookmarkCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - if targetStatus.Account == nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) - } - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - if !visible { - return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + targetStatus, existingBookmarkID, errWithCode := p.getBookmarkTarget(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } - // first check if the status is already bookmarked, if so we don't need to do anything - newBookmark := true - gtsBookmark := >smodel.StatusBookmark{} - if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil { - // we already have a bookmark for this status - newBookmark = false + if existingBookmarkID != "" { + // Status is already bookmarked. + return p.apiStatus(ctx, targetStatus, requestingAccount) } - if newBookmark { - // we need to create a new bookmark in the database - gtsBookmark := >smodel.StatusBookmark{ - ID: id.NewULID(), - AccountID: requestingAccount.ID, - Account: requestingAccount, - TargetAccountID: targetStatus.AccountID, - TargetAccount: targetStatus.Account, - StatusID: targetStatus.ID, - Status: targetStatus, - } - - if err := p.state.DB.Put(ctx, gtsBookmark); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting bookmark in database: %s", err)) - } + // Create and store a new bookmark. + gtsBookmark := >smodel.StatusBookmark{ + ID: id.NewULID(), + AccountID: requestingAccount.ID, + Account: requestingAccount, + TargetAccountID: targetStatus.AccountID, + TargetAccount: targetStatus.Account, + StatusID: targetStatus.ID, + Status: targetStatus, } - // return the apidon representation of the target status - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + if err := p.state.DB.PutStatusBookmark(ctx, gtsBookmark); err != nil { + err = fmt.Errorf("BookmarkCreate: error putting bookmark in database: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) } // BookmarkRemove removes a bookmark for the requesting account, targeting the given status (no-op if bookmark doesn't exist). func (p *Processor) BookmarkRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - if targetStatus.Account == nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) - } - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - if !visible { - return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + targetStatus, existingBookmarkID, errWithCode := p.getBookmarkTarget(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } - // first check if the status is actually bookmarked - toUnbookmark := false - gtsBookmark := >smodel.StatusBookmark{} - if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil { - // we have a bookmark for this status - toUnbookmark = true + if existingBookmarkID == "" { + // Status isn't bookmarked. + return p.apiStatus(ctx, targetStatus, requestingAccount) } - if toUnbookmark { - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unfaveing status: %s", err)) - } + // We have a bookmark to remove. + if err := p.state.DB.DeleteStatusBookmark(ctx, existingBookmarkID); err != nil { + err = fmt.Errorf("BookmarkRemove: error removing status bookmark: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // return the api representation of the target status - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) +} + +func (p *Processor) getBookmarkTarget(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, string, gtserror.WithCode) { + targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, "", errWithCode + } + + bookmarkID, err := p.state.DB.GetStatusBookmarkID(ctx, requestingAccount.ID, targetStatus.ID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("getBookmarkTarget: error checking existing bookmark: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + return targetStatus, bookmarkID, nil } diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go index 510e99a41..f5b5a4052 100644 --- a/internal/processing/status/boost.go +++ b/internal/processing/status/boost.go @@ -86,13 +86,7 @@ func (p *Processor) BoostCreate(ctx context.Context, requestingAccount *gtsmodel TargetAccount: targetStatus.Account, }) - // return the frontend representation of the new status to the submitter - apiStatus, err := p.tc.StatusToAPIStatus(ctx, boostWrapperStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, boostWrapperStatus, requestingAccount) } // BoostRemove processes the unboost/unreblog of a given status, returning the status if all is well. @@ -159,12 +153,7 @@ func (p *Processor) BoostRemove(ctx context.Context, requestingAccount *gtsmodel }) } - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) } // StatusBoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings. diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go new file mode 100644 index 000000000..5b73e9c94 --- /dev/null +++ b/internal/processing/status/common.go @@ -0,0 +1,63 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package status + +import ( + "context" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *Processor) apiStatus(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, gtserror.WithCode) { + apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) + if err != nil { + err = fmt.Errorf("error converting status %s to frontend representation: %w", targetStatus.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiStatus, nil +} + +func (p *Processor) getVisibleStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, gtserror.WithCode) { + targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) + if err != nil { + err = fmt.Errorf("getVisibleStatus: db error fetching status %s: %w", targetStatusID, err) + return nil, gtserror.NewErrorNotFound(err) + } + + if targetStatus.Account == nil { + err = fmt.Errorf("getVisibleStatus: no status owner for status %s", targetStatusID) + return nil, gtserror.NewErrorNotFound(err) + } + + visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) + if err != nil { + err = fmt.Errorf("getVisibleStatus: error seeing if status %s is visible: %w", targetStatus.ID, err) + return nil, gtserror.NewErrorNotFound(err) + } + + if !visible { + err = fmt.Errorf("getVisibleStatus: status %s is not visible to requesting account", targetStatusID) + return nil, gtserror.NewErrorNotFound(err) + } + + return targetStatus, nil +} diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index ad439de30..71db8c18e 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -93,13 +93,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, appli OriginAccount: account, }) - // return the frontend representation of the new status to the submitter - apiStatus, err := p.tc.StatusToAPIStatus(ctx, newStatus, account) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", newStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, newStatus, account) } func processReplyToID(ctx context.Context, dbService db.DB, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { diff --git a/internal/processing/status/delete.go b/internal/processing/status/delete.go index fc6743ed2..5549e0329 100644 --- a/internal/processing/status/delete.go +++ b/internal/processing/status/delete.go @@ -35,6 +35,7 @@ func (p *Processor) Delete(ctx context.Context, requestingAccount *gtsmodel.Acco if err != nil { return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) } + if targetStatus.Account == nil { return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) } @@ -43,12 +44,13 @@ func (p *Processor) Delete(ctx context.Context, requestingAccount *gtsmodel.Acco return nil, gtserror.NewErrorForbidden(errors.New("status doesn't belong to requesting account")) } - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + // Parse the status to API model BEFORE deleting it. + apiStatus, errWithCode := p.apiStatus(ctx, targetStatus, requestingAccount) + if errWithCode != nil { + return nil, errWithCode } - // send the status back to the processor for async processing + // Process delete side effects. p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityDelete, diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go index 19d4cdb22..da1bae8a1 100644 --- a/internal/processing/status/fave.go +++ b/internal/processing/status/fave.go @@ -28,181 +28,140 @@ "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" ) -// FaveCreate processes the faving of a given status, returning the updated status if the fave goes through. +// FaveCreate adds a fave for the requestingAccount, targeting the given status (no-op if fave already exists). func (p *Processor) FaveCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - if targetStatus.Account == nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) + targetStatus, existingFave, errWithCode := p.getFaveTarget(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - if !visible { - return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) - } - if !*targetStatus.Likeable { - return nil, gtserror.NewErrorForbidden(errors.New("status is not faveable")) + if existingFave != nil { + // Status is already faveed. + return p.apiStatus(ctx, targetStatus, requestingAccount) } - // first check if the status is already faved, if so we don't need to do anything - newFave := true - gtsFave := >smodel.StatusFave{} - if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsFave); err == nil { - // we already have a fave for this status - newFave = false + // Create and store a new fave + faveID := id.NewULID() + gtsFave := >smodel.StatusFave{ + ID: faveID, + AccountID: requestingAccount.ID, + Account: requestingAccount, + TargetAccountID: targetStatus.AccountID, + TargetAccount: targetStatus.Account, + StatusID: targetStatus.ID, + Status: targetStatus, + URI: uris.GenerateURIForLike(requestingAccount.Username, faveID), } - if newFave { - thisFaveID := id.NewULID() - - // we need to create a new fave in the database - gtsFave := >smodel.StatusFave{ - ID: thisFaveID, - AccountID: requestingAccount.ID, - Account: requestingAccount, - TargetAccountID: targetStatus.AccountID, - TargetAccount: targetStatus.Account, - StatusID: targetStatus.ID, - Status: targetStatus, - URI: uris.GenerateURIForLike(requestingAccount.Username, thisFaveID), - } - - if err := p.state.DB.Put(ctx, gtsFave); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting fave in database: %s", err)) - } - - // send it back to the processor for async processing - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ActivityLike, - APActivityType: ap.ActivityCreate, - GTSModel: gtsFave, - OriginAccount: requestingAccount, - TargetAccount: targetStatus.Account, - }) + if err := p.state.DB.PutStatusFave(ctx, gtsFave); err != nil { + err = fmt.Errorf("FaveCreate: error putting fave in database: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // return the apidon representation of the target status - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } + // Process new status fave side effects. + p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityCreate, + GTSModel: gtsFave, + OriginAccount: requestingAccount, + TargetAccount: targetStatus.Account, + }) - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) } -// FaveRemove processes the unfaving of a given status, returning the updated status if the fave goes through. +// FaveRemove removes a fave for the requesting account, targeting the given status (no-op if fave doesn't exist). func (p *Processor) FaveRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - if targetStatus.Account == nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) + targetStatus, existingFave, errWithCode := p.getFaveTarget(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - if !visible { - return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + if existingFave == nil { + // Status isn't faveed. + return p.apiStatus(ctx, targetStatus, requestingAccount) } - // check if we actually have a fave for this status - var toUnfave bool - - gtsFave := >smodel.StatusFave{} - err = p.state.DB.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsFave) - if err == nil { - // we have a fave - toUnfave = true - } - if err != nil { - // something went wrong in the db finding the fave - if err != db.ErrNoEntries { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching existing fave from database: %s", err)) - } - // we just don't have a fave - toUnfave = false + // We have a fave to remove. + if err := p.state.DB.DeleteStatusFave(ctx, existingFave.ID); err != nil { + err = fmt.Errorf("FaveRemove: error removing status fave: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - if toUnfave { - // we had a fave, so take some action to get rid of it - if err := p.state.DB.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsFave); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unfaveing status: %s", err)) - } + // Process remove status fave side effects. + p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityUndo, + GTSModel: existingFave, + OriginAccount: requestingAccount, + TargetAccount: targetStatus.Account, + }) - // send it back to the processor for async processing - p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ActivityLike, - APActivityType: ap.ActivityUndo, - GTSModel: gtsFave, - OriginAccount: requestingAccount, - TargetAccount: targetStatus.Account, - }) - } - - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) } // FavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. func (p *Processor) FavedBy(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - if targetStatus.Account == nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) + targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) + statusFaves, err := p.state.DB.GetStatusFaves(ctx, targetStatus.ID) if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - if !visible { - return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("FavedBy: error seeing who faved status: %s", err)) } - statusFaves, err := p.state.DB.GetStatusFaves(ctx, targetStatus) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing who faved status: %s", err)) - } - - // filter the list so the user doesn't see accounts they blocked or which blocked them - filteredAccounts := []*gtsmodel.Account{} + // For each fave, ensure that we're only showing + // the requester accounts that they don't block, + // and which don't block them. + apiAccounts := make([]*apimodel.Account, 0, len(statusFaves)) for _, fave := range statusFaves { - blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, fave.AccountID, true) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking blocks: %s", err)) + if blocked, err := p.state.DB.IsBlocked(ctx, requestingAccount.ID, fave.AccountID, true); err != nil { + err = fmt.Errorf("FavedBy: error checking blocks: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } else if blocked { + continue } - if !blocked { - filteredAccounts = append(filteredAccounts, fave.Account) - } - } - // now we can return the api representation of those accounts - apiAccounts := []*apimodel.Account{} - for _, acc := range filteredAccounts { - apiAccount, err := p.tc.AccountToAPIAccountPublic(ctx, acc) + if fave.Account == nil { + // Account isn't set for some reason, just skip. + log.WithContext(ctx).WithField("fave", fave).Warn("fave had no associated account") + continue + } + + apiAccount, err := p.tc.AccountToAPIAccountPublic(ctx, fave.Account) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + err = fmt.Errorf("FavedBy: error converting account %s to frontend representation: %w", fave.AccountID, err) + return nil, gtserror.NewErrorInternalError(err) } apiAccounts = append(apiAccounts, apiAccount) } return apiAccounts, nil } + +func (p *Processor) getFaveTarget(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, *gtsmodel.StatusFave, gtserror.WithCode) { + targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, nil, errWithCode + } + + if !*targetStatus.Likeable { + err := errors.New("status is not faveable") + return nil, nil, gtserror.NewErrorForbidden(err, err.Error()) + } + + fave, err := p.state.DB.GetStatusFaveByAccountID(ctx, requestingAccount.ID, targetStatusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("getFaveTarget: error checking existing fave: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + return targetStatus, fave, nil +} diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 393c6001e..251a095de 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -19,8 +19,6 @@ import ( "context" - "errors" - "fmt" "sort" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -30,46 +28,19 @@ // Get gets the given status, taking account of privacy settings and blocks etc. func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - if targetStatus.Account == nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) + targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - if !visible { - return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) - } - - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) } // ContextGet returns the context (previous and following posts) from the given status ID. func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - if targetStatus.Account == nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID)) - } - - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) - } - if !visible { - return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } context := &apimodel.Context{ diff --git a/internal/processing/status/pin.go b/internal/processing/status/pin.go index cea509c11..1e7dc40e8 100644 --- a/internal/processing/status/pin.go +++ b/internal/processing/status/pin.go @@ -38,40 +38,24 @@ // - Status belongs to requesting account. // - Status is public, unlisted, or followers-only. // - Status is not a boost. -func (p *Processor) getPinnableStatus(ctx context.Context, targetStatusID string, requestingAccountID string) (*gtsmodel.Status, gtserror.WithCode) { - targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID) - if err != nil { - err = fmt.Errorf("error fetching status %s: %w", targetStatusID, err) - return nil, gtserror.NewErrorNotFound(err) +func (p *Processor) getPinnableStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, gtserror.WithCode) { + targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID) + if errWithCode != nil { + return nil, errWithCode } - requestingAccount, err := p.state.DB.GetAccountByID(ctx, requestingAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - if !visible { - err = fmt.Errorf("status %s not visible to account %s", targetStatusID, requestingAccountID) - return nil, gtserror.NewErrorNotFound(err) - } - - if targetStatus.AccountID != requestingAccountID { - err = fmt.Errorf("status %s does not belong to account %s", targetStatusID, requestingAccountID) + if targetStatus.AccountID != requestingAccount.ID { + err := fmt.Errorf("status %s does not belong to account %s", targetStatusID, requestingAccount.ID) return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) } if targetStatus.Visibility == gtsmodel.VisibilityDirect { - err = errors.New("cannot pin direct messages") + err := errors.New("cannot pin direct messages") return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) } if targetStatus.BoostOfID != "" { - err = errors.New("cannot pin boosts") + err := errors.New("cannot pin boosts") return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) } @@ -89,7 +73,7 @@ func (p *Processor) getPinnableStatus(ctx context.Context, targetStatusID string // // If the conditions can't be met, then code 422 Unprocessable Entity will be returned. func (p *Processor) PinCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, errWithCode := p.getPinnableStatus(ctx, targetStatusID, requestingAccount.ID) + targetStatus, errWithCode := p.getPinnableStatus(ctx, requestingAccount, targetStatusID) if errWithCode != nil { return nil, errWithCode } @@ -114,12 +98,7 @@ func (p *Processor) PinCreate(ctx context.Context, requestingAccount *gtsmodel.A return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error pinning status: %w", err)) } - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %w", targetStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) } // PinRemove unpins the target status from the top of requestingAccount's profile, if possible. @@ -134,7 +113,7 @@ func (p *Processor) PinCreate(ctx context.Context, requestingAccount *gtsmodel.A // Unlike with PinCreate, statuses that are already unpinned will not return 422, but just do // nothing and return the api model representation of the status, to conform to the masto API. func (p *Processor) PinRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, errWithCode := p.getPinnableStatus(ctx, targetStatusID, requestingAccount.ID) + targetStatus, errWithCode := p.getPinnableStatus(ctx, requestingAccount, targetStatusID) if errWithCode != nil { return nil, errWithCode } @@ -146,10 +125,5 @@ func (p *Processor) PinRemove(ctx context.Context, requestingAccount *gtsmodel.A } } - apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %w", targetStatus.ID, err)) - } - - return apiStatus, nil + return p.apiStatus(ctx, targetStatus, requestingAccount) } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index dc6a06e04..bc42226ff 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -26,7 +26,6 @@ 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" @@ -60,15 +59,9 @@ func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode // then adding the Source object to it... // check pending follow requests aimed at this account - frs, err := c.db.GetAccountFollowRequests(ctx, a.ID) + frc, err := c.db.CountFollowRequests(ctx, "", a.ID) if err != nil { - if err != db.ErrNoEntries { - return nil, fmt.Errorf("error getting follow requests: %s", err) - } - } - var frc int - if frs != nil { - frc = len(frs) + return nil, fmt.Errorf("error counting follow requests: %s", err) } statusContentType := string(apimodel.StatusContentTypeDefault) @@ -91,13 +84,13 @@ func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { // count followers - followersCount, err := c.db.CountAccountFollowedBy(ctx, a.ID, false) + followersCount, err := c.db.CountFollows(ctx, "", a.ID) if err != nil { return nil, fmt.Errorf("error counting followers: %s", err) } // count following - followingCount, err := c.db.CountAccountFollows(ctx, a.ID, false) + followingCount, err := c.db.CountFollows(ctx, a.ID, "") if err != nil { return nil, fmt.Errorf("error counting following: %s", err) } diff --git a/internal/workers/workers.go b/internal/workers/workers.go index 88e92be49..bf64a28ee 100644 --- a/internal/workers/workers.go +++ b/internal/workers/workers.go @@ -38,8 +38,8 @@ type Workers struct { // Enqueue functions for clientAPI / federator worker pools, // these are pointers to Processor{}.Enqueue___() msg functions. // This prevents dependency cycling as Processor depends on Workers. - EnqueueClientAPI func(context.Context, messages.FromClientAPI) - EnqueueFederator func(context.Context, messages.FromFederator) + EnqueueClientAPI func(context.Context, ...messages.FromClientAPI) + EnqueueFederator func(context.Context, ...messages.FromFederator) // Media manager worker pools. Media runners.WorkerPool diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 6e3f87258..269bc9470 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1845,6 +1845,15 @@ func NewTestNotifications() map[string]*gtsmodel.Notification { StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY", Read: FalseBool(), }, + "local_account_2_like": { + ID: "01GTS6PRPXJYZBPFFQ56PP0XR8", + NotificationType: gtsmodel.NotificationFave, + CreatedAt: TimeMustParse("2022-01-13T12:45:01+02:00"), + TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", + OriginAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", + StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R", + Read: FalseBool(), + }, } } diff --git a/testrig/util.go b/testrig/util.go index 2de5a7c40..d7183df1c 100644 --- a/testrig/util.go +++ b/testrig/util.go @@ -31,8 +31,8 @@ ) func StartWorkers(state *state.State) { - state.Workers.EnqueueClientAPI = func(context.Context, messages.FromClientAPI) {} - state.Workers.EnqueueFederator = func(context.Context, messages.FromFederator) {} + state.Workers.EnqueueClientAPI = func(context.Context, ...messages.FromClientAPI) {} + state.Workers.EnqueueFederator = func(context.Context, ...messages.FromFederator) {} _ = state.Workers.Scheduler.Start(nil) _ = state.Workers.ClientAPI.Start(1, 10)