diff options
Diffstat (limited to 'internal/db')
-rw-r--r-- | internal/db/bundb/account.go | 22 | ||||
-rw-r--r-- | internal/db/bundb/basic_test.go | 2 | ||||
-rw-r--r-- | internal/db/bundb/bundb_test.go | 46 | ||||
-rw-r--r-- | internal/db/bundb/instance.go | 3 | ||||
-rw-r--r-- | internal/db/bundb/interaction.go | 289 | ||||
-rw-r--r-- | internal/db/bundb/interaction_test.go | 261 | ||||
-rw-r--r-- | internal/db/bundb/migrations/20240716151327_interaction_policy.go | 29 | ||||
-rw-r--r-- | internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go | 154 | ||||
-rw-r--r-- | internal/db/bundb/timeline.go | 38 | ||||
-rw-r--r-- | internal/db/interaction.go | 46 |
10 files changed, 762 insertions, 128 deletions
diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 94fd054bf..b57dcb57b 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -1285,34 +1285,40 @@ func (a *accountDB) RegenerateAccountStats(ctx context.Context, account *gtsmode if err := a.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { var err error - // Scan database for account statuses. + // Scan database for account statuses, ignoring + // statuses that are currently pending approval. statusesCount, err := tx.NewSelect(). - Table("statuses"). - Where("? = ?", bun.Ident("account_id"), account.ID). + TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). + Where("? = ?", bun.Ident("status.account_id"), account.ID). + Where("NOT ? = ?", bun.Ident("status.pending_approval"), true). Count(ctx) if err != nil { return err } stats.StatusesCount = &statusesCount - // Scan database for pinned statuses. + // Scan database for pinned statuses, ignoring + // statuses that are currently pending approval. statusesPinnedCount, err := tx.NewSelect(). - Table("statuses"). - Where("? = ?", bun.Ident("account_id"), account.ID). - Where("? IS NOT NULL", bun.Ident("pinned_at")). + TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). + Where("? = ?", bun.Ident("status.account_id"), account.ID). + Where("? IS NOT NULL", bun.Ident("status.pinned_at")). + Where("NOT ? = ?", bun.Ident("status.pending_approval"), true). Count(ctx) if err != nil { return err } stats.StatusesPinnedCount = &statusesPinnedCount - // Scan database for last status. + // Scan database for last status, ignoring + // statuses that are currently pending approval. lastStatusAt := time.Time{} err = tx. NewSelect(). TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). Column("status.created_at"). Where("? = ?", bun.Ident("status.account_id"), account.ID). + Where("NOT ? = ?", bun.Ident("status.pending_approval"), true). Order("status.id DESC"). Limit(1). Scan(ctx, &lastStatusAt) diff --git a/internal/db/bundb/basic_test.go b/internal/db/bundb/basic_test.go index f6647c1f5..56159dc25 100644 --- a/internal/db/bundb/basic_test.go +++ b/internal/db/bundb/basic_test.go @@ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() { s := []*gtsmodel.Status{} err := suite.db.GetAll(context.Background(), &s) suite.NoError(err) - suite.Len(s, 24) + suite.Len(s, 25) } func (suite *BasicTestSuite) TestGetAllNotNull() { diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index adfd605a5..e976199e4 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -34,28 +34,29 @@ type BunDBStandardTestSuite struct { state state.State // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - testTags map[string]*gtsmodel.Tag - testMentions map[string]*gtsmodel.Mention - 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 - testLists map[string]*gtsmodel.List - testListEntries map[string]*gtsmodel.ListEntry - testAccountNotes map[string]*gtsmodel.AccountNote - testMarkers map[string]*gtsmodel.Marker - testRules map[string]*gtsmodel.Rule - testThreads map[string]*gtsmodel.Thread - testPolls map[string]*gtsmodel.Poll - testPollVotes map[string]*gtsmodel.PollVote + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testTags map[string]*gtsmodel.Tag + testMentions map[string]*gtsmodel.Mention + 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 + testLists map[string]*gtsmodel.List + testListEntries map[string]*gtsmodel.ListEntry + testAccountNotes map[string]*gtsmodel.AccountNote + testMarkers map[string]*gtsmodel.Marker + testRules map[string]*gtsmodel.Rule + testThreads map[string]*gtsmodel.Thread + testPolls map[string]*gtsmodel.Poll + testPollVotes map[string]*gtsmodel.PollVote + testInteractionRequests map[string]*gtsmodel.InteractionRequest } func (suite *BunDBStandardTestSuite) SetupSuite() { @@ -81,6 +82,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { suite.testThreads = testrig.NewTestThreads() suite.testPolls = testrig.NewTestPolls() suite.testPollVotes = testrig.NewTestPollVotes() + suite.testInteractionRequests = testrig.NewTestInteractionRequests() } func (suite *BunDBStandardTestSuite) SetupTest() { diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go index 6d98c8db7..008b6c8f3 100644 --- a/internal/db/bundb/instance.go +++ b/internal/db/bundb/instance.go @@ -76,6 +76,9 @@ func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) ( Where("? = ?", bun.Ident("account.domain"), domain) } + // Ignore statuses that are currently pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + count, err := q.Count(ctx) if err != nil { return 0, err diff --git a/internal/db/bundb/interaction.go b/internal/db/bundb/interaction.go index 2ded70311..78abcc763 100644 --- a/internal/db/bundb/interaction.go +++ b/internal/db/bundb/interaction.go @@ -19,10 +19,14 @@ package bundb import ( "context" + "errors" + "slices" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/uptrace/bun" ) @@ -32,56 +36,70 @@ type interactionDB struct { state *state.State } -func (r *interactionDB) newInteractionApprovalQ(approval interface{}) *bun.SelectQuery { - return r.db. +func (i *interactionDB) newInteractionRequestQ(request interface{}) *bun.SelectQuery { + return i.db. NewSelect(). - Model(approval) + Model(request) } -func (r *interactionDB) GetInteractionApprovalByID(ctx context.Context, id string) (*gtsmodel.InteractionApproval, error) { - return r.getInteractionApproval( +func (i *interactionDB) GetInteractionRequestByID(ctx context.Context, id string) (*gtsmodel.InteractionRequest, error) { + return i.getInteractionRequest( ctx, "ID", - func(approval *gtsmodel.InteractionApproval) error { - return r. - newInteractionApprovalQ(approval). - Where("? = ?", bun.Ident("interaction_approval.id"), id). + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.id"), id). Scan(ctx) }, id, ) } -func (r *interactionDB) GetInteractionApprovalByURI(ctx context.Context, uri string) (*gtsmodel.InteractionApproval, error) { - return r.getInteractionApproval( +func (i *interactionDB) GetInteractionRequestByInteractionURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) { + return i.getInteractionRequest( + ctx, + "InteractionURI", + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.interaction_uri"), uri). + Scan(ctx) + }, + uri, + ) +} + +func (i *interactionDB) GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) { + return i.getInteractionRequest( ctx, "URI", - func(approval *gtsmodel.InteractionApproval) error { - return r. - newInteractionApprovalQ(approval). - Where("? = ?", bun.Ident("interaction_approval.uri"), uri). + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.uri"), uri). Scan(ctx) }, uri, ) } -func (r *interactionDB) getInteractionApproval( +func (i *interactionDB) getInteractionRequest( ctx context.Context, lookup string, - dbQuery func(*gtsmodel.InteractionApproval) error, + dbQuery func(*gtsmodel.InteractionRequest) error, keyParts ...any, -) (*gtsmodel.InteractionApproval, error) { - // Fetch approval from database cache with loader callback - approval, err := r.state.Caches.DB.InteractionApproval.LoadOne(lookup, func() (*gtsmodel.InteractionApproval, error) { - var approval gtsmodel.InteractionApproval +) (*gtsmodel.InteractionRequest, error) { + // Fetch request from database cache with loader callback + request, err := i.state.Caches.DB.InteractionRequest.LoadOne(lookup, func() (*gtsmodel.InteractionRequest, error) { + var request gtsmodel.InteractionRequest // Not cached! Perform database query - if err := dbQuery(&approval); err != nil { + if err := dbQuery(&request); err != nil { return nil, err } - return &approval, nil + return &request, nil }, keyParts...) if err != nil { // Error already processed. @@ -90,60 +108,241 @@ func (r *interactionDB) getInteractionApproval( if gtscontext.Barebones(ctx) { // Only a barebones model was requested. - return approval, nil + return request, nil } - if err := r.PopulateInteractionApproval(ctx, approval); err != nil { + if err := i.PopulateInteractionRequest(ctx, request); err != nil { return nil, err } - return approval, nil + return request, nil } -func (r *interactionDB) PopulateInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error { +func (i *interactionDB) PopulateInteractionRequest(ctx context.Context, req *gtsmodel.InteractionRequest) error { var ( err error - errs = gtserror.NewMultiError(2) + errs = gtserror.NewMultiError(4) ) - if approval.Account == nil { - // Account is not set, fetch from the database. - approval.Account, err = r.state.DB.GetAccountByID( + if req.Status == nil { + // Target status is not set, fetch from the database. + req.Status, err = i.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + req.StatusID, + ) + if err != nil { + errs.Appendf("error populating interactionRequest target: %w", err) + } + } + + if req.TargetAccount == nil { + // Target account is not set, fetch from the database. + req.TargetAccount, err = i.state.DB.GetAccountByID( gtscontext.SetBarebones(ctx), - approval.AccountID, + req.TargetAccountID, ) if err != nil { - errs.Appendf("error populating interactionApproval account: %w", err) + errs.Appendf("error populating interactionRequest target account: %w", err) } } - if approval.InteractingAccount == nil { + if req.InteractingAccount == nil { // InteractingAccount is not set, fetch from the database. - approval.InteractingAccount, err = r.state.DB.GetAccountByID( + req.InteractingAccount, err = i.state.DB.GetAccountByID( gtscontext.SetBarebones(ctx), - approval.InteractingAccountID, + req.InteractingAccountID, ) if err != nil { - errs.Appendf("error populating interactionApproval interacting account: %w", err) + errs.Appendf("error populating interactionRequest interacting account: %w", err) + } + } + + // Depending on the interaction type, *try* to populate + // the related model, but don't error if this is not + // possible, as it may have just already been deleted + // by its owner and we haven't cleaned up yet. + switch req.InteractionType { + + case gtsmodel.InteractionLike: + req.Like, err = i.state.DB.GetStatusFaveByURI(ctx, req.InteractionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs.Appendf("error populating interactionRequest Like: %w", err) + } + + case gtsmodel.InteractionReply: + req.Reply, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs.Appendf("error populating interactionRequest Reply: %w", err) + } + + case gtsmodel.InteractionAnnounce: + req.Announce, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs.Appendf("error populating interactionRequest Announce: %w", err) } } return errs.Combine() } -func (r *interactionDB) PutInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error { - return r.state.Caches.DB.InteractionApproval.Store(approval, func() error { - _, err := r.db.NewInsert().Model(approval).Exec(ctx) +func (i *interactionDB) PutInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error { + return i.state.Caches.DB.InteractionRequest.Store(request, func() error { + _, err := i.db.NewInsert().Model(request).Exec(ctx) return err }) } -func (r *interactionDB) DeleteInteractionApprovalByID(ctx context.Context, id string) error { - defer r.state.Caches.DB.InteractionApproval.Invalidate("ID", id) +func (i *interactionDB) UpdateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest, columns ...string) error { + return i.state.Caches.DB.InteractionRequest.Store(request, func() error { + _, err := i.db. + NewUpdate(). + Model(request). + Where("? = ?", bun.Ident("interaction_request.id"), request.ID). + Column(columns...). + Exec(ctx) + return err + }) +} + +func (i *interactionDB) DeleteInteractionRequestByID(ctx context.Context, id string) error { + defer i.state.Caches.DB.InteractionRequest.Invalidate("ID", id) - _, err := r.db.NewDelete(). - TableExpr("? AS ?", bun.Ident("interaction_approvals"), bun.Ident("interaction_approval")). - Where("? = ?", bun.Ident("interaction_approval.id"), id). + _, err := i.db.NewDelete(). + TableExpr("? AS ?", bun.Ident("interaction_requests"), bun.Ident("interaction_request")). + Where("? = ?", bun.Ident("interaction_request.id"), id). Exec(ctx) return err } + +func (i *interactionDB) GetInteractionsRequestsForAcct( + ctx context.Context, + acctID string, + statusID string, + likes bool, + replies bool, + boosts bool, + page *paging.Page, +) ([]*gtsmodel.InteractionRequest, error) { + if !likes && !replies && !boosts { + return nil, gtserror.New("at least one of likes, replies, or boosts must be true") + } + + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + reqIDs = make([]string, 0, limit) + ) + + // Create the basic select query. + q := i.db. + NewSelect(). + Column("id"). + TableExpr( + "? AS ?", + bun.Ident("interaction_requests"), + bun.Ident("interaction_request"), + ). + // Select only interaction requests that + // are neither accepted or rejected yet, + // ie., without an Accept or Reject URI. + Where("? IS NULL", bun.Ident("uri")) + + // Select interactions targeting status. + if statusID != "" { + q = q.Where("? = ?", bun.Ident("status_id"), statusID) + } + + // Select interactions targeting account. + if acctID != "" { + q = q.Where("? = ?", bun.Ident("target_account_id"), acctID) + } + + // Figure out which types of interaction are + // being sought, and add them to the query. + wantTypes := make([]gtsmodel.InteractionType, 0, 3) + if likes { + wantTypes = append(wantTypes, gtsmodel.InteractionLike) + } + if replies { + wantTypes = append(wantTypes, gtsmodel.InteractionReply) + } + if boosts { + wantTypes = append(wantTypes, gtsmodel.InteractionAnnounce) + } + q = q.Where("? IN (?)", bun.Ident("interaction_type"), bun.In(wantTypes)) + + // Add paging param max ID. + if maxID != "" { + q = q.Where("? < ?", bun.Ident("id"), maxID) + } + + // Add paging param min ID. + if minID != "" { + q = q.Where("? > ?", bun.Ident("id"), minID) + } + + // Add paging param order. + if order == paging.OrderAscending { + // Page up. + q = q.OrderExpr("? ASC", bun.Ident("id")) + } else { + // Page down. + q = q.OrderExpr("? DESC", bun.Ident("id")) + } + + // Add paging param limit. + if limit > 0 { + q = q.Limit(limit) + } + + // Execute the query and scan into IDs. + err := q.Scan(ctx, &reqIDs) + if err != nil { + return nil, err + } + + // Catch case of no items early + if len(reqIDs) == 0 { + return nil, db.ErrNoEntries + } + + // If we're paging up, we still want interactions + // to be sorted by ID desc, so reverse ids slice. + if order == paging.OrderAscending { + slices.Reverse(reqIDs) + } + + // For each interaction request ID, + // select the interaction request. + reqs := make([]*gtsmodel.InteractionRequest, 0, len(reqIDs)) + for _, id := range reqIDs { + req, err := i.GetInteractionRequestByID(ctx, id) + if err != nil { + return nil, err + } + + reqs = append(reqs, req) + } + + return reqs, nil +} + +func (i *interactionDB) IsInteractionRejected(ctx context.Context, interactionURI string) (bool, error) { + req, err := i.GetInteractionRequestByInteractionURI(ctx, interactionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return false, gtserror.Newf("db error getting interaction request: %w", err) + } + + if req == nil { + // No interaction req at all with this + // interactionURI so it can't be rejected. + return false, nil + } + + return req.IsRejected(), nil +} diff --git a/internal/db/bundb/interaction_test.go b/internal/db/bundb/interaction_test.go new file mode 100644 index 000000000..37684f18c --- /dev/null +++ b/internal/db/bundb/interaction_test.go @@ -0,0 +1,261 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package bundb_test + +import ( + "context" + "errors" + "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/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +type InteractionTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *InteractionTestSuite) markInteractionsPending( + ctx context.Context, + statusID string, +) (pendingCount int) { + // Get replies of given status. + replies, err := suite.state.DB.GetStatusReplies(ctx, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + // Mark each reply as pending approval. + for _, reply := range replies { + reply.PendingApproval = util.Ptr(true) + if err := suite.state.DB.UpdateStatus( + ctx, + reply, + "pending_approval", + ); err != nil { + suite.FailNow(err.Error()) + } + + // Put an interaction request + // in the DB for this reply. + req, err := typeutils.StatusToInteractionRequest(ctx, reply) + if err != nil { + suite.FailNow(err.Error()) + } + + if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { + suite.FailNow(err.Error()) + } + + pendingCount++ + } + + // Get boosts of given status. + boosts, err := suite.state.DB.GetStatusBoosts(ctx, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + // Mark each boost as pending approval. + for _, boost := range boosts { + boost.PendingApproval = util.Ptr(true) + if err := suite.state.DB.UpdateStatus( + ctx, + boost, + "pending_approval", + ); err != nil { + suite.FailNow(err.Error()) + } + + // Put an interaction request + // in the DB for this boost. + req, err := typeutils.StatusToInteractionRequest(ctx, boost) + if err != nil { + suite.FailNow(err.Error()) + } + + if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { + suite.FailNow(err.Error()) + } + + pendingCount++ + } + + // Get faves of given status. + faves, err := suite.state.DB.GetStatusFaves(ctx, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + // Mark each fave as pending approval. + for _, fave := range faves { + fave.PendingApproval = util.Ptr(true) + if err := suite.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + ); err != nil { + suite.FailNow(err.Error()) + } + + // Put an interaction request + // in the DB for this fave. + req, err := typeutils.StatusFaveToInteractionRequest(ctx, fave) + if err != nil { + suite.FailNow(err.Error()) + } + + if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { + suite.FailNow(err.Error()) + } + + pendingCount++ + } + + return pendingCount +} + +func (suite *InteractionTestSuite) TestGetPending() { + var ( + testStatus = suite.testStatuses["local_account_1_status_1"] + ctx = context.Background() + acctID = suite.testAccounts["local_account_1"].ID + statusID = "" + likes = true + replies = true + boosts = true + page = &paging.Page{ + Max: paging.MaxID(id.Highest), + Limit: 20, + } + ) + + // Update target test status to mark + // all interactions with it pending. + pendingCount := suite.markInteractionsPending(ctx, testStatus.ID) + + // Get pendingInts interactions. + pendingInts, err := suite.state.DB.GetInteractionsRequestsForAcct( + ctx, + acctID, + statusID, + likes, + replies, + boosts, + page, + ) + suite.NoError(err) + suite.Len(pendingInts, pendingCount) + + // Ensure relevant model populated. + for _, pendingInt := range pendingInts { + switch pendingInt.InteractionType { + + case gtsmodel.InteractionLike: + suite.NotNil(pendingInt.Like) + + case gtsmodel.InteractionReply: + suite.NotNil(pendingInt.Reply) + + case gtsmodel.InteractionAnnounce: + suite.NotNil(pendingInt.Announce) + } + } +} + +func (suite *InteractionTestSuite) TestGetPendingRepliesOnly() { + var ( + testStatus = suite.testStatuses["local_account_1_status_1"] + ctx = context.Background() + acctID = suite.testAccounts["local_account_1"].ID + statusID = "" + likes = false + replies = true + boosts = false + page = &paging.Page{ + Max: paging.MaxID(id.Highest), + Limit: 20, + } + ) + + // Update target test status to mark + // all interactions with it pending. + suite.markInteractionsPending(ctx, testStatus.ID) + + // Get pendingInts interactions. + pendingInts, err := suite.state.DB.GetInteractionsRequestsForAcct( + ctx, + acctID, + statusID, + likes, + replies, + boosts, + page, + ) + suite.NoError(err) + + // Ensure only replies returned. + for _, pendingInt := range pendingInts { + suite.Equal(gtsmodel.InteractionReply, pendingInt.InteractionType) + } +} + +func (suite *InteractionTestSuite) TestInteractionRejected() { + var ( + ctx = context.Background() + req = new(gtsmodel.InteractionRequest) + ) + + // Make a copy of the request we'll modify. + *req = *suite.testInteractionRequests["admin_account_reply_turtle"] + + // No rejection in the db for this interaction URI so it should be OK. + rejected, err := suite.state.DB.IsInteractionRejected(ctx, req.InteractionURI) + if err != nil { + suite.FailNow(err.Error()) + } + if rejected { + suite.FailNow("wanted rejected = false, got true") + } + + // Update the interaction request to mark it rejected. + req.RejectedAt = time.Now() + req.URI = "https://some.reject.uri" + if err := suite.state.DB.UpdateInteractionRequest(ctx, req, "uri", "rejected_at"); err != nil { + suite.FailNow(err.Error()) + } + + // Rejection in the db for this interaction URI now so it should be très mauvais. + rejected, err = suite.state.DB.IsInteractionRejected(ctx, req.InteractionURI) + if err != nil { + suite.FailNow(err.Error()) + } + if !rejected { + suite.FailNow("wanted rejected = true, got false") + } +} + +func TestInteractionTestSuite(t *testing.T) { + suite.Run(t, new(InteractionTestSuite)) +} diff --git a/internal/db/bundb/migrations/20240716151327_interaction_policy.go b/internal/db/bundb/migrations/20240716151327_interaction_policy.go index fb0d1d752..210d4b2c5 100644 --- a/internal/db/bundb/migrations/20240716151327_interaction_policy.go +++ b/internal/db/bundb/migrations/20240716151327_interaction_policy.go @@ -20,41 +20,12 @@ package migrations import ( "context" - gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/uptrace/bun" ) func init() { up := func(ctx context.Context, db *bun.DB) error { return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { - if _, err := tx. - NewCreateTable(). - Model(>smodel.InteractionApproval{}). - IfNotExists(). - Exec(ctx); err != nil { - return err - } - - if _, err := tx. - NewCreateIndex(). - Table("interaction_approvals"). - Index("interaction_approvals_account_id_idx"). - Column("account_id"). - IfNotExists(). - Exec(ctx); err != nil { - return err - } - - if _, err := tx. - NewCreateIndex(). - Table("interaction_approvals"). - Index("interaction_approvals_interacting_account_id_idx"). - Column("interacting_account_id"). - IfNotExists(). - Exec(ctx); err != nil { - return err - } - return nil }) } diff --git a/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go new file mode 100644 index 000000000..82c2b4016 --- /dev/null +++ b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go @@ -0,0 +1,154 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + + // Drop interaction approvals table if it exists, + // ie., if instance was running on main between now + // and 2024-07-16. + // + // We might lose some interaction approvals this way, + // but since they weren't *really* used much yet this + // it's not a big deal, that's the running-on-main life! + if _, err := tx.NewDropTable(). + Table("interaction_approvals"). + IfExists(). + Exec(ctx); err != nil { + return err + } + + // Add `interaction_requests` + // table and new indexes. + if _, err := tx. + NewCreateTable(). + Model(>smodel.InteractionRequest{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + for idx, col := range map[string]string{ + "interaction_requests_status_id_idx": "status_id", + "interaction_requests_target_account_id_idx": "target_account_id", + "interaction_requests_interacting_account_id_idx": "interacting_account_id", + } { + if _, err := tx. + NewCreateIndex(). + Table("interaction_requests"). + Index(idx). + Column(col). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + } + + // Select all pending statuses (replies or boosts). + pendingStatuses := []*gtsmodel.Status{} + err := tx. + NewSelect(). + Model(&pendingStatuses). + Column( + "created_at", + "in_reply_to_id", + "boost_of_id", + "in_reply_to_account_id", + "boost_of_account_id", + "account_id", + "uri", + ). + Where("? = ?", bun.Ident("pending_approval"), true). + Scan(ctx) + if err != nil { + return err + } + + // For each currently pending status, check whether it's a reply or + // a boost, and insert a corresponding interaction request into the db. + for _, pendingStatus := range pendingStatuses { + req, err := typeutils.StatusToInteractionRequest(ctx, pendingStatus) + if err != nil { + return err + } + + if _, err := tx. + NewInsert(). + Model(req). + Exec(ctx); err != nil { + return err + } + } + + // Now do the same thing for pending faves. + pendingFaves := []*gtsmodel.StatusFave{} + err = tx. + NewSelect(). + Model(&pendingFaves). + Column( + "created_at", + "status_id", + "target_account_id", + "account_id", + "uri", + ). + Where("? = ?", bun.Ident("pending_approval"), true). + Scan(ctx) + if err != nil { + return err + } + + for _, pendingFave := range pendingFaves { + req, err := typeutils.StatusFaveToInteractionRequest(ctx, pendingFave) + if err != nil { + return err + } + + if _, err := tx. + NewInsert(). + Model(req). + Exec(ctx); err != nil { + return err + } + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/timeline.go b/internal/db/bundb/timeline.go index e6c7e482d..995c4e84f 100644 --- a/internal/db/bundb/timeline.go +++ b/internal/db/bundb/timeline.go @@ -89,19 +89,6 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI q = q.Where("? = ?", bun.Ident("status.local"), local) } - if limit > 0 { - // limit amount of statuses returned - q = q.Limit(limit) - } - - if frontToBack { - // Page down. - q = q.Order("status.id DESC") - } else { - // Page up. - q = q.Order("status.id ASC") - } - // As this is the home timeline, it should be // populated by statuses from accounts followed // by accountID, and posts from accountID itself. @@ -137,6 +124,22 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI bun.In(targetAccountIDs), ) + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + + if limit > 0 { + // limit amount of statuses returned + q = q.Limit(limit) + } + + if frontToBack { + // Page down. + q = q.Order("status.id DESC") + } else { + // Page up. + q = q.Order("status.id ASC") + } + if err := q.Scan(ctx, &statusIDs); err != nil { return nil, err } @@ -213,6 +216,9 @@ func (t *timelineDB) GetPublicTimeline(ctx context.Context, maxID string, sinceI q = q.Where("? = ?", bun.Ident("status.local"), local) } + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + if limit > 0 { // limit amount of statuses returned q = q.Limit(limit) @@ -395,6 +401,9 @@ func (t *timelineDB) GetListTimeline( frontToBack = false } + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + if limit > 0 { // limit amount of statuses returned q = q.Limit(limit) @@ -491,6 +500,9 @@ func (t *timelineDB) GetTagTimeline( frontToBack = false } + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + if limit > 0 { // limit amount of statuses returned q = q.Limit(limit) diff --git a/internal/db/interaction.go b/internal/db/interaction.go index 6f595c54e..a3a3afde9 100644 --- a/internal/db/interaction.go +++ b/internal/db/interaction.go @@ -21,21 +21,47 @@ import ( "context" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) type Interaction interface { - // GetInteractionApprovalByID gets one approval with the given id. - GetInteractionApprovalByID(ctx context.Context, id string) (*gtsmodel.InteractionApproval, error) + // GetInteractionRequestByID gets one request with the given id. + GetInteractionRequestByID(ctx context.Context, id string) (*gtsmodel.InteractionRequest, error) - // GetInteractionApprovalByID gets one approval with the given uri. - GetInteractionApprovalByURI(ctx context.Context, id string) (*gtsmodel.InteractionApproval, error) + // GetInteractionRequestByID gets one request with the given interaction uri. + GetInteractionRequestByInteractionURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) - // PopulateInteractionApproval ensures that the approval's struct fields are populated. - PopulateInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error + // GetInteractionRequestByURI returns one accepted or rejected + // interaction request with the given URI, if it exists in the db. + GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) - // PutInteractionApproval puts a new approval in the database. - PutInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error + // PopulateInteractionRequest ensures that the request's struct fields are populated. + PopulateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error - // DeleteInteractionApprovalByID deletes one approval with the given ID. - DeleteInteractionApprovalByID(ctx context.Context, id string) error + // PutInteractionRequest puts a new request in the database. + PutInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error + + // UpdateInteractionRequest updates the given interaction request. + UpdateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest, columns ...string) error + + // DeleteInteractionRequestByID deletes one request with the given ID. + DeleteInteractionRequestByID(ctx context.Context, id string) error + + // GetInteractionsRequestsForAcct returns pending interactions targeting + // the given (optional) account ID and the given (optional) status ID. + // + // At least one of `likes`, `replies`, or `boosts` must be true. + GetInteractionsRequestsForAcct( + ctx context.Context, + acctID string, + statusID string, + likes bool, + replies bool, + boosts bool, + page *paging.Page, + ) ([]*gtsmodel.InteractionRequest, error) + + // IsInteractionRejected returns true if an rejection exists in the database for an + // object with the given interactionURI (ie., a status or announce or fave uri). + IsInteractionRejected(ctx context.Context, interactionURI string) (bool, error) } |