From 9770d54237bea828cab7e50aec7dff452c203138 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 9 Aug 2023 19:14:33 +0200 Subject: [PATCH] [feature] List replies policy, refactor async workers (#2087) * Add/update some DB functions. * move async workers into subprocessor * rename FromFederator -> FromFediAPI * update home timeline check to include check for current status first before moving to parent status * change streamMap to pointer to mollify linter * update followtoas func signature * fix merge * remove errant debug log * don't use separate errs.Combine() check to wrap errs * wrap parts of workers functionality in sub-structs * populate report using new db funcs * embed federator (tiny bit tidier) * flesh out error msg, add continue(!) * fix other error messages to be more specific * better, nicer * give parseURI util function a bit more util * missing headers * use pointers for subprocessors --- cmd/gotosocial/action/server/server.go | 4 +- internal/db/bundb/account.go | 6 +- internal/db/bundb/instance.go | 6 +- internal/db/bundb/list.go | 22 +- internal/db/bundb/list_test.go | 21 + internal/db/bundb/relationship_block.go | 51 +- internal/db/bundb/relationship_follow.go | 6 +- internal/db/bundb/relationship_follow_req.go | 51 +- internal/db/bundb/report.go | 85 +- internal/db/bundb/statusfave.go | 6 +- internal/db/list.go | 3 + internal/db/relationship.go | 6 + internal/db/report.go | 7 + internal/federation/federatingdb/accept.go | 4 +- internal/federation/federatingdb/announce.go | 2 +- internal/federation/federatingdb/create.go | 12 +- internal/federation/federatingdb/delete.go | 4 +- .../federatingdb/federatingdb_test.go | 6 +- .../federation/federatingdb/reject_test.go | 2 +- internal/federation/federatingdb/update.go | 2 +- internal/gtserror/new.go | 22 +- internal/messages/messages.go | 4 +- internal/processing/fromclientapi.go | 1021 ----------------- internal/processing/fromclientapi_test.go | 273 ----- internal/processing/fromcommon.go | 587 ---------- internal/processing/fromfederator.go | 486 -------- internal/processing/processor.go | 97 +- internal/processing/processor_test.go | 4 +- internal/processing/stream/stream.go | 3 +- internal/processing/user/email.go | 54 - internal/processing/user/email_test.go | 31 - internal/processing/workers/federate.go | 892 ++++++++++++++ internal/processing/workers/fromclientapi.go | 548 +++++++++ .../processing/workers/fromclientapi_test.go | 589 ++++++++++ internal/processing/workers/fromfediapi.go | 540 +++++++++ .../fromfediapi_test.go} | 40 +- internal/processing/workers/surface.go | 40 + internal/processing/workers/surfaceemail.go | 160 +++ internal/processing/workers/surfacenotify.go | 221 ++++ .../processing/workers/surfacetimeline.go | 401 +++++++ internal/processing/workers/wipestatus.go | 119 ++ internal/processing/workers/workers.go | 92 ++ internal/processing/workers/workers_test.go | 169 +++ internal/typeutils/converter.go | 2 +- internal/typeutils/internaltoas.go | 14 +- internal/visibility/home_timeline.go | 47 +- internal/workers/workers.go | 2 +- testrig/processor.go | 4 +- testrig/util.go | 2 +- 49 files changed, 4110 insertions(+), 2660 deletions(-) delete mode 100644 internal/processing/fromclientapi.go delete mode 100644 internal/processing/fromclientapi_test.go delete mode 100644 internal/processing/fromcommon.go delete mode 100644 internal/processing/fromfederator.go create mode 100644 internal/processing/workers/federate.go create mode 100644 internal/processing/workers/fromclientapi.go create mode 100644 internal/processing/workers/fromclientapi_test.go create mode 100644 internal/processing/workers/fromfediapi.go rename internal/processing/{fromfederator_test.go => workers/fromfediapi_test.go} (93%) create mode 100644 internal/processing/workers/surface.go create mode 100644 internal/processing/workers/surfaceemail.go create mode 100644 internal/processing/workers/surfacenotify.go create mode 100644 internal/processing/workers/surfacetimeline.go create mode 100644 internal/processing/workers/wipestatus.go create mode 100644 internal/processing/workers/workers.go create mode 100644 internal/processing/workers/workers_test.go diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go index 8dd6a026d..eb76b8f43 100644 --- a/cmd/gotosocial/action/server/server.go +++ b/cmd/gotosocial/action/server/server.go @@ -177,8 +177,8 @@ processor := processing.NewProcessor(typeConverter, federator, oauthServer, mediaManager, &state, emailSender) // Set state client / federator worker enqueue functions - state.Workers.EnqueueClientAPI = processor.EnqueueClientAPI - state.Workers.EnqueueFederator = processor.EnqueueFederator + state.Workers.EnqueueClientAPI = processor.Workers().EnqueueClientAPI + state.Workers.EnqueueFediAPI = processor.Workers().EnqueueFediAPI /* HTTP router initialization diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 83b3c13f5..2d9a64454 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -290,11 +290,7 @@ func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Accou } } - if err := errs.Combine(); err != nil { - return gtserror.Newf("%w", err) - } - - return nil + return errs.Combine() } func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) error { diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go index 6657072fd..09084642f 100644 --- a/internal/db/bundb/instance.go +++ b/internal/db/bundb/instance.go @@ -198,11 +198,7 @@ func (i *instanceDB) populateInstance(ctx context.Context, instance *gtsmodel.In } } - if err := errs.Combine(); err != nil { - return gtserror.Newf("%w", err) - } - - return nil + return errs.Combine() } func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error { diff --git a/internal/db/bundb/list.go b/internal/db/bundb/list.go index 5cf10ce3c..ec96f1dfc 100644 --- a/internal/db/bundb/list.go +++ b/internal/db/bundb/list.go @@ -143,11 +143,7 @@ func (l *listDB) PopulateList(ctx context.Context, list *gtsmodel.List) error { } } - if err := errs.Combine(); err != nil { - return gtserror.Newf("%w", err) - } - - return nil + return errs.Combine() } func (l *listDB) PutList(ctx context.Context, list *gtsmodel.List) error { @@ -503,6 +499,22 @@ func (l *listDB) DeleteListEntriesForFollowID(ctx context.Context, followID stri return nil } +func (l *listDB) ListIncludesAccount(ctx context.Context, listID string, accountID string) (bool, error) { + exists, err := l.db. + NewSelect(). + TableExpr("? AS ?", bun.Ident("list_entries"), bun.Ident("list_entry")). + Join( + "JOIN ? AS ? ON ? = ?", + bun.Ident("follows"), bun.Ident("follow"), + bun.Ident("list_entry.follow_id"), bun.Ident("follow.id"), + ). + Where("? = ?", bun.Ident("list_entry.list_id"), listID). + Where("? = ?", bun.Ident("follow.target_account_id"), accountID). + Exists(ctx) + + return exists, l.db.ProcessError(err) +} + // collate will collect the values of type T from an expected slice of length 'len', // passing the expected index to each call of 'get' and deduplicating the end result. func collate[T comparable](get func(int) T, len int) []T { diff --git a/internal/db/bundb/list_test.go b/internal/db/bundb/list_test.go index 296ab7c1a..ca078d086 100644 --- a/internal/db/bundb/list_test.go +++ b/internal/db/bundb/list_test.go @@ -310,6 +310,27 @@ func (suite *ListTestSuite) TestDeleteListEntriesForFollowID() { suite.checkList(testList, dbList) } +func (suite *ListTestSuite) TestListIncludesAccount() { + ctx := context.Background() + testList, _ := suite.testStructs() + + for accountID, expected := range map[string]bool{ + suite.testAccounts["admin_account"].ID: true, + suite.testAccounts["local_account_1"].ID: false, + suite.testAccounts["local_account_2"].ID: true, + "01H7074GEZJ56J5C86PFB0V2CT": false, + } { + includes, err := suite.db.ListIncludesAccount(ctx, testList.ID, accountID) + if err != nil { + suite.FailNow(err.Error()) + } + + if includes != expected { + suite.FailNow("", "expected %t for accountID %s got %t", expected, accountID, includes) + } + } +} + func TestListTestSuite(t *testing.T) { suite.Run(t, new(ListTestSuite)) } diff --git a/internal/db/bundb/relationship_block.go b/internal/db/bundb/relationship_block.go index 2a042bed4..33a3b85fa 100644 --- a/internal/db/bundb/relationship_block.go +++ b/internal/db/bundb/relationship_block.go @@ -20,10 +20,10 @@ import ( "context" "errors" - "fmt" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/uptrace/bun" @@ -139,27 +139,44 @@ func (r *relationshipDB) getBlock(ctx context.Context, lookup string, dbQuery fu return block, nil } - // Set the block source account - block.Account, err = r.state.DB.GetAccountByID( - gtscontext.SetBarebones(ctx), - block.AccountID, - ) - if err != nil { - return nil, fmt.Errorf("error getting block source account: %w", err) - } - - // Set the block target account - block.TargetAccount, err = r.state.DB.GetAccountByID( - gtscontext.SetBarebones(ctx), - block.TargetAccountID, - ) - if err != nil { - return nil, fmt.Errorf("error getting block target account: %w", err) + if err := r.state.DB.PopulateBlock(ctx, block); err != nil { + return nil, err } return block, nil } +func (r *relationshipDB) PopulateBlock(ctx context.Context, block *gtsmodel.Block) error { + var ( + err error + errs = gtserror.NewMultiError(2) + ) + + if block.Account == nil { + // Block origin account is not set, fetch from database. + block.Account, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + block.AccountID, + ) + if err != nil { + errs.Appendf("error populating block account: %w", err) + } + } + + if block.TargetAccount == nil { + // Block target account is not set, fetch from database. + block.TargetAccount, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + block.TargetAccountID, + ) + if err != nil { + errs.Appendf("error populating block target account: %w", err) + } + } + + return errs.Combine() +} + func (r *relationshipDB) PutBlock(ctx context.Context, block *gtsmodel.Block) error { return r.state.Caches.GTS.Block().Store(block, func() error { _, err := r.db.NewInsert().Model(block).Exec(ctx) diff --git a/internal/db/bundb/relationship_follow.go b/internal/db/bundb/relationship_follow.go index e22ed30de..b693269df 100644 --- a/internal/db/bundb/relationship_follow.go +++ b/internal/db/bundb/relationship_follow.go @@ -185,11 +185,7 @@ func (r *relationshipDB) PopulateFollow(ctx context.Context, follow *gtsmodel.Fo } } - if err := errs.Combine(); err != nil { - return gtserror.Newf("%w", err) - } - - return nil + return errs.Combine() } func (r *relationshipDB) PutFollow(ctx context.Context, follow *gtsmodel.Follow) error { diff --git a/internal/db/bundb/relationship_follow_req.go b/internal/db/bundb/relationship_follow_req.go index dc5e760e6..cde9dc187 100644 --- a/internal/db/bundb/relationship_follow_req.go +++ b/internal/db/bundb/relationship_follow_req.go @@ -20,11 +20,11 @@ import ( "context" "errors" - "fmt" "time" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/uptrace/bun" @@ -127,27 +127,44 @@ func (r *relationshipDB) getFollowRequest(ctx context.Context, lookup string, db return followReq, nil } - // Set the follow request source account - followReq.Account, err = r.state.DB.GetAccountByID( - gtscontext.SetBarebones(ctx), - followReq.AccountID, - ) - if err != nil { - return nil, fmt.Errorf("error getting follow request source account: %w", err) - } - - // Set the follow request target account - followReq.TargetAccount, err = r.state.DB.GetAccountByID( - gtscontext.SetBarebones(ctx), - followReq.TargetAccountID, - ) - if err != nil { - return nil, fmt.Errorf("error getting follow request target account: %w", err) + if err := r.state.DB.PopulateFollowRequest(ctx, followReq); err != nil { + return nil, err } return followReq, nil } +func (r *relationshipDB) PopulateFollowRequest(ctx context.Context, follow *gtsmodel.FollowRequest) error { + var ( + err error + errs = gtserror.NewMultiError(2) + ) + + if follow.Account == nil { + // Follow account is not set, fetch from the database. + follow.Account, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + follow.AccountID, + ) + if err != nil { + errs.Appendf("error populating follow request account: %w", err) + } + } + + if follow.TargetAccount == nil { + // Follow target account is not set, fetch from the database. + follow.TargetAccount, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + follow.TargetAccountID, + ) + if err != nil { + errs.Appendf("error populating follow target request account: %w", err) + } + } + + return errs.Combine() +} + func (r *relationshipDB) PutFollowRequest(ctx context.Context, follow *gtsmodel.FollowRequest) error { return r.state.Caches.GTS.FollowRequest().Store(follow, func() error { _, err := r.db.NewInsert().Model(follow).Exec(ctx) diff --git a/internal/db/bundb/report.go b/internal/db/bundb/report.go index 3a1e18789..eaeac4860 100644 --- a/internal/db/bundb/report.go +++ b/internal/db/bundb/report.go @@ -20,11 +20,11 @@ import ( "context" "errors" - "fmt" "time" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/state" @@ -135,37 +135,72 @@ func (r *reportDB) getReport(ctx context.Context, lookup string, dbQuery func(*g return nil, err } - // Set the report author account - report.Account, err = r.state.DB.GetAccountByID(ctx, report.AccountID) - if err != nil { - return nil, fmt.Errorf("error getting report account: %w", err) + if gtscontext.Barebones(ctx) { + // Only a barebones model was requested. + return report, nil } - // Set the report target account - report.TargetAccount, err = r.state.DB.GetAccountByID(ctx, report.TargetAccountID) - if err != nil { - return nil, fmt.Errorf("error getting report target account: %w", err) - } - - if len(report.StatusIDs) > 0 { - // Fetch reported statuses - report.Statuses, err = r.state.DB.GetStatusesByIDs(ctx, report.StatusIDs) - if err != nil { - return nil, fmt.Errorf("error getting status mentions: %w", err) - } - } - - if report.ActionTakenByAccountID != "" { - // Set the report action taken by account - report.ActionTakenByAccount, err = r.state.DB.GetAccountByID(ctx, report.ActionTakenByAccountID) - if err != nil { - return nil, fmt.Errorf("error getting report action taken by account: %w", err) - } + if err := r.state.DB.PopulateReport(ctx, report); err != nil { + return nil, err } return report, nil } +func (r *reportDB) PopulateReport(ctx context.Context, report *gtsmodel.Report) error { + var ( + err error + errs = gtserror.NewMultiError(4) + ) + + if report.Account == nil { + // Report account is not set, fetch from the database. + report.Account, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + report.AccountID, + ) + if err != nil { + errs.Appendf("error populating report account: %w", err) + } + } + + if report.TargetAccount == nil { + // Report target account is not set, fetch from the database. + report.TargetAccount, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + report.TargetAccountID, + ) + if err != nil { + errs.Appendf("error populating report target account: %w", err) + } + } + + if l := len(report.StatusIDs); l > 0 && l != len(report.Statuses) { + // Report target statuses not set, fetch from the database. + report.Statuses, err = r.state.DB.GetStatusesByIDs( + gtscontext.SetBarebones(ctx), + report.StatusIDs, + ) + if err != nil { + errs.Appendf("error populating report statuses: %w", err) + } + } + + if report.ActionTakenByAccountID != "" && + report.ActionTakenByAccount == nil { + // Report action account is not set, fetch from the database. + report.ActionTakenByAccount, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + report.ActionTakenByAccountID, + ) + if err != nil { + errs.Appendf("error populating report action taken by account: %w", err) + } + } + + return errs.Combine() +} + func (r *reportDB) PutReport(ctx context.Context, report *gtsmodel.Report) error { return r.state.Caches.GTS.Report().Store(report, func() error { _, err := r.db.NewInsert().Model(report).Exec(ctx) diff --git a/internal/db/bundb/statusfave.go b/internal/db/bundb/statusfave.go index ab09fb1ba..37b88326b 100644 --- a/internal/db/bundb/statusfave.go +++ b/internal/db/bundb/statusfave.go @@ -197,11 +197,7 @@ func (s *statusFaveDB) PopulateStatusFave(ctx context.Context, statusFave *gtsmo } } - if err := errs.Combine(); err != nil { - return gtserror.Newf("%w", err) - } - - return nil + return errs.Combine() } func (s *statusFaveDB) PutStatusFave(ctx context.Context, fave *gtsmodel.StatusFave) error { diff --git a/internal/db/list.go b/internal/db/list.go index 4472589dc..91a540486 100644 --- a/internal/db/list.go +++ b/internal/db/list.go @@ -64,4 +64,7 @@ type List interface { // DeleteListEntryForFollowID deletes all list entries with the given followID. DeleteListEntriesForFollowID(ctx context.Context, followID string) error + + // ListIncludesAccount returns true if the given listID includes the given accountID. + ListIncludesAccount(ctx context.Context, listID string, accountID string) (bool, error) } diff --git a/internal/db/relationship.go b/internal/db/relationship.go index 6ba9fdf8c..50f615ef3 100644 --- a/internal/db/relationship.go +++ b/internal/db/relationship.go @@ -41,6 +41,9 @@ type Relationship interface { // GetBlock returns the block from account1 targeting account2, if it exists, or an error if it doesn't. GetBlock(ctx context.Context, account1 string, account2 string) (*gtsmodel.Block, error) + // PopulateBlock populates the struct pointers on the given block. + PopulateBlock(ctx context.Context, block *gtsmodel.Block) error + // PutBlock attempts to place the given account block in the database. PutBlock(ctx context.Context, block *gtsmodel.Block) error @@ -77,6 +80,9 @@ type Relationship interface { // GetFollowRequest retrieves a follow request if it exists between source and target accounts. GetFollowRequest(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.FollowRequest, error) + // PopulateFollowRequest populates the struct pointers on the given follow request. + PopulateFollowRequest(ctx context.Context, follow *gtsmodel.FollowRequest) error + // IsFollowing returns true if sourceAccount follows target account, or an error if something goes wrong while finding out. IsFollowing(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error) diff --git a/internal/db/report.go b/internal/db/report.go index f39e53140..a04b4d3fa 100644 --- a/internal/db/report.go +++ b/internal/db/report.go @@ -27,17 +27,24 @@ type Report interface { // GetReportByID gets one report by its db id GetReportByID(ctx context.Context, id string) (*gtsmodel.Report, error) + // GetReports gets limit n reports using the given parameters. // Parameters that are empty / zero are ignored. GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, error) + + // PopulateReport populates the struct pointers on the given report. + PopulateReport(ctx context.Context, report *gtsmodel.Report) error + // PutReport puts the given report in the database. PutReport(ctx context.Context, report *gtsmodel.Report) error + // UpdateReport updates one report by its db id. // The given columns will be updated; if no columns are // provided, then all columns will be updated. // updated_at will also be updated, no need to pass this // as a specific column. UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, error) + // DeleteReportByID deletes report with the given id. DeleteReportByID(ctx context.Context, id string) error } diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go index 7d3e16d4e..27dcec612 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -72,7 +72,7 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return err } - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityAccept, GTSModel: follow, @@ -107,7 +107,7 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return err } - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityAccept, GTSModel: follow, diff --git a/internal/federation/federatingdb/announce.go b/internal/federation/federatingdb/announce.go index 0246cff7c..334c46ba5 100644 --- a/internal/federation/federatingdb/announce.go +++ b/internal/federation/federatingdb/announce.go @@ -56,7 +56,7 @@ func (f *federatingDB) Announce(ctx context.Context, announce vocab.ActivityStre } // This is a new boost. Process side effects asynchronously. - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityAnnounce, APActivityType: ap.ActivityCreate, GTSModel: boost, diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go index 0075aa97a..3c9eaf9a5 100644 --- a/internal/federation/federatingdb/create.go +++ b/internal/federation/federatingdb/create.go @@ -105,7 +105,7 @@ func (f *federatingDB) activityBlock(ctx context.Context, asType vocab.Type, rec return fmt.Errorf("activityBlock: database error inserting block: %s", err) } - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityBlock, APActivityType: ap.ActivityCreate, GTSModel: block, @@ -233,7 +233,7 @@ func (f *federatingDB) createStatusable( if forward { // Pass the statusable URI (APIri) into the processor worker // and do the rest of the processing asynchronously. - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityCreate, APIri: statusableURI, @@ -291,7 +291,7 @@ func (f *federatingDB) createStatusable( // Do the rest of the processing asynchronously. The processor // will handle inserting/updating + further dereferencing the status. - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityCreate, APIri: nil, @@ -344,7 +344,7 @@ func (f *federatingDB) activityFollow(ctx context.Context, asType vocab.Type, re return fmt.Errorf("activityFollow: database error inserting follow request: %s", err) } - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityCreate, GTSModel: followRequest, @@ -381,7 +381,7 @@ func (f *federatingDB) activityLike(ctx context.Context, asType vocab.Type, rece return fmt.Errorf("activityLike: database error inserting fave: %w", err) } - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityLike, APActivityType: ap.ActivityCreate, GTSModel: fave, @@ -412,7 +412,7 @@ func (f *federatingDB) activityFlag(ctx context.Context, asType vocab.Type, rece return fmt.Errorf("activityFlag: database error inserting report: %w", err) } - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityFlag, APActivityType: ap.ActivityCreate, GTSModel: report, diff --git a/internal/federation/federatingdb/delete.go b/internal/federation/federatingdb/delete.go index 95f9be354..cca5fdcad 100644 --- a/internal/federation/federatingdb/delete.go +++ b/internal/federation/federatingdb/delete.go @@ -49,7 +49,7 @@ func (f *federatingDB) Delete(ctx context.Context, id *url.URL) error { // so we have to try a few different things... if s, err := f.state.DB.GetStatusByURI(ctx, id.String()); err == nil && requestingAccount.ID == s.AccountID { l.Debugf("uri is for STATUS with id: %s", s.ID) - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityDelete, GTSModel: s, @@ -59,7 +59,7 @@ func (f *federatingDB) Delete(ctx context.Context, id *url.URL) error { if a, err := f.state.DB.GetAccountByURI(ctx, id.String()); err == nil && requestingAccount.ID == a.ID { l.Debugf("uri is for ACCOUNT with id %s", a.ID) - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ObjectProfile, APActivityType: ap.ActivityDelete, GTSModel: a, diff --git a/internal/federation/federatingdb/federatingdb_test.go b/internal/federation/federatingdb/federatingdb_test.go index 6a8754519..ea5ebf0c3 100644 --- a/internal/federation/federatingdb/federatingdb_test.go +++ b/internal/federation/federatingdb/federatingdb_test.go @@ -36,7 +36,7 @@ type FederatingDBTestSuite struct { suite.Suite db db.DB tc typeutils.TypeConverter - fromFederator chan messages.FromFederator + fromFederator chan messages.FromFediAPI federatingDB federatingdb.DB state state.State @@ -69,8 +69,8 @@ func (suite *FederatingDBTestSuite) SetupTest() { suite.state.Caches.Init() testrig.StartWorkers(&suite.state) - suite.fromFederator = make(chan messages.FromFederator, 10) - suite.state.Workers.EnqueueFederator = func(ctx context.Context, msgs ...messages.FromFederator) { + suite.fromFederator = make(chan messages.FromFediAPI, 10) + suite.state.Workers.EnqueueFediAPI = func(ctx context.Context, msgs ...messages.FromFediAPI) { for _, msg := range msgs { suite.fromFederator <- msg } diff --git a/internal/federation/federatingdb/reject_test.go b/internal/federation/federatingdb/reject_test.go index f7d30b228..d4c537a92 100644 --- a/internal/federation/federatingdb/reject_test.go +++ b/internal/federation/federatingdb/reject_test.go @@ -52,7 +52,7 @@ func (suite *RejectTestSuite) TestRejectFollowRequest() { err := suite.db.Put(ctx, fr) suite.NoError(err) - asFollow, err := suite.tc.FollowToAS(ctx, suite.tc.FollowRequestToFollow(ctx, fr), followingAccount, followedAccount) + asFollow, err := suite.tc.FollowToAS(ctx, suite.tc.FollowRequestToFollow(ctx, fr)) suite.NoError(err) rejectingAccountURI := testrig.URLMustParse(followedAccount.URI) diff --git a/internal/federation/federatingdb/update.go b/internal/federation/federatingdb/update.go index 5ac4cc289..8e452eb3c 100644 --- a/internal/federation/federatingdb/update.go +++ b/internal/federation/federatingdb/update.go @@ -93,7 +93,7 @@ func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gts // was delivered along with the Update, for further asynchronous // updating of eg., avatar/header, emojis, etc. The actual db // inserts/updates will take place there. - f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ObjectProfile, APActivityType: ap.ActivityUpdate, GTSModel: requestingAcct, diff --git a/internal/gtserror/new.go b/internal/gtserror/new.go index bb88d5f6a..c360d3345 100644 --- a/internal/gtserror/new.go +++ b/internal/gtserror/new.go @@ -21,16 +21,34 @@ "net/http" ) -// New returns a new error, prepended with caller function name if gtserror.Caller is enabled. +// New returns a new error, prepended with caller +// function name if gtserror.Caller is enabled. func New(msg string) error { return newAt(3, msg) } -// Newf returns a new formatted error, prepended with caller function name if gtserror.Caller is enabled. +// Newf returns a new formatted error, prepended with +// caller function name if gtserror.Caller is enabled. func Newf(msgf string, args ...any) error { return newfAt(3, msgf, args...) } +// NewfAt returns a new formatted error with the given +// calldepth+1, useful when you want to wrap an error +// from within an anonymous function or utility function, +// but preserve the name in the error of the wrapping +// function that did the calling. +// +// Provide calldepth 2 to prepend only the name of the +// current containing function, 3 to prepend the name +// of the function containing *that* function, and so on. +// +// This function is just exposed for dry-dick optimization +// purposes. Most callers should just call Newf instead. +func NewfAt(calldepth int, msgf string, args ...any) error { + return newfAt(calldepth+1, msgf, args...) +} + // NewResponseError crafts an error from provided HTTP response // including the method, status and body (if any provided). This // will also wrap the returned error using WithStatusCode() and diff --git a/internal/messages/messages.go b/internal/messages/messages.go index 7f9b3f37c..236aea722 100644 --- a/internal/messages/messages.go +++ b/internal/messages/messages.go @@ -32,8 +32,8 @@ type FromClientAPI struct { TargetAccount *gtsmodel.Account } -// FromFederator wraps a message that travels from the federator into the processor. -type FromFederator struct { +// FromFediAPI wraps a message that travels from the federating API into the processor. +type FromFediAPI struct { APObjectType string APActivityType string APIri *url.URL diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go deleted file mode 100644 index 412403c44..000000000 --- a/internal/processing/fromclientapi.go +++ /dev/null @@ -1,1021 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package processing - -import ( - "context" - "errors" - "fmt" - "net/url" - - "codeberg.org/gruf/go-kv" - "codeberg.org/gruf/go-logger/v2/level" - "github.com/superseriousbusiness/activity/pub" - "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/messages" -) - -func (p *Processor) ProcessFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - // Allocate new log fields slice - fields := make([]kv.Field, 3, 4) - fields[0] = kv.Field{"activityType", clientMsg.APActivityType} - fields[1] = kv.Field{"objectType", clientMsg.APObjectType} - fields[2] = kv.Field{"fromAccount", clientMsg.OriginAccount.Username} - - if clientMsg.GTSModel != nil && - log.Level() >= level.DEBUG { - // Append converted model to log - fields = append(fields, kv.Field{ - "model", clientMsg.GTSModel, - }) - } - - // Log this federated message - l := log.WithContext(ctx).WithFields(fields...) - l.Info("processing from client") - - switch clientMsg.APActivityType { - case ap.ActivityCreate: - // CREATE - switch clientMsg.APObjectType { - case ap.ObjectProfile, ap.ActorPerson: - // CREATE ACCOUNT/PROFILE - return p.processCreateAccountFromClientAPI(ctx, clientMsg) - case ap.ObjectNote: - // CREATE NOTE - return p.processCreateStatusFromClientAPI(ctx, clientMsg) - case ap.ActivityFollow: - // CREATE FOLLOW REQUEST - return p.processCreateFollowRequestFromClientAPI(ctx, clientMsg) - case ap.ActivityLike: - // CREATE LIKE/FAVE - return p.processCreateFaveFromClientAPI(ctx, clientMsg) - case ap.ActivityAnnounce: - // CREATE BOOST/ANNOUNCE - return p.processCreateAnnounceFromClientAPI(ctx, clientMsg) - case ap.ActivityBlock: - // CREATE BLOCK - return p.processCreateBlockFromClientAPI(ctx, clientMsg) - } - case ap.ActivityUpdate: - // UPDATE - switch clientMsg.APObjectType { - case ap.ObjectProfile, ap.ActorPerson: - // UPDATE ACCOUNT/PROFILE - return p.processUpdateAccountFromClientAPI(ctx, clientMsg) - case ap.ActivityFlag: - // UPDATE A FLAG/REPORT (mark as resolved/closed) - return p.processUpdateReportFromClientAPI(ctx, clientMsg) - } - case ap.ActivityAccept: - // ACCEPT - if clientMsg.APObjectType == ap.ActivityFollow { - // ACCEPT FOLLOW - return p.processAcceptFollowFromClientAPI(ctx, clientMsg) - } - case ap.ActivityReject: - // REJECT - if clientMsg.APObjectType == ap.ActivityFollow { - // REJECT FOLLOW (request) - return p.processRejectFollowFromClientAPI(ctx, clientMsg) - } - case ap.ActivityUndo: - // UNDO - switch clientMsg.APObjectType { - case ap.ActivityFollow: - // UNDO FOLLOW - return p.processUndoFollowFromClientAPI(ctx, clientMsg) - case ap.ActivityBlock: - // UNDO BLOCK - return p.processUndoBlockFromClientAPI(ctx, clientMsg) - case ap.ActivityLike: - // UNDO LIKE/FAVE - return p.processUndoFaveFromClientAPI(ctx, clientMsg) - case ap.ActivityAnnounce: - // UNDO ANNOUNCE/BOOST - return p.processUndoAnnounceFromClientAPI(ctx, clientMsg) - } - case ap.ActivityDelete: - // DELETE - switch clientMsg.APObjectType { - case ap.ObjectNote: - // DELETE STATUS/NOTE - return p.processDeleteStatusFromClientAPI(ctx, clientMsg) - case ap.ObjectProfile, ap.ActorPerson: - // DELETE ACCOUNT/PROFILE - return p.processDeleteAccountFromClientAPI(ctx, clientMsg) - } - case ap.ActivityFlag: - // FLAG - if clientMsg.APObjectType == ap.ObjectProfile { - // FLAG/REPORT A PROFILE - return p.processReportAccountFromClientAPI(ctx, clientMsg) - } - } - return nil -} - -func (p *Processor) processCreateAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - account, ok := clientMsg.GTSModel.(*gtsmodel.Account) - if !ok { - return errors.New("account was not parseable as *gtsmodel.Account") - } - - // Do nothing if this isn't our activity. - if !account.IsLocal() { - return nil - } - - // get the user this account belongs to - user, err := p.state.DB.GetUserByAccountID(ctx, account.ID) - if err != nil { - return err - } - - // email a confirmation to this user - return p.User().EmailSendConfirmation(ctx, user, account.Username) -} - -func (p *Processor) processCreateStatusFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - status, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return gtserror.New("status was not parseable as *gtsmodel.Status") - } - - if err := p.timelineAndNotifyStatus(ctx, status); err != nil { - return gtserror.Newf("error timelining status: %w", err) - } - - if status.InReplyToID != "" { - // Interaction counts changed on the replied status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, status.InReplyToID) - } - - if err := p.federateStatus(ctx, status); err != nil { - return gtserror.Newf("error federating status: %w", err) - } - - return nil -} - -func (p *Processor) processCreateFollowRequestFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) - if !ok { - return errors.New("followrequest was not parseable as *gtsmodel.FollowRequest") - } - - if err := p.notifyFollowRequest(ctx, followRequest); err != nil { - return err - } - - return p.federateFollow(ctx, followRequest, clientMsg.OriginAccount, clientMsg.TargetAccount) -} - -func (p *Processor) processCreateFaveFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - statusFave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) - if !ok { - return gtserror.New("statusFave was not parseable as *gtsmodel.StatusFave") - } - - if err := p.notifyFave(ctx, statusFave); err != nil { - return gtserror.Newf("error notifying status fave: %w", err) - } - - // Interaction counts changed on the faved status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, statusFave.StatusID) - - if err := p.federateFave(ctx, statusFave, clientMsg.OriginAccount, clientMsg.TargetAccount); err != nil { - return gtserror.Newf("error federating status fave: %w", err) - } - - return nil -} - -func (p *Processor) processCreateAnnounceFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - status, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("boost was not parseable as *gtsmodel.Status") - } - - // Timeline and notify. - if err := p.timelineAndNotifyStatus(ctx, status); err != nil { - return gtserror.Newf("error timelining boost: %w", err) - } - - if err := p.notifyAnnounce(ctx, status); err != nil { - return gtserror.Newf("error notifying boost: %w", err) - } - - // Interaction counts changed on the boosted status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, status.BoostOfID) - - if err := p.federateAnnounce(ctx, status, clientMsg.OriginAccount, clientMsg.TargetAccount); err != nil { - return gtserror.Newf("error federating boost: %w", err) - } - - return nil -} - -func (p *Processor) processCreateBlockFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - block, ok := clientMsg.GTSModel.(*gtsmodel.Block) - if !ok { - return errors.New("block was not parseable as *gtsmodel.Block") - } - - // remove any of the blocking account's statuses from the blocked account's timeline, and vice versa - if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil { - return err - } - if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil { - return err - } - - // TODO: same with notifications - // TODO: same with bookmarks - - return p.federateBlock(ctx, block) -} - -func (p *Processor) processUpdateAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - account, ok := clientMsg.GTSModel.(*gtsmodel.Account) - if !ok { - return errors.New("account was not parseable as *gtsmodel.Account") - } - - return p.federateAccountUpdate(ctx, account, clientMsg.OriginAccount) -} - -func (p *Processor) processUpdateReportFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - report, ok := clientMsg.GTSModel.(*gtsmodel.Report) - if !ok { - return errors.New("report was not parseable as *gtsmodel.Report") - } - - if report.Account.IsRemote() { - // Report creator is a remote account, - // we shouldn't email or notify them. - return nil - } - - return p.emailReportClosed(ctx, report) -} - -func (p *Processor) processAcceptFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) - if !ok { - return errors.New("accept was not parseable as *gtsmodel.Follow") - } - - if err := p.notifyFollow(ctx, follow, clientMsg.TargetAccount); err != nil { - return err - } - - return p.federateAcceptFollowRequest(ctx, follow) -} - -func (p *Processor) processRejectFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) - if !ok { - return errors.New("reject was not parseable as *gtsmodel.FollowRequest") - } - - return p.federateRejectFollowRequest(ctx, followRequest) -} - -func (p *Processor) processUndoFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) - if !ok { - return errors.New("undo was not parseable as *gtsmodel.Follow") - } - return p.federateUnfollow(ctx, follow, clientMsg.OriginAccount, clientMsg.TargetAccount) -} - -func (p *Processor) processUndoBlockFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - block, ok := clientMsg.GTSModel.(*gtsmodel.Block) - if !ok { - return errors.New("undo was not parseable as *gtsmodel.Block") - } - return p.federateUnblock(ctx, block) -} - -func (p *Processor) processUndoFaveFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - statusFave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) - if !ok { - return gtserror.New("statusFave was not parseable as *gtsmodel.StatusFave") - } - - // Interaction counts changed on the faved status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, statusFave.StatusID) - - if err := p.federateUnfave(ctx, statusFave, clientMsg.OriginAccount, clientMsg.TargetAccount); err != nil { - return gtserror.Newf("error federating status unfave: %w", err) - } - - return nil -} - -func (p *Processor) processUndoAnnounceFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - status, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("boost was not parseable as *gtsmodel.Status") - } - - if err := p.state.DB.DeleteStatusByID(ctx, status.ID); err != nil { - return gtserror.Newf("db error deleting boost: %w", err) - } - - if err := p.deleteStatusFromTimelines(ctx, status.ID); err != nil { - return gtserror.Newf("error removing boost from timelines: %w", err) - } - - // Interaction counts changed on the boosted status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, status.BoostOfID) - - if err := p.federateUnannounce(ctx, status, clientMsg.OriginAccount, clientMsg.TargetAccount); err != nil { - return gtserror.Newf("error federating status unboost: %w", err) - } - - return nil -} - -func (p *Processor) processDeleteStatusFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - status, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return gtserror.New("status was not parseable as *gtsmodel.Status") - } - - if err := p.state.DB.PopulateStatus(ctx, status); err != nil { - return gtserror.Newf("db error populating status: %w", err) - } - - // Don't delete attachments, just unattach them: this - // request comes from the client API and the poster - // may want to use attachments again in a new post. - deleteAttachments := false - if err := p.wipeStatus(ctx, status, deleteAttachments); err != nil { - return gtserror.Newf("error wiping status: %w", err) - } - - if status.InReplyToID != "" { - // Interaction counts changed on the replied status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, status.InReplyToID) - } - - if err := p.federateStatusDelete(ctx, status); err != nil { - return gtserror.Newf("error federating status delete: %w", err) - } - - return nil -} - -func (p *Processor) processDeleteAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - // the origin of the delete could be either a domain block, or an action by another (or this) account - var origin string - if domainBlock, ok := clientMsg.GTSModel.(*gtsmodel.DomainBlock); ok { - // origin is a domain block - origin = domainBlock.ID - } else { - // origin is whichever account caused this message - origin = clientMsg.OriginAccount.ID - } - - if err := p.federateAccountDelete(ctx, clientMsg.TargetAccount); err != nil { - return err - } - - return p.account.Delete(ctx, clientMsg.TargetAccount, origin) -} - -func (p *Processor) processReportAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { - report, ok := clientMsg.GTSModel.(*gtsmodel.Report) - if !ok { - return errors.New("report was not parseable as *gtsmodel.Report") - } - - if *report.Forwarded { - if err := p.federateReport(ctx, report); err != nil { - return fmt.Errorf("processReportAccountFromClientAPI: error federating report: %w", err) - } - } - - if err := p.emailReport(ctx, report); err != nil { - return fmt.Errorf("processReportAccountFromClientAPI: error notifying report: %w", err) - } - - return nil -} - -// 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 activity. - if !account.IsLocal() { - return nil - } - - outboxIRI, err := url.Parse(account.OutboxURI) - if err != nil { - return fmt.Errorf("federateAccountDelete: error parsing outboxURI %s: %s", account.OutboxURI, err) - } - - actorIRI, err := url.Parse(account.URI) - if err != nil { - return fmt.Errorf("federateAccountDelete: error parsing actorIRI %s: %s", account.URI, err) - } - - followersIRI, err := url.Parse(account.FollowersURI) - if err != nil { - return fmt.Errorf("federateAccountDelete: error parsing followersIRI %s: %s", account.FollowersURI, err) - } - - publicIRI, err := url.Parse(pub.PublicActivityPubIRI) - if err != nil { - return fmt.Errorf("federateAccountDelete: error parsing url %s: %s", pub.PublicActivityPubIRI, err) - } - - // create a delete and set the appropriate actor on it - delete := streams.NewActivityStreamsDelete() - - // set the actor for the delete; no matter who deleted it we should use the account owner for this - deleteActor := streams.NewActivityStreamsActorProperty() - deleteActor.AppendIRI(actorIRI) - delete.SetActivityStreamsActor(deleteActor) - - // Set the account IRI as the 'object' property. - deleteObject := streams.NewActivityStreamsObjectProperty() - deleteObject.AppendIRI(actorIRI) - delete.SetActivityStreamsObject(deleteObject) - - // send to followers... - deleteTo := streams.NewActivityStreamsToProperty() - deleteTo.AppendIRI(followersIRI) - delete.SetActivityStreamsTo(deleteTo) - - // ... and CC to public - deleteCC := streams.NewActivityStreamsCcProperty() - deleteCC.AppendIRI(publicIRI) - delete.SetActivityStreamsCc(deleteCC) - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, delete) - return err -} - -func (p *Processor) federateStatus(ctx context.Context, status *gtsmodel.Status) error { - // do nothing if the status shouldn't be federated - if !*status.Federated { - return nil - } - - if status.Account == nil { - statusAccount, err := p.state.DB.GetAccountByID(ctx, status.AccountID) - if err != nil { - return fmt.Errorf("federateStatus: error fetching status author account: %s", err) - } - status.Account = statusAccount - } - - // Do nothing if this isn't our activity. - if !status.Account.IsLocal() { - return nil - } - - asStatus, err := p.tc.StatusToAS(ctx, status) - if err != nil { - return fmt.Errorf("federateStatus: error converting status to as format: %s", err) - } - - create, err := p.tc.WrapNoteInCreate(asStatus, false) - if err != nil { - return fmt.Errorf("federateStatus: error wrapping status in create: %s", err) - } - - outboxIRI, err := url.Parse(status.Account.OutboxURI) - if err != nil { - return fmt.Errorf("federateStatus: error parsing outboxURI %s: %s", status.Account.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, create) - return err -} - -func (p *Processor) federateStatusDelete(ctx context.Context, status *gtsmodel.Status) error { - if status.Account == nil { - statusAccount, err := p.state.DB.GetAccountByID(ctx, status.AccountID) - if err != nil { - return fmt.Errorf("federateStatusDelete: error fetching status author account: %s", err) - } - status.Account = statusAccount - } - - // Do nothing if this isn't our activity. - if !status.Account.IsLocal() { - return nil - } - - delete, err := p.tc.StatusToASDelete(ctx, status) - if err != nil { - return fmt.Errorf("federateStatusDelete: error creating Delete: %w", err) - } - - outboxIRI, err := url.Parse(status.Account.OutboxURI) - if err != nil { - return fmt.Errorf("federateStatusDelete: error parsing outboxURI %s: %w", status.Account.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, delete) - return err -} - -func (p *Processor) federateFollow(ctx context.Context, followRequest *gtsmodel.FollowRequest, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // Do nothing if both accounts are local. - if originAccount.IsLocal() && targetAccount.IsLocal() { - return nil - } - - follow := p.tc.FollowRequestToFollow(ctx, followRequest) - - asFollow, err := p.tc.FollowToAS(ctx, follow, originAccount, targetAccount) - if err != nil { - return fmt.Errorf("federateFollow: error converting follow to as format: %s", err) - } - - outboxIRI, err := url.Parse(originAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateFollow: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, asFollow) - return err -} - -func (p *Processor) federateUnfollow(ctx context.Context, follow *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // Do nothing if both accounts are local. - if originAccount.IsLocal() && targetAccount.IsLocal() { - return nil - } - - // recreate the follow - asFollow, err := p.tc.FollowToAS(ctx, follow, originAccount, targetAccount) - if err != nil { - return fmt.Errorf("federateUnfollow: error converting follow to as format: %s", err) - } - - targetAccountURI, err := url.Parse(targetAccount.URI) - if err != nil { - return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) - } - - // create an Undo and set the appropriate actor on it - undo := streams.NewActivityStreamsUndo() - undo.SetActivityStreamsActor(asFollow.GetActivityStreamsActor()) - - // Set the recreated follow as the 'object' property. - undoObject := streams.NewActivityStreamsObjectProperty() - undoObject.AppendActivityStreamsFollow(asFollow) - undo.SetActivityStreamsObject(undoObject) - - // Set the To of the undo as the target of the recreated follow - undoTo := streams.NewActivityStreamsToProperty() - undoTo.AppendIRI(targetAccountURI) - undo.SetActivityStreamsTo(undoTo) - - outboxIRI, err := url.Parse(originAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateUnfollow: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - - // send off the Undo - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, undo) - return err -} - -func (p *Processor) federateUnfave(ctx context.Context, fave *gtsmodel.StatusFave, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // Do nothing if both accounts are local. - if originAccount.IsLocal() && targetAccount.IsLocal() { - return nil - } - - // create the AS fave - asFave, err := p.tc.FaveToAS(ctx, fave) - if err != nil { - return fmt.Errorf("federateFave: error converting fave to as format: %s", err) - } - - targetAccountURI, err := url.Parse(targetAccount.URI) - if err != nil { - return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) - } - - // create an Undo and set the appropriate actor on it - undo := streams.NewActivityStreamsUndo() - undo.SetActivityStreamsActor(asFave.GetActivityStreamsActor()) - - // Set the fave as the 'object' property. - undoObject := streams.NewActivityStreamsObjectProperty() - undoObject.AppendActivityStreamsLike(asFave) - undo.SetActivityStreamsObject(undoObject) - - // Set the To of the undo as the target of the fave - undoTo := streams.NewActivityStreamsToProperty() - undoTo.AppendIRI(targetAccountURI) - undo.SetActivityStreamsTo(undoTo) - - outboxIRI, err := url.Parse(originAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateFave: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, undo) - return err -} - -func (p *Processor) federateUnannounce(ctx context.Context, boost *gtsmodel.Status, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // Do nothing if this isn't our activity. - if !originAccount.IsLocal() { - return nil - } - - asAnnounce, err := p.tc.BoostToAS(ctx, boost, originAccount, targetAccount) - if err != nil { - return fmt.Errorf("federateUnannounce: error converting status to announce: %s", err) - } - - // create an Undo and set the appropriate actor on it - undo := streams.NewActivityStreamsUndo() - undo.SetActivityStreamsActor(asAnnounce.GetActivityStreamsActor()) - - // Set the boost as the 'object' property. - undoObject := streams.NewActivityStreamsObjectProperty() - undoObject.AppendActivityStreamsAnnounce(asAnnounce) - undo.SetActivityStreamsObject(undoObject) - - // set the to - undo.SetActivityStreamsTo(asAnnounce.GetActivityStreamsTo()) - - // set the cc - undo.SetActivityStreamsCc(asAnnounce.GetActivityStreamsCc()) - - outboxIRI, err := url.Parse(originAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateUnannounce: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, undo) - return err -} - -func (p *Processor) federateAcceptFollowRequest(ctx context.Context, follow *gtsmodel.Follow) error { - if follow.Account == nil { - a, err := p.state.DB.GetAccountByID(ctx, follow.AccountID) - if err != nil { - return err - } - follow.Account = a - } - originAccount := follow.Account - - if follow.TargetAccount == nil { - a, err := p.state.DB.GetAccountByID(ctx, follow.TargetAccountID) - if err != nil { - return err - } - follow.TargetAccount = a - } - targetAccount := follow.TargetAccount - - // Do nothing if target account *isn't* local, - // or both origin + target *are* local. - if targetAccount.IsRemote() || originAccount.IsLocal() { - return nil - } - - // recreate the AS follow - asFollow, err := p.tc.FollowToAS(ctx, follow, originAccount, targetAccount) - if err != nil { - return fmt.Errorf("federateUnfollow: error converting follow to as format: %s", err) - } - - acceptingAccountURI, err := url.Parse(targetAccount.URI) - if err != nil { - return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) - } - - requestingAccountURI, err := url.Parse(originAccount.URI) - if err != nil { - return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) - } - - // create an Accept - accept := streams.NewActivityStreamsAccept() - - // set the accepting actor on it - acceptActorProp := streams.NewActivityStreamsActorProperty() - acceptActorProp.AppendIRI(acceptingAccountURI) - accept.SetActivityStreamsActor(acceptActorProp) - - // Set the recreated follow as the 'object' property. - acceptObject := streams.NewActivityStreamsObjectProperty() - acceptObject.AppendActivityStreamsFollow(asFollow) - accept.SetActivityStreamsObject(acceptObject) - - // Set the To of the accept as the originator of the follow - acceptTo := streams.NewActivityStreamsToProperty() - acceptTo.AppendIRI(requestingAccountURI) - accept.SetActivityStreamsTo(acceptTo) - - outboxIRI, err := url.Parse(targetAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateAcceptFollowRequest: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - - // send off the accept using the accepter's outbox - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, accept) - return err -} - -func (p *Processor) federateRejectFollowRequest(ctx context.Context, followRequest *gtsmodel.FollowRequest) error { - if followRequest.Account == nil { - a, err := p.state.DB.GetAccountByID(ctx, followRequest.AccountID) - if err != nil { - return err - } - followRequest.Account = a - } - originAccount := followRequest.Account - - if followRequest.TargetAccount == nil { - a, err := p.state.DB.GetAccountByID(ctx, followRequest.TargetAccountID) - if err != nil { - return err - } - followRequest.TargetAccount = a - } - targetAccount := followRequest.TargetAccount - - // Do nothing if target account *isn't* local, - // or both origin + target *are* local. - if targetAccount.IsRemote() || originAccount.IsLocal() { - return nil - } - - // recreate the AS follow - follow := p.tc.FollowRequestToFollow(ctx, followRequest) - asFollow, err := p.tc.FollowToAS(ctx, follow, originAccount, targetAccount) - if err != nil { - return fmt.Errorf("federateUnfollow: error converting follow to as format: %s", err) - } - - rejectingAccountURI, err := url.Parse(targetAccount.URI) - if err != nil { - return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) - } - - requestingAccountURI, err := url.Parse(originAccount.URI) - if err != nil { - return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) - } - - // create a Reject - reject := streams.NewActivityStreamsReject() - - // set the rejecting actor on it - acceptActorProp := streams.NewActivityStreamsActorProperty() - acceptActorProp.AppendIRI(rejectingAccountURI) - reject.SetActivityStreamsActor(acceptActorProp) - - // Set the recreated follow as the 'object' property. - acceptObject := streams.NewActivityStreamsObjectProperty() - acceptObject.AppendActivityStreamsFollow(asFollow) - reject.SetActivityStreamsObject(acceptObject) - - // Set the To of the reject as the originator of the follow - acceptTo := streams.NewActivityStreamsToProperty() - acceptTo.AppendIRI(requestingAccountURI) - reject.SetActivityStreamsTo(acceptTo) - - outboxIRI, err := url.Parse(targetAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateRejectFollowRequest: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - - // send off the reject using the rejecting account's outbox - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, reject) - return err -} - -func (p *Processor) federateFave(ctx context.Context, fave *gtsmodel.StatusFave, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { - // Do nothing if both accounts are local. - if originAccount.IsLocal() && targetAccount.IsLocal() { - return nil - } - - // create the AS fave - asFave, err := p.tc.FaveToAS(ctx, fave) - if err != nil { - return fmt.Errorf("federateFave: error converting fave to as format: %s", err) - } - - outboxIRI, err := url.Parse(originAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateFave: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, asFave) - return err -} - -func (p *Processor) federateAnnounce(ctx context.Context, boostWrapperStatus *gtsmodel.Status, boostingAccount *gtsmodel.Account, boostedAccount *gtsmodel.Account) error { - announce, err := p.tc.BoostToAS(ctx, boostWrapperStatus, boostingAccount, boostedAccount) - if err != nil { - return fmt.Errorf("federateAnnounce: error converting status to announce: %s", err) - } - - outboxIRI, err := url.Parse(boostingAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateAnnounce: error parsing outboxURI %s: %s", boostingAccount.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, announce) - return err -} - -func (p *Processor) federateAccountUpdate(ctx context.Context, updatedAccount *gtsmodel.Account, originAccount *gtsmodel.Account) error { - person, err := p.tc.AccountToAS(ctx, updatedAccount) - if err != nil { - return fmt.Errorf("federateAccountUpdate: error converting account to person: %s", err) - } - - update, err := p.tc.WrapPersonInUpdate(person, originAccount) - if err != nil { - return fmt.Errorf("federateAccountUpdate: error wrapping person in update: %s", err) - } - - outboxIRI, err := url.Parse(originAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateAnnounce: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, update) - return err -} - -func (p *Processor) federateBlock(ctx context.Context, block *gtsmodel.Block) error { - if block.Account == nil { - blockAccount, err := p.state.DB.GetAccountByID(ctx, block.AccountID) - if err != nil { - return fmt.Errorf("federateBlock: error getting block account from database: %s", err) - } - block.Account = blockAccount - } - - if block.TargetAccount == nil { - blockTargetAccount, err := p.state.DB.GetAccountByID(ctx, block.TargetAccountID) - if err != nil { - return fmt.Errorf("federateBlock: error getting block target account from database: %s", err) - } - block.TargetAccount = blockTargetAccount - } - - // Do nothing if both accounts are local. - if block.Account.IsLocal() && block.TargetAccount.IsLocal() { - return nil - } - - asBlock, err := p.tc.BlockToAS(ctx, block) - if err != nil { - return fmt.Errorf("federateBlock: error converting block to AS format: %s", err) - } - - outboxIRI, err := url.Parse(block.Account.OutboxURI) - if err != nil { - return fmt.Errorf("federateBlock: error parsing outboxURI %s: %s", block.Account.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, asBlock) - return err -} - -func (p *Processor) federateUnblock(ctx context.Context, block *gtsmodel.Block) error { - if block.Account == nil { - blockAccount, err := p.state.DB.GetAccountByID(ctx, block.AccountID) - if err != nil { - return fmt.Errorf("federateUnblock: error getting block account from database: %s", err) - } - block.Account = blockAccount - } - - if block.TargetAccount == nil { - blockTargetAccount, err := p.state.DB.GetAccountByID(ctx, block.TargetAccountID) - if err != nil { - return fmt.Errorf("federateUnblock: error getting block target account from database: %s", err) - } - block.TargetAccount = blockTargetAccount - } - - // Do nothing if both accounts are local. - if block.Account.IsLocal() && block.TargetAccount.IsLocal() { - return nil - } - - asBlock, err := p.tc.BlockToAS(ctx, block) - if err != nil { - return fmt.Errorf("federateUnblock: error converting block to AS format: %s", err) - } - - targetAccountURI, err := url.Parse(block.TargetAccount.URI) - if err != nil { - return fmt.Errorf("federateUnblock: error parsing uri %s: %s", block.TargetAccount.URI, err) - } - - // create an Undo and set the appropriate actor on it - undo := streams.NewActivityStreamsUndo() - undo.SetActivityStreamsActor(asBlock.GetActivityStreamsActor()) - - // Set the block as the 'object' property. - undoObject := streams.NewActivityStreamsObjectProperty() - undoObject.AppendActivityStreamsBlock(asBlock) - undo.SetActivityStreamsObject(undoObject) - - // Set the To of the undo as the target of the block - undoTo := streams.NewActivityStreamsToProperty() - undoTo.AppendIRI(targetAccountURI) - undo.SetActivityStreamsTo(undoTo) - - outboxIRI, err := url.Parse(block.Account.OutboxURI) - if err != nil { - return fmt.Errorf("federateUnblock: error parsing outboxURI %s: %s", block.Account.OutboxURI, err) - } - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, undo) - return err -} - -func (p *Processor) federateReport(ctx context.Context, report *gtsmodel.Report) error { - if report.TargetAccount == nil { - reportTargetAccount, err := p.state.DB.GetAccountByID(ctx, report.TargetAccountID) - if err != nil { - return fmt.Errorf("federateReport: error getting report target account from database: %w", err) - } - report.TargetAccount = reportTargetAccount - } - - if len(report.StatusIDs) > 0 && len(report.Statuses) == 0 { - statuses, err := p.state.DB.GetStatusesByIDs(ctx, report.StatusIDs) - if err != nil { - return fmt.Errorf("federateReport: error getting report statuses from database: %w", err) - } - report.Statuses = statuses - } - - flag, err := p.tc.ReportToASFlag(ctx, report) - if err != nil { - return fmt.Errorf("federateReport: error converting report to AS flag: %w", err) - } - - // add bto so that our federating actor knows where to - // send the Flag; it'll still use a shared inbox if possible - reportTargetURI, err := url.Parse(report.TargetAccount.URI) - if err != nil { - return fmt.Errorf("federateReport: error parsing outboxURI %s: %w", report.TargetAccount.URI, err) - } - bTo := streams.NewActivityStreamsBtoProperty() - bTo.AppendIRI(reportTargetURI) - flag.SetActivityStreamsBto(bTo) - - // deliver the flag using the outbox of the - // instance account to anonymize the report - instanceAccount, err := p.state.DB.GetInstanceAccount(ctx, "") - if err != nil { - return fmt.Errorf("federateReport: error getting instance account: %w", err) - } - - outboxIRI, err := url.Parse(instanceAccount.OutboxURI) - if err != nil { - return fmt.Errorf("federateReport: error parsing outboxURI %s: %w", instanceAccount.OutboxURI, err) - } - - _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, flag) - return err -} diff --git a/internal/processing/fromclientapi_test.go b/internal/processing/fromclientapi_test.go deleted file mode 100644 index 94068e192..000000000 --- a/internal/processing/fromclientapi_test.go +++ /dev/null @@ -1,273 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package processing_test - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type FromClientAPITestSuite struct { - ProcessingStandardTestSuite -} - -// This test ensures that when admin_account posts a new -// status, it ends up in the correct streaming timelines -// of local_account_1, which follows it. -func (suite *FromClientAPITestSuite) TestProcessStreamNewStatus() { - var ( - ctx = context.Background() - postingAccount = suite.testAccounts["admin_account"] - receivingAccount = suite.testAccounts["local_account_1"] - testList = suite.testLists["local_account_1_list_1"] - streams = suite.openStreams(ctx, receivingAccount, []string{testList.ID}) - homeStream = streams[stream.TimelineHome] - listStream = streams[stream.TimelineList+":"+testList.ID] - ) - - // Make a new status from admin account. - newStatus := >smodel.Status{ - ID: "01FN4B2F88TF9676DYNXWE1WSS", - URI: "http://localhost:8080/users/admin/statuses/01FN4B2F88TF9676DYNXWE1WSS", - URL: "http://localhost:8080/@admin/statuses/01FN4B2F88TF9676DYNXWE1WSS", - Content: "this status should stream :)", - AttachmentIDs: []string{}, - TagIDs: []string{}, - MentionIDs: []string{}, - EmojiIDs: []string{}, - CreatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"), - UpdatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"), - Local: util.Ptr(true), - AccountURI: "http://localhost:8080/users/admin", - AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", - InReplyToID: "", - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityFollowersOnly, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "01F8MGXQRHYF5QPMTMXP78QC2F", - Federated: util.Ptr(false), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - - // Put the status in the db first, to mimic what - // would have already happened earlier up the flow. - if err := suite.db.PutStatus(ctx, newStatus); err != nil { - suite.FailNow(err.Error()) - } - - // Process the new status. - if err := suite.processor.ProcessFromClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ObjectNote, - APActivityType: ap.ActivityCreate, - GTSModel: newStatus, - OriginAccount: postingAccount, - }); err != nil { - suite.FailNow(err.Error()) - } - - // Check message in home stream. - homeMsg := <-homeStream.Messages - suite.Equal(stream.EventTypeUpdate, homeMsg.Event) - suite.EqualValues([]string{stream.TimelineHome}, homeMsg.Stream) - suite.Empty(homeStream.Messages) // Stream should now be empty. - - // Check status from home stream. - homeStreamStatus := &apimodel.Status{} - if err := json.Unmarshal([]byte(homeMsg.Payload), homeStreamStatus); err != nil { - suite.FailNow(err.Error()) - } - suite.Equal(newStatus.ID, homeStreamStatus.ID) - suite.Equal(newStatus.Content, homeStreamStatus.Content) - - // Check message in list stream. - listMsg := <-listStream.Messages - suite.Equal(stream.EventTypeUpdate, listMsg.Event) - suite.EqualValues([]string{stream.TimelineList + ":" + testList.ID}, listMsg.Stream) - suite.Empty(listStream.Messages) // Stream should now be empty. - - // Check status from list stream. - listStreamStatus := &apimodel.Status{} - if err := json.Unmarshal([]byte(listMsg.Payload), listStreamStatus); err != nil { - suite.FailNow(err.Error()) - } - suite.Equal(newStatus.ID, listStreamStatus.ID) - suite.Equal(newStatus.Content, listStreamStatus.Content) -} - -func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { - var ( - ctx = context.Background() - deletingAccount = suite.testAccounts["local_account_1"] - receivingAccount = suite.testAccounts["local_account_2"] - deletedStatus = suite.testStatuses["local_account_1_status_1"] - boostOfDeletedStatus = suite.testStatuses["admin_account_status_4"] - streams = suite.openStreams(ctx, receivingAccount, nil) - homeStream = streams[stream.TimelineHome] - ) - - // Delete the status from the db first, to mimic what - // would have already happened earlier up the flow - if err := suite.db.DeleteStatusByID(ctx, deletedStatus.ID); err != nil { - suite.FailNow(err.Error()) - } - - // Process the status delete. - if err := suite.processor.ProcessFromClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ObjectNote, - APActivityType: ap.ActivityDelete, - GTSModel: deletedStatus, - OriginAccount: deletingAccount, - }); err != nil { - suite.FailNow(err.Error()) - } - - // Stream should have the delete of admin's boost in it now. - msg := <-homeStream.Messages - suite.Equal(stream.EventTypeDelete, msg.Event) - suite.Equal(boostOfDeletedStatus.ID, msg.Payload) - suite.EqualValues([]string{stream.TimelineHome}, msg.Stream) - - // Stream should also have the delete of the message itself in it. - msg = <-homeStream.Messages - suite.Equal(stream.EventTypeDelete, msg.Event) - suite.Equal(deletedStatus.ID, msg.Payload) - suite.EqualValues([]string{stream.TimelineHome}, msg.Stream) - - // Stream should now be empty. - suite.Empty(homeStream.Messages) - - // Boost should no longer be in the database. - if !testrig.WaitFor(func() bool { - _, err := suite.db.GetStatusByID(ctx, boostOfDeletedStatus.ID) - return errors.Is(err, db.ErrNoEntries) - }) { - suite.FailNow("timed out waiting for status delete") - } -} - -func (suite *FromClientAPITestSuite) TestProcessNewStatusWithNotification() { - var ( - ctx = context.Background() - postingAccount = suite.testAccounts["admin_account"] - receivingAccount = suite.testAccounts["local_account_1"] - streams = suite.openStreams(ctx, receivingAccount, nil) - notifStream = streams[stream.TimelineNotifications] - ) - - // Update the follow from receiving account -> posting account so - // that receiving account wants notifs when posting account posts. - follow := >smodel.Follow{} - *follow = *suite.testFollows["local_account_1_admin_account"] - follow.Notify = util.Ptr(true) - if err := suite.db.UpdateFollow(ctx, follow); err != nil { - suite.FailNow(err.Error()) - } - - // Make a new status from admin account. - newStatus := >smodel.Status{ - ID: "01FN4B2F88TF9676DYNXWE1WSS", - URI: "http://localhost:8080/users/admin/statuses/01FN4B2F88TF9676DYNXWE1WSS", - URL: "http://localhost:8080/@admin/statuses/01FN4B2F88TF9676DYNXWE1WSS", - Content: "this status should create a notification", - AttachmentIDs: []string{}, - TagIDs: []string{}, - MentionIDs: []string{}, - EmojiIDs: []string{}, - CreatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"), - UpdatedAt: testrig.TimeMustParse("2021-10-20T11:36:45Z"), - Local: util.Ptr(true), - AccountURI: "http://localhost:8080/users/admin", - AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", - InReplyToID: "", - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityFollowersOnly, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "01F8MGXQRHYF5QPMTMXP78QC2F", - Federated: util.Ptr(false), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - - // Put the status in the db first, to mimic what - // would have already happened earlier up the flow. - if err := suite.db.PutStatus(ctx, newStatus); err != nil { - suite.FailNow(err.Error()) - } - - // Process the new status. - if err := suite.processor.ProcessFromClientAPI(ctx, messages.FromClientAPI{ - APObjectType: ap.ObjectNote, - APActivityType: ap.ActivityCreate, - GTSModel: newStatus, - OriginAccount: postingAccount, - }); err != nil { - suite.FailNow(err.Error()) - } - - // Wait for a notification to appear for the status. - if !testrig.WaitFor(func() bool { - _, err := suite.db.GetNotification( - ctx, - gtsmodel.NotificationStatus, - receivingAccount.ID, - postingAccount.ID, - newStatus.ID, - ) - return err == nil - }) { - suite.FailNow("timed out waiting for new status notification") - } - - // Check message in notification stream. - notifMsg := <-notifStream.Messages - suite.Equal(stream.EventTypeNotification, notifMsg.Event) - suite.EqualValues([]string{stream.TimelineNotifications}, notifMsg.Stream) - suite.Empty(notifStream.Messages) // Stream should now be empty. - - // Check notif. - notif := &apimodel.Notification{} - if err := json.Unmarshal([]byte(notifMsg.Payload), notif); err != nil { - suite.FailNow(err.Error()) - } - suite.Equal(newStatus.ID, notif.Status.ID) -} - -func TestFromClientAPITestSuite(t *testing.T) { - suite.Run(t, &FromClientAPITestSuite{}) -} diff --git a/internal/processing/fromcommon.go b/internal/processing/fromcommon.go deleted file mode 100644 index 07895b6ba..000000000 --- a/internal/processing/fromcommon.go +++ /dev/null @@ -1,587 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package processing - -import ( - "context" - "errors" - - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/internal/timeline" -) - -// timelineAndNotifyStatus processes the given new status and inserts it into -// the HOME and LIST timelines of accounts that follow the status author. -// -// It will also handle notifications for any mentions attached to the account, and -// also notifications for any local accounts that want to know when this account posts. -func (p *Processor) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.Status) error { - // Ensure status fully populated; including account, mentions, etc. - if err := p.state.DB.PopulateStatus(ctx, status); err != nil { - return gtserror.Newf("error populating status with id %s: %w", status.ID, err) - } - - // Get local followers of the account that posted the status. - follows, err := p.state.DB.GetAccountLocalFollowers(ctx, status.AccountID) - if err != nil { - return gtserror.Newf("error getting local followers for account id %s: %w", status.AccountID, err) - } - - // 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() { - follows = append(follows, >smodel.Follow{ - AccountID: status.AccountID, - Account: status.Account, - Notify: func() *bool { b := false; return &b }(), // Account shouldn't notify itself. - ShowReblogs: func() *bool { b := true; return &b }(), // Account should show own reblogs. - }) - } - - // Timeline the status for each local follower of this account. - // This will also handle notifying any followers with notify - // set to true on their follow. - if err := p.timelineAndNotifyStatusForFollowers(ctx, status, follows); err != nil { - return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err) - } - - // Notify each local account that's mentioned by this status. - if err := p.notifyStatusMentions(ctx, status); err != nil { - return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err) - } - - return nil -} - -func (p *Processor) timelineAndNotifyStatusForFollowers(ctx context.Context, status *gtsmodel.Status, follows []*gtsmodel.Follow) error { - var ( - errs = gtserror.NewMultiError(len(follows)) - boost = status.BoostOfID != "" - reply = status.InReplyToURI != "" - ) - - for _, follow := range follows { - if sr := follow.ShowReblogs; boost && (sr == nil || !*sr) { - // This is a boost, but this follower - // doesn't want to see those from this - // account, so just skip everything. - continue - } - - // Add status to each list that this follow - // is included in, and stream it if applicable. - listEntries, err := p.state.DB.GetListEntriesForFollowID( - // We only need the list IDs. - gtscontext.SetBarebones(ctx), - follow.ID, - ) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - errs.Appendf("error list timelining status: %w", err) - continue - } - - for _, listEntry := range listEntries { - if _, err := p.timelineStatus( - ctx, - p.state.Timelines.List.IngestOne, - listEntry.ListID, // list timelines are keyed by list ID - follow.Account, - status, - stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list - ); err != nil { - errs.Appendf("error list timelining status: %w", err) - continue - } - } - - // Add status to home timeline for this - // follower, and stream it if applicable. - if timelined, err := p.timelineStatus( - ctx, - p.state.Timelines.Home.IngestOne, - follow.AccountID, // home timelines are keyed by account ID - follow.Account, - status, - stream.TimelineHome, - ); err != nil { - errs.Appendf("error home timelining status: %w", err) - continue - } else if !timelined { - // Status wasn't added to home tomeline, - // so we shouldn't notify it either. - continue - } - - if n := follow.Notify; n == nil || !*n { - // This follower doesn't have notifications - // set for this account's new posts, so bail. - continue - } - - if boost || reply { - // Don't notify for boosts or replies. - continue - } - - // If we reach here, we know: - // - // - This follower wants to be notified when this account posts. - // - This is a top-level post (not a reply). - // - This is not a boost of another post. - // - The post is visible in this follower's home timeline. - // - // That means we can officially notify this one. - if err := p.notify( - ctx, - gtsmodel.NotificationStatus, - follow.AccountID, - status.AccountID, - status.ID, - ); err != nil { - errs.Appendf("error notifying account %s about new status: %w", follow.AccountID, err) - } - } - - if err := errs.Combine(); err != nil { - return gtserror.Newf("%w", err) - } - - return nil -} - -// timelineStatus uses the provided ingest function to put the given -// status in a timeline with the given ID, if it's timelineable. -// -// If the status was inserted into the timeline, true will be returned -// + it will also be streamed to the user using the given streamType. -func (p *Processor) timelineStatus( - ctx context.Context, - ingest func(context.Context, string, timeline.Timelineable) (bool, error), - timelineID string, - account *gtsmodel.Account, - status *gtsmodel.Status, - streamType string, -) (bool, error) { - // Make sure the status is timelineable. - // This works for both home and list timelines. - if timelineable, err := p.filter.StatusHomeTimelineable(ctx, account, status); err != nil { - err = gtserror.Newf("error getting timelineability for status for timeline with id %s: %w", account.ID, err) - return false, err - } else if !timelineable { - // Nothing to do. - return false, nil - } - - // Ingest status into given timeline using provided function. - if inserted, err := ingest(ctx, timelineID, status); err != nil { - err = gtserror.Newf("error ingesting status %s: %w", status.ID, err) - return false, err - } else if !inserted { - // Nothing more to do. - return false, nil - } - - // The status was inserted so stream it to the user. - apiStatus, err := p.tc.StatusToAPIStatus(ctx, status, account) - if err != nil { - err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) - return true, err - } - - if err := p.stream.Update(apiStatus, account, []string{streamType}); err != nil { - err = gtserror.Newf("error streaming update for status %s: %w", status.ID, err) - return true, err - } - - return true, nil -} - -func (p *Processor) notifyStatusMentions(ctx context.Context, status *gtsmodel.Status) error { - errs := gtserror.NewMultiError(len(status.Mentions)) - - for _, m := range status.Mentions { - if err := p.notify( - ctx, - gtsmodel.NotificationMention, - m.TargetAccountID, - m.OriginAccountID, - m.StatusID, - ); err != nil { - errs.Append(err) - } - } - - if err := errs.Combine(); err != nil { - return gtserror.Newf("%w", err) - } - - return nil -} - -func (p *Processor) notifyFollowRequest(ctx context.Context, followRequest *gtsmodel.FollowRequest) error { - return p.notify( - ctx, - gtsmodel.NotificationFollowRequest, - followRequest.TargetAccountID, - followRequest.AccountID, - "", - ) -} - -func (p *Processor) notifyFollow(ctx context.Context, follow *gtsmodel.Follow, targetAccount *gtsmodel.Account) error { - // Remove previous follow request notification, if it exists. - prevNotif, err := p.state.DB.GetNotification( - gtscontext.SetBarebones(ctx), - gtsmodel.NotificationFollowRequest, - targetAccount.ID, - follow.AccountID, - "", - ) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - // Proper error while checking. - return gtserror.Newf("db error checking for previous follow request notification: %w", err) - } - - if prevNotif != nil { - // Previous notification existed, delete. - if err := p.state.DB.DeleteNotificationByID(ctx, prevNotif.ID); err != nil { - return gtserror.Newf("db error removing previous follow request notification %s: %w", prevNotif.ID, err) - } - } - - // Now notify the follow itself. - return p.notify( - ctx, - gtsmodel.NotificationFollow, - targetAccount.ID, - follow.AccountID, - "", - ) -} - -func (p *Processor) notifyFave(ctx context.Context, fave *gtsmodel.StatusFave) error { - if fave.TargetAccountID == fave.AccountID { - // Self-fave, nothing to do. - return nil - } - - return p.notify( - ctx, - gtsmodel.NotificationFave, - fave.TargetAccountID, - fave.AccountID, - fave.StatusID, - ) -} - -func (p *Processor) notifyAnnounce(ctx context.Context, status *gtsmodel.Status) error { - if status.BoostOfID == "" { - // Not a boost, nothing to do. - return nil - } - - if status.BoostOfAccountID == status.AccountID { - // Self-boost, nothing to do. - return nil - } - - return p.notify( - ctx, - gtsmodel.NotificationReblog, - status.BoostOfAccountID, - status.AccountID, - status.ID, - ) -} - -func (p *Processor) notify( - ctx context.Context, - notificationType gtsmodel.NotificationType, - targetAccountID string, - originAccountID string, - statusID string, -) error { - targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID) - if err != nil { - return gtserror.Newf("error getting target account %s: %w", targetAccountID, err) - } - - if !targetAccount.IsLocal() { - // Nothing to do. - return nil - } - - // Make sure a notification doesn't - // already exist with these params. - if _, err := p.state.DB.GetNotification( - ctx, - notificationType, - targetAccountID, - originAccountID, - statusID, - ); err == nil { - // Notification exists, nothing to do. - return nil - } else if !errors.Is(err, db.ErrNoEntries) { - // Real error. - return gtserror.Newf("error checking existence of notification: %w", err) - } - - // Notification doesn't yet exist, so - // we need to create + store one. - notif := >smodel.Notification{ - ID: id.NewULID(), - NotificationType: notificationType, - TargetAccountID: targetAccountID, - OriginAccountID: originAccountID, - StatusID: statusID, - } - - if err := p.state.DB.PutNotification(ctx, notif); err != nil { - return gtserror.Newf("error putting notification in database: %w", err) - } - - // Stream notification to the user. - apiNotif, err := p.tc.NotificationToAPINotification(ctx, notif) - if err != nil { - return gtserror.Newf("error converting notification to api representation: %w", err) - } - - if err := p.stream.Notify(apiNotif, targetAccount); err != nil { - return gtserror.Newf("error streaming notification to account: %w", err) - } - - return nil -} - -// wipeStatus contains common logic used to totally delete a status -// + all its attachments, notifications, boosts, and timeline entries. -func (p *Processor) wipeStatus(ctx context.Context, statusToDelete *gtsmodel.Status, deleteAttachments bool) error { - var errs gtserror.MultiError - - // either delete all attachments for this status, or simply - // unattach all attachments for this status, so they'll be - // cleaned later by a separate process; reason to unattach rather - // than delete is that the poster might want to reattach them - // to another status immediately (in case of delete + redraft) - if deleteAttachments { - // todo: p.state.DB.DeleteAttachmentsForStatus - for _, a := range statusToDelete.AttachmentIDs { - if err := p.media.Delete(ctx, a); err != nil { - errs.Appendf("error deleting media: %w", err) - } - } - } else { - // todo: p.state.DB.UnattachAttachmentsForStatus - for _, a := range statusToDelete.AttachmentIDs { - if _, err := p.media.Unattach(ctx, statusToDelete.Account, a); err != nil { - errs.Appendf("error unattaching media: %w", err) - } - } - } - - // delete all mention entries generated by this status - // todo: p.state.DB.DeleteMentionsForStatus - for _, id := range statusToDelete.MentionIDs { - if err := p.state.DB.DeleteMentionByID(ctx, id); err != nil { - errs.Appendf("error deleting status mention: %w", err) - } - } - - // delete all notification entries generated by this status - if err := p.state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil { - errs.Appendf("error deleting status notifications: %w", err) - } - - // delete all bookmarks that point to this status - if err := p.state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil { - errs.Appendf("error deleting status bookmarks: %w", err) - } - - // delete all faves of this status - if err := p.state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil { - errs.Appendf("error deleting status faves: %w", err) - } - - // delete all boosts for this status + remove them from timelines - boosts, err := p.state.DB.GetStatusBoosts( - // we MUST set a barebones context here, - // as depending on where it came from the - // original BoostOf may already be gone. - gtscontext.SetBarebones(ctx), - statusToDelete.ID) - if err != nil { - errs.Appendf("error fetching status boosts: %w", err) - } - for _, b := range boosts { - if err := p.deleteStatusFromTimelines(ctx, b.ID); err != nil { - errs.Appendf("error deleting boost from timelines: %w", err) - } - if err := p.state.DB.DeleteStatusByID(ctx, b.ID); err != nil { - errs.Appendf("error deleting boost: %w", err) - } - } - - // delete this status from any and all timelines - if err := p.deleteStatusFromTimelines(ctx, statusToDelete.ID); err != nil { - errs.Appendf("error deleting status from timelines: %w", err) - } - - // finally, delete the status itself - if err := p.state.DB.DeleteStatusByID(ctx, statusToDelete.ID); err != nil { - errs.Appendf("error deleting status: %w", err) - } - - return errs.Combine() -} - -// deleteStatusFromTimelines completely removes the given status from all timelines. -// It will also stream deletion of the status to all open streams. -func (p *Processor) deleteStatusFromTimelines(ctx context.Context, statusID string) error { - if err := p.state.Timelines.Home.WipeItemFromAllTimelines(ctx, statusID); err != nil { - return err - } - - if err := p.state.Timelines.List.WipeItemFromAllTimelines(ctx, statusID); err != nil { - return err - } - - return p.stream.Delete(statusID) -} - -// invalidateStatusFromTimelines does cache invalidation on the given status by -// unpreparing it from all timelines, forcing it to be prepared again (with updated -// stats, boost counts, etc) next time it's fetched by the timeline owner. This goes -// both for the status itself, and for any boosts of the status. -func (p *Processor) invalidateStatusFromTimelines(ctx context.Context, statusID string) { - if err := p.state.Timelines.Home.UnprepareItemFromAllTimelines(ctx, statusID); err != nil { - log. - WithContext(ctx). - WithField("statusID", statusID). - Errorf("error unpreparing status from home timelines: %v", err) - } - - if err := p.state.Timelines.List.UnprepareItemFromAllTimelines(ctx, statusID); err != nil { - log. - WithContext(ctx). - WithField("statusID", statusID). - Errorf("error unpreparing status from list timelines: %v", err) - } -} - -/* - EMAIL FUNCTIONS -*/ - -func (p *Processor) emailReport(ctx context.Context, report *gtsmodel.Report) error { - instance, err := p.state.DB.GetInstance(ctx, config.GetHost()) - if err != nil { - return gtserror.Newf("error getting instance: %w", err) - } - - toAddresses, err := p.state.DB.GetInstanceModeratorAddresses(ctx) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - // No registered moderator addresses. - return nil - } - return gtserror.Newf("error getting instance moderator addresses: %w", err) - } - - if report.Account == nil { - report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID) - if err != nil { - return gtserror.Newf("error getting report account: %w", err) - } - } - - if report.TargetAccount == nil { - report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID) - if err != nil { - return gtserror.Newf("error getting report target account: %w", err) - } - } - - reportData := email.NewReportData{ - InstanceURL: instance.URI, - InstanceName: instance.Title, - ReportURL: instance.URI + "/settings/admin/reports/" + report.ID, - ReportDomain: report.Account.Domain, - ReportTargetDomain: report.TargetAccount.Domain, - } - - if err := p.emailSender.SendNewReportEmail(toAddresses, reportData); err != nil { - return gtserror.Newf("error emailing instance moderators: %w", err) - } - - return nil -} - -func (p *Processor) emailReportClosed(ctx context.Context, report *gtsmodel.Report) error { - user, err := p.state.DB.GetUserByAccountID(ctx, report.Account.ID) - if err != nil { - return gtserror.Newf("db error getting user: %w", err) - } - - if user.ConfirmedAt.IsZero() || !*user.Approved || *user.Disabled || user.Email == "" { - // Only email users who: - // - are confirmed - // - are approved - // - are not disabled - // - have an email address - return nil - } - - instance, err := p.state.DB.GetInstance(ctx, config.GetHost()) - if err != nil { - return gtserror.Newf("db error getting instance: %w", err) - } - - if report.Account == nil { - report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID) - if err != nil { - return gtserror.Newf("error getting report account: %w", err) - } - } - - if report.TargetAccount == nil { - report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID) - if err != nil { - return gtserror.Newf("error getting report target account: %w", err) - } - } - - reportClosedData := email.ReportClosedData{ - Username: report.Account.Username, - InstanceURL: instance.URI, - InstanceName: instance.Title, - ReportTargetUsername: report.TargetAccount.Username, - ReportTargetDomain: report.TargetAccount.Domain, - ActionTakenComment: report.ActionTaken, - } - - return p.emailSender.SendReportClosedEmail(user.Email, reportClosedData) -} diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go deleted file mode 100644 index 2790d31ee..000000000 --- a/internal/processing/fromfederator.go +++ /dev/null @@ -1,486 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package processing - -import ( - "context" - "errors" - "net/url" - - "codeberg.org/gruf/go-kv" - "codeberg.org/gruf/go-logger/v2/level" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "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" -) - -// ProcessFromFederator reads the APActivityType and APObjectType of an incoming message from the federator, -// and directs the message into the appropriate side effect handler function, or simply does nothing if there's -// no handler function defined for the combination of Activity and Object. -func (p *Processor) ProcessFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - // Allocate new log fields slice - fields := make([]kv.Field, 3, 5) - fields[0] = kv.Field{"activityType", federatorMsg.APActivityType} - fields[1] = kv.Field{"objectType", federatorMsg.APObjectType} - fields[2] = kv.Field{"toAccount", federatorMsg.ReceivingAccount.Username} - - if federatorMsg.APIri != nil { - // An IRI was supplied, append to log - fields = append(fields, kv.Field{ - "iri", federatorMsg.APIri, - }) - } - - if federatorMsg.GTSModel != nil && - log.Level() >= level.DEBUG { - // Append converted model to log - fields = append(fields, kv.Field{ - "model", federatorMsg.GTSModel, - }) - } - - // Log this federated message - l := log.WithContext(ctx).WithFields(fields...) - l.Info("processing from federator") - - switch federatorMsg.APActivityType { - case ap.ActivityCreate: - // CREATE SOMETHING - switch federatorMsg.APObjectType { - case ap.ObjectNote: - // CREATE A STATUS - return p.processCreateStatusFromFederator(ctx, federatorMsg) - case ap.ActivityLike: - // CREATE A FAVE - return p.processCreateFaveFromFederator(ctx, federatorMsg) - case ap.ActivityFollow: - // CREATE A FOLLOW REQUEST - return p.processCreateFollowRequestFromFederator(ctx, federatorMsg) - case ap.ActivityAnnounce: - // CREATE AN ANNOUNCE - return p.processCreateAnnounceFromFederator(ctx, federatorMsg) - case ap.ActivityBlock: - // CREATE A BLOCK - return p.processCreateBlockFromFederator(ctx, federatorMsg) - case ap.ActivityFlag: - // CREATE A FLAG / REPORT - return p.processCreateFlagFromFederator(ctx, federatorMsg) - } - case ap.ActivityUpdate: - // UPDATE SOMETHING - if federatorMsg.APObjectType == ap.ObjectProfile { - // UPDATE AN ACCOUNT - return p.processUpdateAccountFromFederator(ctx, federatorMsg) - } - case ap.ActivityDelete: - // DELETE SOMETHING - switch federatorMsg.APObjectType { - case ap.ObjectNote: - // DELETE A STATUS - return p.processDeleteStatusFromFederator(ctx, federatorMsg) - case ap.ObjectProfile: - // DELETE A PROFILE/ACCOUNT - return p.processDeleteAccountFromFederator(ctx, federatorMsg) - } - } - - // not a combination we can/need to process - return nil -} - -// processCreateStatusFromFederator handles Activity Create and Object Note. -func (p *Processor) processCreateStatusFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - var ( - status *gtsmodel.Status - err error - - // Check the federatorMsg for either an already dereferenced - // and converted status pinned to the message, or a forwarded - // AP IRI that we still need to deref. - forwarded = (federatorMsg.GTSModel == nil) - ) - - if forwarded { - // Model was not set, deref with IRI. - // This will also cause the status to be inserted into the db. - status, err = p.statusFromAPIRI(ctx, federatorMsg) - } else { - // Model is set, ensure we have the most up-to-date model. - status, err = p.statusFromGTSModel(ctx, federatorMsg) - } - - if err != nil { - return gtserror.Newf("error extracting status from federatorMsg: %w", err) - } - - if status.Account == nil || status.Account.IsRemote() { - // Either no account attached yet, or a remote account. - // Both situations we need to parse account URI to fetch it. - accountURI, err := url.Parse(status.AccountURI) - if err != nil { - return err - } - - // Ensure that account for this status has been deref'd. - status.Account, _, err = p.federator.GetAccountByURI(ctx, - federatorMsg.ReceivingAccount.Username, - accountURI, - ) - if err != nil { - return err - } - } - - // Ensure status ancestors dereferenced. We need at least the - // immediate parent (if present) to ascertain timelineability. - if err := p.federator.DereferenceStatusAncestors(ctx, - federatorMsg.ReceivingAccount.Username, - status, - ); err != nil { - return err - } - - if status.InReplyToID != "" { - // Interaction counts changed on the replied status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, status.InReplyToID) - } - - if err := p.timelineAndNotifyStatus(ctx, status); err != nil { - return gtserror.Newf("error timelining status: %w", err) - } - - return nil -} - -func (p *Processor) statusFromGTSModel(ctx context.Context, federatorMsg messages.FromFederator) (*gtsmodel.Status, error) { - // There should be a status pinned to the federatorMsg - // (we've already checked to ensure this is not nil). - status, ok := federatorMsg.GTSModel.(*gtsmodel.Status) - if !ok { - err := gtserror.New("Note was not parseable as *gtsmodel.Status") - return nil, err - } - - // AP statusable representation may have also - // been set on message (no problem if not). - statusable, _ := federatorMsg.APObjectModel.(ap.Statusable) - - // Call refresh on status to update - // it (deref remote) if necessary. - var err error - status, _, err = p.federator.RefreshStatus( - ctx, - federatorMsg.ReceivingAccount.Username, - status, - statusable, - false, - ) - if err != nil { - return nil, gtserror.Newf("%w", err) - } - - return status, nil -} - -func (p *Processor) statusFromAPIRI(ctx context.Context, federatorMsg messages.FromFederator) (*gtsmodel.Status, error) { - // There should be a status IRI pinned to - // the federatorMsg for us to dereference. - if federatorMsg.APIri == nil { - err := gtserror.New("status was not pinned to federatorMsg, and neither was an IRI for us to dereference") - return nil, err - } - - // Get the status + ensure we have - // the most up-to-date version. - status, _, err := p.federator.GetStatusByURI( - ctx, - federatorMsg.ReceivingAccount.Username, - federatorMsg.APIri, - ) - if err != nil { - return nil, gtserror.Newf("%w", err) - } - - return status, nil -} - -// processCreateFaveFromFederator handles Activity Create with Object Like. -func (p *Processor) processCreateFaveFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - statusFave, ok := federatorMsg.GTSModel.(*gtsmodel.StatusFave) - if !ok { - return gtserror.New("Like was not parseable as *gtsmodel.StatusFave") - } - - if err := p.notifyFave(ctx, statusFave); err != nil { - return gtserror.Newf("error notifying status fave: %w", err) - } - - // Interaction counts changed on the faved status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, statusFave.StatusID) - - return nil -} - -// processCreateFollowRequestFromFederator handles Activity Create and Object Follow -func (p *Processor) processCreateFollowRequestFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - followRequest, ok := federatorMsg.GTSModel.(*gtsmodel.FollowRequest) - if !ok { - return errors.New("incomingFollowRequest was not parseable as *gtsmodel.FollowRequest") - } - - // make sure the account is pinned - if followRequest.Account == nil { - a, err := p.state.DB.GetAccountByID(ctx, followRequest.AccountID) - if err != nil { - return err - } - followRequest.Account = a - } - - // Get the remote account to make sure the avi and header are cached. - if followRequest.Account.Domain != "" { - remoteAccountID, err := url.Parse(followRequest.Account.URI) - if err != nil { - return err - } - - a, _, err := p.federator.GetAccountByURI(ctx, - federatorMsg.ReceivingAccount.Username, - remoteAccountID, - ) - if err != nil { - return err - } - - followRequest.Account = a - } - - if followRequest.TargetAccount == nil { - a, err := p.state.DB.GetAccountByID(ctx, followRequest.TargetAccountID) - if err != nil { - return err - } - followRequest.TargetAccount = a - } - - if *followRequest.TargetAccount.Locked { - // if the account is locked just notify the follow request and nothing else - return p.notifyFollowRequest(ctx, followRequest) - } - - // if the target account isn't locked, we should already accept the follow and notify about the new follower instead - follow, err := p.state.DB.AcceptFollowRequest(ctx, followRequest.AccountID, followRequest.TargetAccountID) - if err != nil { - return err - } - - if err := p.federateAcceptFollowRequest(ctx, follow); err != nil { - return err - } - - return p.notifyFollow(ctx, follow, followRequest.TargetAccount) -} - -// processCreateAnnounceFromFederator handles Activity Create with Object Announce. -func (p *Processor) processCreateAnnounceFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - status, ok := federatorMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return gtserror.New("Announce was not parseable as *gtsmodel.Status") - } - - // Dereference status that this status boosts. - if err := p.federator.DereferenceAnnounce(ctx, status, federatorMsg.ReceivingAccount.Username); err != nil { - return gtserror.Newf("error dereferencing announce: %w", err) - } - - // Generate an ID for the boost wrapper status. - statusID, err := id.NewULIDFromTime(status.CreatedAt) - if err != nil { - return gtserror.Newf("error generating id: %w", err) - } - status.ID = statusID - - // Store the boost wrapper status. - if err := p.state.DB.PutStatus(ctx, status); err != nil { - return gtserror.Newf("db error inserting status: %w", err) - } - - // Ensure boosted status ancestors dereferenced. We need at least - // the immediate parent (if present) to ascertain timelineability. - if err := p.federator.DereferenceStatusAncestors(ctx, - federatorMsg.ReceivingAccount.Username, - status.BoostOf, - ); err != nil { - return err - } - - // Timeline and notify the announce. - if err := p.timelineAndNotifyStatus(ctx, status); err != nil { - return gtserror.Newf("error timelining status: %w", err) - } - - if err := p.notifyAnnounce(ctx, status); err != nil { - return gtserror.Newf("error notifying status: %w", err) - } - - // Interaction counts changed on the boosted status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, status.ID) - - return nil -} - -// processCreateBlockFromFederator handles Activity Create and Object Block -func (p *Processor) processCreateBlockFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - block, ok := federatorMsg.GTSModel.(*gtsmodel.Block) - if !ok { - return gtserror.New("block was not parseable as *gtsmodel.Block") - } - - // Remove each account's posts from the other's timelines. - // - // First home timelines. - if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil { - return gtserror.Newf("%w", err) - } - - if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil { - return gtserror.Newf("%w", err) - } - - // Now list timelines. - if err := p.state.Timelines.List.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil { - return gtserror.Newf("%w", err) - } - - if err := p.state.Timelines.List.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil { - return gtserror.Newf("%w", err) - } - - // Remove any follows that existed between blocker + blockee. - if err := p.state.DB.DeleteFollow(ctx, block.AccountID, block.TargetAccountID); err != nil { - return gtserror.Newf( - "db error deleting follow from %s targeting %s: %w", - block.AccountID, block.TargetAccountID, err, - ) - } - - if err := p.state.DB.DeleteFollow(ctx, block.TargetAccountID, block.AccountID); err != nil { - return gtserror.Newf( - "db error deleting follow from %s targeting %s: %w", - block.TargetAccountID, block.AccountID, err, - ) - } - - // Remove any follow requests that existed between blocker + blockee. - if err := p.state.DB.DeleteFollowRequest(ctx, block.AccountID, block.TargetAccountID); err != nil { - return gtserror.Newf( - "db error deleting follow request from %s targeting %s: %w", - block.AccountID, block.TargetAccountID, err, - ) - } - - if err := p.state.DB.DeleteFollowRequest(ctx, block.TargetAccountID, block.AccountID); err != nil { - return gtserror.Newf( - "db error deleting follow request from %s targeting %s: %w", - block.TargetAccountID, block.AccountID, err, - ) - } - - return nil -} - -func (p *Processor) processCreateFlagFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - incomingReport, ok := federatorMsg.GTSModel.(*gtsmodel.Report) - if !ok { - return errors.New("flag was not parseable as *gtsmodel.Report") - } - - // TODO: handle additional side effects of flag creation: - // - notify admins by dm / notification - - return p.emailReport(ctx, incomingReport) -} - -// processUpdateAccountFromFederator handles Activity Update and Object Profile -func (p *Processor) processUpdateAccountFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - // Parse the old/existing account model. - account, ok := federatorMsg.GTSModel.(*gtsmodel.Account) - if !ok { - return gtserror.New("account was not parseable as *gtsmodel.Account") - } - - // Because this was an Update, the new Accountable should be set on the message. - apubAcc, ok := federatorMsg.APObjectModel.(ap.Accountable) - if !ok { - return gtserror.New("Accountable was not parseable on update account message") - } - - // Fetch up-to-date bio, avatar, header, etc. - _, _, err := p.federator.RefreshAccount( - ctx, - federatorMsg.ReceivingAccount.Username, - account, - apubAcc, - true, // Force refresh. - ) - if err != nil { - return gtserror.Newf("error refreshing updated account: %w", err) - } - - return nil -} - -// processDeleteStatusFromFederator handles Activity Delete and Object Note -func (p *Processor) processDeleteStatusFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - status, ok := federatorMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("Note was not parseable as *gtsmodel.Status") - } - - // Delete attachments from this status, since this request - // comes from the federating API, and there's no way the - // poster can do a delete + redraft for it on our instance. - deleteAttachments := true - if err := p.wipeStatus(ctx, status, deleteAttachments); err != nil { - return gtserror.Newf("error wiping status: %w", err) - } - - if status.InReplyToID != "" { - // Interaction counts changed on the replied status; - // uncache the prepared version from all timelines. - p.invalidateStatusFromTimelines(ctx, status.InReplyToID) - } - - return nil -} - -// processDeleteAccountFromFederator handles Activity Delete and Object Profile -func (p *Processor) processDeleteAccountFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { - account, ok := federatorMsg.GTSModel.(*gtsmodel.Account) - if !ok { - return errors.New("account delete was not parseable as *gtsmodel.Account") - } - - return p.account.Delete(ctx, account, account.ID) -} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 2f1f43826..c0fd15a24 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -18,13 +18,9 @@ package processing import ( - "context" - "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/log" mm "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/admin" @@ -38,19 +34,23 @@ "github.com/superseriousbusiness/gotosocial/internal/processing/stream" "github.com/superseriousbusiness/gotosocial/internal/processing/timeline" "github.com/superseriousbusiness/gotosocial/internal/processing/user" + "github.com/superseriousbusiness/gotosocial/internal/processing/workers" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/visibility" ) +// Processor groups together processing functions and +// sub processors for handling actions + events coming +// from either the client or federating APIs. +// +// Many of the functions available through this struct +// or sub processors will trigger asynchronous processing +// via the workers contained in state. type Processor struct { - federator federation.Federator - tc typeutils.TypeConverter - oauthServer oauth.Server - mediaManager *mm.Manager - state *state.State - emailSender email.Sender - filter *visibility.Filter + tc typeutils.TypeConverter + oauthServer oauth.Server + state *state.State /* SUB-PROCESSORS @@ -68,6 +68,7 @@ type Processor struct { stream stream.Processor timeline timeline.Processor user user.Processor + workers workers.Processor } func (p *Processor) Account() *account.Processor { @@ -118,6 +119,10 @@ func (p *Processor) User() *user.Processor { return &p.user } +func (p *Processor) Workers() *workers.Processor { + return &p.workers +} + // NewProcessor returns a new Processor. func NewProcessor( tc typeutils.TypeConverter, @@ -127,57 +132,53 @@ func NewProcessor( state *state.State, emailSender email.Sender, ) *Processor { - parseMentionFunc := GetParseMentionFunc(state.DB, federator) - - filter := visibility.NewFilter(state) + var ( + parseMentionFunc = GetParseMentionFunc(state.DB, federator) + filter = visibility.NewFilter(state) + ) processor := &Processor{ - federator: federator, - tc: tc, - oauthServer: oauthServer, - mediaManager: mediaManager, - state: state, - filter: filter, - emailSender: emailSender, + tc: tc, + oauthServer: oauthServer, + state: state, } // Instantiate sub processors. - processor.account = account.New(state, tc, mediaManager, oauthServer, federator, filter, parseMentionFunc) + // + // Start with sub processors that will + // be required by the workers processor. + accountProcessor := account.New(state, tc, mediaManager, oauthServer, federator, filter, parseMentionFunc) + mediaProcessor := media.New(state, tc, mediaManager, federator.TransportController()) + streamProcessor := stream.New(state, oauthServer) + + // Instantiate the rest of the sub + // processors + pin them to this struct. + processor.account = accountProcessor processor.admin = admin.New(state, tc, mediaManager, federator.TransportController(), emailSender) processor.fedi = fedi.New(state, tc, federator, filter) processor.list = list.New(state, tc) processor.markers = markers.New(state, tc) - processor.media = media.New(state, tc, mediaManager, federator.TransportController()) + processor.media = mediaProcessor processor.report = report.New(state, tc) processor.timeline = timeline.New(state, tc, filter) processor.search = search.New(state, federator, tc, filter) processor.status = status.New(state, federator, tc, filter, parseMentionFunc) - processor.stream = stream.New(state, oauthServer) + processor.stream = streamProcessor processor.user = user.New(state, emailSender) + // Workers processor handles asynchronous + // worker jobs; instantiate it separately + // and pass subset of sub processors it needs. + processor.workers = workers.New( + state, + federator, + tc, + filter, + emailSender, + &accountProcessor, + &mediaProcessor, + &streamProcessor, + ) + return processor } - -func (p *Processor) EnqueueClientAPI(ctx context.Context, msgs ...messages.FromClientAPI) { - log.Trace(ctx, "enqueuing") - _ = p.state.Workers.ClientAPI.MustEnqueueCtx(ctx, func(ctx context.Context) { - for _, msg := range msgs { - log.Trace(ctx, "processing: %+v", msg) - if err := p.ProcessFromClientAPI(ctx, msg); err != nil { - log.Errorf(ctx, "error processing client API message: %v", err) - } - } - }) -} - -func (p *Processor) EnqueueFederator(ctx context.Context, msgs ...messages.FromFederator) { - log.Trace(ctx, "enqueuing") - _ = p.state.Workers.Federator.MustEnqueueCtx(ctx, func(ctx context.Context) { - for _, msg := range msgs { - log.Trace(ctx, "processing: %+v", msg) - if err := p.ProcessFromFederator(ctx, msg); err != nil { - log.Errorf(ctx, "error processing federator message: %v", err) - } - } - }) -} diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index fc85b38ba..383e1dc9f 100644 --- a/internal/processing/processor_test.go +++ b/internal/processing/processor_test.go @@ -123,8 +123,8 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) suite.processor = processing.NewProcessor(suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaManager, &suite.state, suite.emailSender) - suite.state.Workers.EnqueueClientAPI = suite.processor.EnqueueClientAPI - suite.state.Workers.EnqueueFederator = suite.processor.EnqueueFederator + suite.state.Workers.EnqueueClientAPI = suite.processor.Workers().EnqueueClientAPI + suite.state.Workers.EnqueueFediAPI = suite.processor.Workers().EnqueueFediAPI testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../testrig/media") diff --git a/internal/processing/stream/stream.go b/internal/processing/stream/stream.go index bd49a330c..972173c7a 100644 --- a/internal/processing/stream/stream.go +++ b/internal/processing/stream/stream.go @@ -28,13 +28,14 @@ type Processor struct { state *state.State oauthServer oauth.Server - streamMap sync.Map + streamMap *sync.Map } func New(state *state.State, oauthServer oauth.Server) Processor { return Processor{ state: state, oauthServer: oauthServer, + streamMap: &sync.Map{}, } } diff --git a/internal/processing/user/email.go b/internal/processing/user/email.go index 32c8e760a..dd2a96ae3 100644 --- a/internal/processing/user/email.go +++ b/internal/processing/user/email.go @@ -23,67 +23,13 @@ "fmt" "time" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/uris" ) var oneWeek = 168 * time.Hour -// EmailSendConfirmation sends an email address confirmation request email to the given user. -func (p *Processor) EmailSendConfirmation(ctx context.Context, user *gtsmodel.User, username string) error { - if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { - // user has already confirmed this email address, so there's nothing to do - return nil - } - - // We need a token and a link for the user to click on. - // We'll use a uuid as our token since it's basically impossible to guess. - // From the uuid package we use (which uses crypto/rand under the hood): - // Randomly generated UUIDs have 122 random bits. One's annual risk of being - // hit by a meteorite is estimated to be one chance in 17 billion, that - // means the probability is about 0.00000000006 (6 × 10−11), - // equivalent to the odds of creating a few tens of trillions of UUIDs in a - // year and having one duplicate. - confirmationToken := uuid.NewString() - confirmationLink := uris.GenerateURIForEmailConfirm(confirmationToken) - - // pull our instance entry from the database so we can greet the user nicely in the email - instance := >smodel.Instance{} - host := config.GetHost() - if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "domain", Value: host}}, instance); err != nil { - return fmt.Errorf("SendConfirmEmail: error getting instance: %s", err) - } - - // assemble the email contents and send the email - confirmData := email.ConfirmData{ - Username: username, - InstanceURL: instance.URI, - InstanceName: instance.Title, - ConfirmLink: confirmationLink, - } - if err := p.emailSender.SendConfirmEmail(user.UnconfirmedEmail, confirmData); err != nil { - return fmt.Errorf("SendConfirmEmail: error sending to email address %s belonging to user %s: %s", user.UnconfirmedEmail, username, err) - } - - // email sent, now we need to update the user entry with the token we just sent them - updatingColumns := []string{"confirmation_sent_at", "confirmation_token", "last_emailed_at", "updated_at"} - user.ConfirmationSentAt = time.Now() - user.ConfirmationToken = confirmationToken - user.LastEmailedAt = time.Now() - user.UpdatedAt = time.Now() - - if err := p.state.DB.UpdateByID(ctx, user, user.ID, updatingColumns...); err != nil { - return fmt.Errorf("SendConfirmEmail: error updating user entry after email sent: %s", err) - } - - return nil -} - // EmailConfirm processes an email confirmation request, usually initiated as a result of clicking on a link // in a 'confirm your email address' type email. func (p *Processor) EmailConfirm(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) { diff --git a/internal/processing/user/email_test.go b/internal/processing/user/email_test.go index 038307e55..b42446991 100644 --- a/internal/processing/user/email_test.go +++ b/internal/processing/user/email_test.go @@ -19,7 +19,6 @@ import ( "context" - "fmt" "testing" "time" @@ -30,36 +29,6 @@ type EmailConfirmTestSuite struct { UserStandardTestSuite } -func (suite *EmailConfirmTestSuite) TestSendConfirmEmail() { - user := suite.testUsers["local_account_1"] - - // set a bunch of stuff on the user as though zork hasn't been confirmed (perish the thought) - user.UnconfirmedEmail = "some.email@example.org" - user.Email = "" - user.ConfirmedAt = time.Time{} - user.ConfirmationSentAt = time.Time{} - user.ConfirmationToken = "" - - err := suite.user.EmailSendConfirmation(context.Background(), user, "the_mighty_zork") - suite.NoError(err) - - // zork should have an email now - suite.Len(suite.sentEmails, 1) - email, ok := suite.sentEmails["some.email@example.org"] - suite.True(ok) - - // a token should be set on zork - token := user.ConfirmationToken - suite.NotEmpty(token) - - // email should contain the token - emailShould := fmt.Sprintf("To: some.email@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello the_mighty_zork!\r\n\r\nYou are receiving this mail because you've requested an account on http://localhost:8080.\r\n\r\nWe just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:\r\n\r\nhttp://localhost:8080/confirm_email?token=%s\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of http://localhost:8080\r\n\r\n", token) - suite.Equal(emailShould, email) - - // confirmationSentAt should be recent - suite.WithinDuration(time.Now(), user.ConfirmationSentAt, 1*time.Minute) -} - func (suite *EmailConfirmTestSuite) TestConfirmEmail() { ctx := context.Background() diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go new file mode 100644 index 000000000..76bfc892e --- /dev/null +++ b/internal/processing/workers/federate.go @@ -0,0 +1,892 @@ +// 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 workers + +import ( + "context" + "net/url" + + "github.com/superseriousbusiness/activity/pub" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// federate wraps functions for federating +// something out via ActivityPub in response +// to message processing. +type federate struct { + // Embed federator to give access + // to send and retrieve functions. + federation.Federator + state *state.State + tc typeutils.TypeConverter +} + +// parseURI is a cheeky little +// shortcut to wrap parsing errors. +// +// The returned err will be prepended +// with the name of the function that +// called this function, so it can be +// returned without further wrapping. +func parseURI(s string) (*url.URL, error) { + const ( + // Provides enough calldepth to + // prepend the name of whatever + // function called *this* one, + // so that they don't have to + // wrap the error themselves. + calldepth = 3 + errFmt = "error parsing uri %s: %w" + ) + + uri, err := url.Parse(s) + if err != nil { + return nil, gtserror.NewfAt(calldepth, errFmt, s, err) + } + + return uri, err +} + +func (f *federate) DeleteAccount(ctx context.Context, account *gtsmodel.Account) error { + // Do nothing if it's not our + // account that's been deleted. + if !account.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(account.OutboxURI) + if err != nil { + return err + } + + actorIRI, err := parseURI(account.URI) + if err != nil { + return err + } + + followersIRI, err := parseURI(account.FollowersURI) + if err != nil { + return err + } + + publicIRI, err := parseURI(pub.PublicActivityPubIRI) + if err != nil { + return err + } + + // Create a new delete. + // todo: tc.AccountToASDelete + delete := streams.NewActivityStreamsDelete() + + // Set the Actor for the delete; no matter + // who actually did the delete, we should + // use the account owner for this. + deleteActor := streams.NewActivityStreamsActorProperty() + deleteActor.AppendIRI(actorIRI) + delete.SetActivityStreamsActor(deleteActor) + + // Set the account's IRI as the 'object' property. + deleteObject := streams.NewActivityStreamsObjectProperty() + deleteObject.AppendIRI(actorIRI) + delete.SetActivityStreamsObject(deleteObject) + + // Address the delete To followers. + deleteTo := streams.NewActivityStreamsToProperty() + deleteTo.AppendIRI(followersIRI) + delete.SetActivityStreamsTo(deleteTo) + + // Address the delete CC public. + deleteCC := streams.NewActivityStreamsCcProperty() + deleteCC.AppendIRI(publicIRI) + delete.SetActivityStreamsCc(deleteCC) + + // Send the Delete via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, delete, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + delete, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) error { + // Do nothing if the status + // shouldn't be federated. + if !*status.Federated { + return nil + } + + // Do nothing if this + // isn't our status. + if !*status.Local { + return nil + } + + // Populate model. + if err := f.state.DB.PopulateStatus(ctx, status); err != nil { + return gtserror.Newf("error populating status: %w", err) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(status.Account.OutboxURI) + if err != nil { + return err + } + + // Convert status to an ActivityStreams + // Note, wrapped in a Create activity. + asStatus, err := f.tc.StatusToAS(ctx, status) + if err != nil { + return gtserror.Newf("error converting status to AS: %w", err) + } + + create, err := f.tc.WrapNoteInCreate(asStatus, false) + if err != nil { + return gtserror.Newf("error wrapping status in create: %w", err) + } + + // Send the Create via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, create, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + create, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) DeleteStatus(ctx context.Context, status *gtsmodel.Status) error { + // Do nothing if the status + // shouldn't be federated. + if !*status.Federated { + return nil + } + + // Do nothing if this + // isn't our status. + if !*status.Local { + return nil + } + + // Populate model. + if err := f.state.DB.PopulateStatus(ctx, status); err != nil { + return gtserror.Newf("error populating status: %w", err) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(status.Account.OutboxURI) + if err != nil { + return err + } + + // Wrap the status URI in a Delete activity. + delete, err := f.tc.StatusToASDelete(ctx, status) + if err != nil { + return gtserror.Newf("error creating Delete: %w", err) + } + + // Send the Delete via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, delete, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + delete, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) Follow(ctx context.Context, follow *gtsmodel.Follow) error { + // Populate model. + if err := f.state.DB.PopulateFollow(ctx, follow); err != nil { + return gtserror.Newf("error populating follow: %w", err) + } + + // Do nothing if both accounts are local. + if follow.Account.IsLocal() && + follow.TargetAccount.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(follow.Account.OutboxURI) + if err != nil { + return err + } + + // Convert follow to ActivityStreams Follow. + asFollow, err := f.tc.FollowToAS(ctx, follow) + if err != nil { + return gtserror.Newf("error converting follow to AS: %s", err) + } + + // Send the Follow via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, asFollow, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + asFollow, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) UndoFollow(ctx context.Context, follow *gtsmodel.Follow) error { + // Populate model. + if err := f.state.DB.PopulateFollow(ctx, follow); err != nil { + return gtserror.Newf("error populating follow: %w", err) + } + + // Do nothing if both accounts are local. + if follow.Account.IsLocal() && + follow.TargetAccount.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(follow.Account.OutboxURI) + if err != nil { + return err + } + + targetAccountIRI, err := parseURI(follow.TargetAccount.URI) + if err != nil { + return err + } + + // Recreate the ActivityStreams Follow. + asFollow, err := f.tc.FollowToAS(ctx, follow) + if err != nil { + return gtserror.Newf("error converting follow to AS: %w", err) + } + + // Create a new Undo. + // todo: tc.FollowToASUndo + undo := streams.NewActivityStreamsUndo() + + // Set the Actor for the Undo: + // same as the actor for the Follow. + undo.SetActivityStreamsActor(asFollow.GetActivityStreamsActor()) + + // Set recreated Follow as the 'object' property. + // + // For most AP implementations, it's not enough + // to just send the URI of the original Follow, + // we have to send the whole object again. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsFollow(asFollow) + undo.SetActivityStreamsObject(undoObject) + + // Address the Undo To the target account. + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountIRI) + undo.SetActivityStreamsTo(undoTo) + + // Send the Undo via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, undo, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + undo, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) UndoLike(ctx context.Context, fave *gtsmodel.StatusFave) error { + // Populate model. + if err := f.state.DB.PopulateStatusFave(ctx, fave); err != nil { + return gtserror.Newf("error populating fave: %w", err) + } + + // Do nothing if both accounts are local. + if fave.Account.IsLocal() && + fave.TargetAccount.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(fave.Account.OutboxURI) + if err != nil { + return err + } + + targetAccountIRI, err := parseURI(fave.TargetAccount.URI) + if err != nil { + return err + } + + // Recreate the ActivityStreams Like. + like, err := f.tc.FaveToAS(ctx, fave) + if err != nil { + return gtserror.Newf("error converting fave to AS: %w", err) + } + + // Create a new Undo. + // todo: tc.FaveToASUndo + undo := streams.NewActivityStreamsUndo() + + // Set the Actor for the Undo: + // same as the actor for the Like. + undo.SetActivityStreamsActor(like.GetActivityStreamsActor()) + + // Set recreated Like as the 'object' property. + // + // For most AP implementations, it's not enough + // to just send the URI of the original Like, + // we have to send the whole object again. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsLike(like) + undo.SetActivityStreamsObject(undoObject) + + // Address the Undo To the target account. + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountIRI) + undo.SetActivityStreamsTo(undoTo) + + // Send the Undo via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, undo, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + undo, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) UndoAnnounce(ctx context.Context, boost *gtsmodel.Status) error { + // Populate model. + if err := f.state.DB.PopulateStatus(ctx, boost); err != nil { + return gtserror.Newf("error populating status: %w", err) + } + + // Do nothing if boosting + // account isn't ours. + if !boost.Account.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(boost.Account.OutboxURI) + if err != nil { + return err + } + + // Recreate the ActivityStreams Announce. + asAnnounce, err := f.tc.BoostToAS( + ctx, + boost, + boost.Account, + boost.BoostOfAccount, + ) + if err != nil { + return gtserror.Newf("error converting boost to AS: %w", err) + } + + // Create a new Undo. + // todo: tc.AnnounceToASUndo + undo := streams.NewActivityStreamsUndo() + + // Set the Actor for the Undo: + // same as the actor for the Announce. + undo.SetActivityStreamsActor(asAnnounce.GetActivityStreamsActor()) + + // Set recreated Announce as the 'object' property. + // + // For most AP implementations, it's not enough + // to just send the URI of the original Announce, + // we have to send the whole object again. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsAnnounce(asAnnounce) + undo.SetActivityStreamsObject(undoObject) + + // Address the Undo To the Announce To. + undo.SetActivityStreamsTo(asAnnounce.GetActivityStreamsTo()) + + // Address the Undo CC the Announce CC. + undo.SetActivityStreamsCc(asAnnounce.GetActivityStreamsCc()) + + // Send the Undo via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, undo, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + undo, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) AcceptFollow(ctx context.Context, follow *gtsmodel.Follow) error { + // Populate model. + if err := f.state.DB.PopulateFollow(ctx, follow); err != nil { + return gtserror.Newf("error populating follow: %w", err) + } + + // Bail if requesting account is ours: + // we've already accepted internally and + // shouldn't send an Accept to ourselves. + if follow.Account.IsLocal() { + return nil + } + + // Bail if target account isn't ours: + // we can't Accept a follow on + // another instance's behalf. + if follow.TargetAccount.IsRemote() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(follow.TargetAccount.OutboxURI) + if err != nil { + return err + } + + acceptingAccountIRI, err := parseURI(follow.TargetAccount.URI) + if err != nil { + return err + } + + requestingAccountIRI, err := parseURI(follow.Account.URI) + if err != nil { + return err + } + + // Recreate the ActivityStreams Follow. + asFollow, err := f.tc.FollowToAS(ctx, follow) + if err != nil { + return gtserror.Newf("error converting follow to AS: %w", err) + } + + // Create a new Accept. + // todo: tc.FollowToASAccept + accept := streams.NewActivityStreamsAccept() + + // Set the requestee as Actor of the Accept. + acceptActorProp := streams.NewActivityStreamsActorProperty() + acceptActorProp.AppendIRI(acceptingAccountIRI) + accept.SetActivityStreamsActor(acceptActorProp) + + // Set recreated Follow as the 'object' property. + // + // For most AP implementations, it's not enough + // to just send the URI of the original Follow, + // we have to send the whole object again. + acceptObject := streams.NewActivityStreamsObjectProperty() + acceptObject.AppendActivityStreamsFollow(asFollow) + accept.SetActivityStreamsObject(acceptObject) + + // Address the Accept To the Follow requester. + acceptTo := streams.NewActivityStreamsToProperty() + acceptTo.AppendIRI(requestingAccountIRI) + accept.SetActivityStreamsTo(acceptTo) + + // Send the Accept via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, accept, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + accept, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) RejectFollow(ctx context.Context, follow *gtsmodel.Follow) error { + // Ensure follow populated before proceeding. + if err := f.state.DB.PopulateFollow(ctx, follow); err != nil { + return gtserror.Newf("error populating follow: %w", err) + } + + // Bail if requesting account is ours: + // we've already rejected internally and + // shouldn't send an Reject to ourselves. + if follow.Account.IsLocal() { + return nil + } + + // Bail if target account isn't ours: + // we can't Reject a follow on + // another instance's behalf. + if follow.TargetAccount.IsRemote() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(follow.TargetAccount.OutboxURI) + if err != nil { + return err + } + + rejectingAccountIRI, err := parseURI(follow.TargetAccount.URI) + if err != nil { + return err + } + + requestingAccountIRI, err := parseURI(follow.Account.URI) + if err != nil { + return err + } + + // Recreate the ActivityStreams Follow. + asFollow, err := f.tc.FollowToAS(ctx, follow) + if err != nil { + return gtserror.Newf("error converting follow to AS: %w", err) + } + + // Create a new Reject. + // todo: tc.FollowRequestToASReject + reject := streams.NewActivityStreamsReject() + + // Set the requestee as Actor of the Reject. + rejectActorProp := streams.NewActivityStreamsActorProperty() + rejectActorProp.AppendIRI(rejectingAccountIRI) + reject.SetActivityStreamsActor(rejectActorProp) + + // Set recreated Follow as the 'object' property. + // + // For most AP implementations, it's not enough + // to just send the URI of the original Follow, + // we have to send the whole object again. + rejectObject := streams.NewActivityStreamsObjectProperty() + rejectObject.AppendActivityStreamsFollow(asFollow) + reject.SetActivityStreamsObject(rejectObject) + + // Address the Reject To the Follow requester. + rejectTo := streams.NewActivityStreamsToProperty() + rejectTo.AppendIRI(requestingAccountIRI) + reject.SetActivityStreamsTo(rejectTo) + + // Send the Reject via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, reject, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + reject, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error { + // Populate model. + if err := f.state.DB.PopulateStatusFave(ctx, fave); err != nil { + return gtserror.Newf("error populating fave: %w", err) + } + + // Do nothing if both accounts are local. + if fave.Account.IsLocal() && + fave.TargetAccount.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(fave.Account.OutboxURI) + if err != nil { + return err + } + + // Create the ActivityStreams Like. + like, err := f.tc.FaveToAS(ctx, fave) + if err != nil { + return gtserror.Newf("error converting fave to AS Like: %w", err) + } + + // Send the Like via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, like, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + like, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { + // Populate model. + if err := f.state.DB.PopulateStatus(ctx, boost); err != nil { + return gtserror.Newf("error populating status: %w", err) + } + + // Do nothing if boosting + // account isn't ours. + if !boost.Account.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(boost.Account.OutboxURI) + if err != nil { + return err + } + + // Create the ActivityStreams Announce. + announce, err := f.tc.BoostToAS( + ctx, + boost, + boost.Account, + boost.BoostOfAccount, + ) + if err != nil { + return gtserror.Newf("error converting boost to AS: %w", err) + } + + // Send the Announce via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, announce, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + announce, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) UpdateAccount(ctx context.Context, account *gtsmodel.Account) error { + // Populate model. + if err := f.state.DB.PopulateAccount(ctx, account); err != nil { + return gtserror.Newf("error populating account: %w", err) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(account.OutboxURI) + if err != nil { + return err + } + + // Convert account to ActivityStreams Person. + person, err := f.tc.AccountToAS(ctx, account) + if err != nil { + return gtserror.Newf("error converting account to Person: %w", err) + } + + // Use ActivityStreams Person as Object of Update. + update, err := f.tc.WrapPersonInUpdate(person, account) + if err != nil { + return gtserror.Newf("error wrapping Person in Update: %w", err) + } + + // Send the Update via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, update, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + update, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) Block(ctx context.Context, block *gtsmodel.Block) error { + // Populate model. + if err := f.state.DB.PopulateBlock(ctx, block); err != nil { + return gtserror.Newf("error populating block: %w", err) + } + + // Do nothing if both accounts are local. + if block.Account.IsLocal() && + block.TargetAccount.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(block.Account.OutboxURI) + if err != nil { + return err + } + + // Convert block to ActivityStreams Block. + asBlock, err := f.tc.BlockToAS(ctx, block) + if err != nil { + return gtserror.Newf("error converting block to AS: %w", err) + } + + // Send the Block via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, asBlock, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + asBlock, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) UndoBlock(ctx context.Context, block *gtsmodel.Block) error { + // Populate model. + if err := f.state.DB.PopulateBlock(ctx, block); err != nil { + return gtserror.Newf("error populating block: %w", err) + } + + // Do nothing if both accounts are local. + if block.Account.IsLocal() && + block.TargetAccount.IsLocal() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(block.Account.OutboxURI) + if err != nil { + return err + } + + targetAccountIRI, err := parseURI(block.TargetAccount.URI) + if err != nil { + return err + } + + // Convert block to ActivityStreams Block. + asBlock, err := f.tc.BlockToAS(ctx, block) + if err != nil { + return gtserror.Newf("error converting block to AS: %w", err) + } + + // Create a new Undo. + // todo: tc.BlockToASUndo + undo := streams.NewActivityStreamsUndo() + + // Set the Actor for the Undo: + // same as the actor for the Block. + undo.SetActivityStreamsActor(asBlock.GetActivityStreamsActor()) + + // Set Block as the 'object' property. + // + // For most AP implementations, it's not enough + // to just send the URI of the original Block, + // we have to send the whole object again. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsBlock(asBlock) + undo.SetActivityStreamsObject(undoObject) + + // Address the Undo To the target account. + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountIRI) + undo.SetActivityStreamsTo(undoTo) + + // Send the Undo via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, undo, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + undo, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) Flag(ctx context.Context, report *gtsmodel.Report) error { + // Populate model. + if err := f.state.DB.PopulateReport(ctx, report); err != nil { + return gtserror.Newf("error populating report: %w", err) + } + + // Do nothing if report target + // is not remote account. + if report.TargetAccount.IsLocal() { + return nil + } + + // Get our instance account from the db: + // to anonymize the report, we'll deliver + // using the outbox of the instance account. + instanceAcct, err := f.state.DB.GetInstanceAccount(ctx, "") + if err != nil { + return gtserror.Newf("error getting instance account: %w", err) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(instanceAcct.OutboxURI) + if err != nil { + return err + } + + targetAccountIRI, err := parseURI(report.TargetAccount.URI) + if err != nil { + return err + } + + // Convert report to ActivityStreams Flag. + flag, err := f.tc.ReportToASFlag(ctx, report) + if err != nil { + return gtserror.Newf("error converting report to AS: %w", err) + } + + // To is not set explicitly on Flags. Instead, + // address Flag BTo report target account URI. + // This ensures that our federating actor still + // knows where to send the report, but the BTo + // property will be stripped before sending. + // + // Happily, BTo does not prevent federating + // actor from using shared inbox to deliver. + bTo := streams.NewActivityStreamsBtoProperty() + bTo.AppendIRI(targetAccountIRI) + flag.SetActivityStreamsBto(bTo) + + // Send the Flag via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, flag, + ); err != nil { + return gtserror.Newf( + "error sending activity %T via outbox %s: %w", + flag, outboxIRI, err, + ) + } + + return nil +} diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go new file mode 100644 index 000000000..40efc20bb --- /dev/null +++ b/internal/processing/workers/fromclientapi.go @@ -0,0 +1,548 @@ +// 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 workers + +import ( + "context" + "errors" + + "codeberg.org/gruf/go-kv" + "codeberg.org/gruf/go-logger/v2/level" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "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" + "github.com/superseriousbusiness/gotosocial/internal/processing/account" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// clientAPI wraps processing functions +// specifically for messages originating +// from the client/REST API. +type clientAPI struct { + state *state.State + tc typeutils.TypeConverter + surface *surface + federate *federate + wipeStatus wipeStatus + account *account.Processor +} + +func (p *Processor) EnqueueClientAPI(ctx context.Context, msgs ...messages.FromClientAPI) { + log.Trace(ctx, "enqueuing") + _ = p.workers.ClientAPI.MustEnqueueCtx(ctx, func(ctx context.Context) { + for _, msg := range msgs { + log.Trace(ctx, "processing: %+v", msg) + if err := p.ProcessFromClientAPI(ctx, msg); err != nil { + log.Errorf(ctx, "error processing client API message: %v", err) + } + } + }) +} + +func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.FromClientAPI) error { + // Allocate new log fields slice + fields := make([]kv.Field, 3, 4) + fields[0] = kv.Field{"activityType", cMsg.APActivityType} + fields[1] = kv.Field{"objectType", cMsg.APObjectType} + fields[2] = kv.Field{"fromAccount", cMsg.OriginAccount.Username} + + // Include GTSModel in logs if appropriate. + if cMsg.GTSModel != nil && + log.Level() >= level.DEBUG { + fields = append(fields, kv.Field{ + "model", cMsg.GTSModel, + }) + } + + l := log.WithContext(ctx).WithFields(fields...) + l.Info("processing from client API") + + switch cMsg.APActivityType { + + // CREATE SOMETHING + case ap.ActivityCreate: + switch cMsg.APObjectType { + + // CREATE PROFILE/ACCOUNT + case ap.ObjectProfile, ap.ActorPerson: + return p.clientAPI.CreateAccount(ctx, cMsg) + + // CREATE NOTE/STATUS + case ap.ObjectNote: + return p.clientAPI.CreateStatus(ctx, cMsg) + + // CREATE FOLLOW (request) + case ap.ActivityFollow: + return p.clientAPI.CreateFollowReq(ctx, cMsg) + + // CREATE LIKE/FAVE + case ap.ActivityLike: + return p.clientAPI.CreateLike(ctx, cMsg) + + // CREATE ANNOUNCE/BOOST + case ap.ActivityAnnounce: + return p.clientAPI.CreateAnnounce(ctx, cMsg) + + // CREATE BLOCK + case ap.ActivityBlock: + return p.clientAPI.CreateBlock(ctx, cMsg) + } + + // UPDATE SOMETHING + case ap.ActivityUpdate: + switch cMsg.APObjectType { + + // UPDATE PROFILE/ACCOUNT + case ap.ObjectProfile, ap.ActorPerson: + return p.clientAPI.UpdateAccount(ctx, cMsg) + + // UPDATE A FLAG/REPORT (mark as resolved/closed) + case ap.ActivityFlag: + return p.clientAPI.UpdateReport(ctx, cMsg) + } + + // ACCEPT SOMETHING + case ap.ActivityAccept: + switch cMsg.APObjectType { //nolint:gocritic + + // ACCEPT FOLLOW (request) + case ap.ActivityFollow: + return p.clientAPI.AcceptFollow(ctx, cMsg) + } + + // REJECT SOMETHING + case ap.ActivityReject: + switch cMsg.APObjectType { //nolint:gocritic + + // REJECT FOLLOW (request) + case ap.ActivityFollow: + return p.clientAPI.RejectFollowRequest(ctx, cMsg) + } + + // UNDO SOMETHING + case ap.ActivityUndo: + switch cMsg.APObjectType { + + // UNDO FOLLOW (request) + case ap.ActivityFollow: + return p.clientAPI.UndoFollow(ctx, cMsg) + + // UNDO BLOCK + case ap.ActivityBlock: + return p.clientAPI.UndoBlock(ctx, cMsg) + + // UNDO LIKE/FAVE + case ap.ActivityLike: + return p.clientAPI.UndoFave(ctx, cMsg) + + // UNDO ANNOUNCE/BOOST + case ap.ActivityAnnounce: + return p.clientAPI.UndoAnnounce(ctx, cMsg) + } + + // DELETE SOMETHING + case ap.ActivityDelete: + switch cMsg.APObjectType { + + // DELETE NOTE/STATUS + case ap.ObjectNote: + return p.clientAPI.DeleteStatus(ctx, cMsg) + + // DELETE PROFILE/ACCOUNT + case ap.ObjectProfile, ap.ActorPerson: + return p.clientAPI.DeleteAccount(ctx, cMsg) + } + + // FLAG/REPORT SOMETHING + case ap.ActivityFlag: + switch cMsg.APObjectType { //nolint:gocritic + + // FLAG/REPORT A PROFILE + case ap.ObjectProfile: + return p.clientAPI.ReportAccount(ctx, cMsg) + } + } + + return nil +} + +func (p *clientAPI) CreateAccount(ctx context.Context, cMsg messages.FromClientAPI) error { + account, ok := cMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Account", cMsg.GTSModel) + } + + // Send a confirmation email to the newly created account. + user, err := p.state.DB.GetUserByAccountID(ctx, account.ID) + if err != nil { + return gtserror.Newf("db error getting user for account id %s: %w", account.ID, err) + } + + if err := p.surface.emailPleaseConfirm(ctx, user, account.Username); err != nil { + return gtserror.Newf("error emailing %s: %w", account.Username, err) + } + + return nil +} + +func (p *clientAPI) CreateStatus(ctx context.Context, cMsg messages.FromClientAPI) error { + status, ok := cMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) + } + + if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + return gtserror.Newf("error timelining status: %w", err) + } + + if status.InReplyToID != "" { + // Interaction counts changed on the replied status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + } + + if err := p.federate.CreateStatus(ctx, status); err != nil { + return gtserror.Newf("error federating status: %w", err) + } + + return nil +} + +func (p *clientAPI) CreateFollowReq(ctx context.Context, cMsg messages.FromClientAPI) error { + followRequest, ok := cMsg.GTSModel.(*gtsmodel.FollowRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.FollowRequest", cMsg.GTSModel) + } + + if err := p.surface.notifyFollowRequest(ctx, followRequest); err != nil { + return gtserror.Newf("error notifying follow request: %w", err) + } + + if err := p.federate.Follow( + ctx, + p.tc.FollowRequestToFollow(ctx, followRequest), + ); err != nil { + return gtserror.Newf("error federating follow: %w", err) + } + + return nil +} + +func (p *clientAPI) CreateLike(ctx context.Context, cMsg messages.FromClientAPI) error { + fave, ok := cMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", cMsg.GTSModel) + } + + if err := p.surface.notifyFave(ctx, fave); err != nil { + return gtserror.Newf("error notifying fave: %w", err) + } + + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID) + + if err := p.federate.Like(ctx, fave); err != nil { + return gtserror.Newf("error federating like: %w", err) + } + + return nil +} + +func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg messages.FromClientAPI) error { + boost, ok := cMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) + } + + // Timeline and notify the boost wrapper status. + if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { + return gtserror.Newf("error timelining boost: %w", err) + } + + // Notify the boost target account. + if err := p.surface.notifyAnnounce(ctx, boost); err != nil { + return gtserror.Newf("error notifying boost: %w", err) + } + + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + + if err := p.federate.Announce(ctx, boost); err != nil { + return gtserror.Newf("error federating announce: %w", err) + } + + return nil +} + +func (p *clientAPI) CreateBlock(ctx context.Context, cMsg messages.FromClientAPI) error { + block, ok := cMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Block", cMsg.GTSModel) + } + + // Remove blockee's statuses from blocker's timeline. + if err := p.state.Timelines.Home.WipeItemsFromAccountID( + ctx, + block.AccountID, + block.TargetAccountID, + ); err != nil { + return gtserror.Newf("error wiping timeline items for block: %w", err) + } + + // Remove blocker's statuses from blockee's timeline. + if err := p.state.Timelines.Home.WipeItemsFromAccountID( + ctx, + block.TargetAccountID, + block.AccountID, + ); err != nil { + return gtserror.Newf("error wiping timeline items for block: %w", err) + } + + // TODO: same with notifications? + // TODO: same with bookmarks? + + if err := p.federate.Block(ctx, block); err != nil { + return gtserror.Newf("error federating block: %w", err) + } + + return nil +} + +func (p *clientAPI) UpdateAccount(ctx context.Context, cMsg messages.FromClientAPI) error { + account, ok := cMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Account", cMsg.GTSModel) + } + + if err := p.federate.UpdateAccount(ctx, account); err != nil { + return gtserror.Newf("error federating account update: %w", err) + } + + return nil +} + +func (p *clientAPI) UpdateReport(ctx context.Context, cMsg messages.FromClientAPI) error { + report, ok := cMsg.GTSModel.(*gtsmodel.Report) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Report", cMsg.GTSModel) + } + + if report.Account.IsRemote() { + // Report creator is a remote account, + // we shouldn't try to email them! + return nil + } + + if err := p.surface.emailReportClosed(ctx, report); err != nil { + return gtserror.Newf("error sending report closed email: %w", err) + } + + return nil +} + +func (p *clientAPI) AcceptFollow(ctx context.Context, cMsg messages.FromClientAPI) error { + follow, ok := cMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Follow", cMsg.GTSModel) + } + + if err := p.surface.notifyFollow(ctx, follow); err != nil { + return gtserror.Newf("error notifying follow: %w", err) + } + + if err := p.federate.AcceptFollow(ctx, follow); err != nil { + return gtserror.Newf("error federating follow request accept: %w", err) + } + + return nil +} + +func (p *clientAPI) RejectFollowRequest(ctx context.Context, cMsg messages.FromClientAPI) error { + followReq, ok := cMsg.GTSModel.(*gtsmodel.FollowRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.FollowRequest", cMsg.GTSModel) + } + + if err := p.federate.RejectFollow( + ctx, + p.tc.FollowRequestToFollow(ctx, followReq), + ); err != nil { + return gtserror.Newf("error federating reject follow: %w", err) + } + + return nil +} + +func (p *clientAPI) UndoFollow(ctx context.Context, cMsg messages.FromClientAPI) error { + follow, ok := cMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Follow", cMsg.GTSModel) + } + + if err := p.federate.UndoFollow(ctx, follow); err != nil { + return gtserror.Newf("error federating undo follow: %w", err) + } + + return nil +} + +func (p *clientAPI) UndoBlock(ctx context.Context, cMsg messages.FromClientAPI) error { + block, ok := cMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Block", cMsg.GTSModel) + } + + if err := p.federate.UndoBlock(ctx, block); err != nil { + return gtserror.Newf("error federating undo block: %w", err) + } + + return nil +} + +func (p *clientAPI) UndoFave(ctx context.Context, cMsg messages.FromClientAPI) error { + statusFave, ok := cMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", cMsg.GTSModel) + } + + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, statusFave.StatusID) + + if err := p.federate.UndoLike(ctx, statusFave); err != nil { + return gtserror.Newf("error federating undo like: %w", err) + } + + return nil +} + +func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg messages.FromClientAPI) error { + status, ok := cMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) + } + + if err := p.state.DB.DeleteStatusByID(ctx, status.ID); err != nil { + return gtserror.Newf("db error deleting status: %w", err) + } + + if err := p.surface.deleteStatusFromTimelines(ctx, status.ID); err != nil { + return gtserror.Newf("error removing status from timelines: %w", err) + } + + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID) + + if err := p.federate.UndoAnnounce(ctx, status); err != nil { + return gtserror.Newf("error federating undo announce: %w", err) + } + + return nil +} + +func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg messages.FromClientAPI) error { + // Don't delete attachments, just unattach them: + // this request comes from the client API and the + // poster may want to use attachments again later. + const deleteAttachments = false + + status, ok := cMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) + } + + // Try to populate status structs if possible, + // in order to more thoroughly remove them. + if err := p.state.DB.PopulateStatus( + ctx, status, + ); err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("db error populating status: %w", err) + } + + if err := p.wipeStatus(ctx, status, deleteAttachments); err != nil { + return gtserror.Newf("error wiping status: %w", err) + } + + if status.InReplyToID != "" { + // Interaction counts changed on the replied status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + } + + if err := p.federate.DeleteStatus(ctx, status); err != nil { + return gtserror.Newf("error federating status delete: %w", err) + } + + return nil +} + +func (p *clientAPI) DeleteAccount(ctx context.Context, cMsg messages.FromClientAPI) error { + // The originID of the delete, one of: + // - ID of a domain block, for which + // this account delete is a side effect. + // - ID of the deleted account itself (self delete). + // - ID of an admin account (account suspension). + var originID string + + if domainBlock, ok := cMsg.GTSModel.(*gtsmodel.DomainBlock); ok { + // Origin is a domain block. + originID = domainBlock.ID + } else { + // Origin is whichever account + // originated this message. + originID = cMsg.OriginAccount.ID + } + + if err := p.federate.DeleteAccount(ctx, cMsg.TargetAccount); err != nil { + return gtserror.Newf("error federating account delete: %w", err) + } + + if err := p.account.Delete(ctx, cMsg.TargetAccount, originID); err != nil { + return gtserror.Newf("error deleting account: %w", err) + } + + return nil +} + +func (p *clientAPI) ReportAccount(ctx context.Context, cMsg messages.FromClientAPI) error { + report, ok := cMsg.GTSModel.(*gtsmodel.Report) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Report", cMsg.GTSModel) + } + + // Federate this report to the + // remote instance if desired. + if *report.Forwarded { + if err := p.federate.Flag(ctx, report); err != nil { + return gtserror.Newf("error federating report: %w", err) + } + } + + if err := p.surface.emailReportOpened(ctx, report); err != nil { + return gtserror.Newf("error sending report opened email: %w", err) + } + + return nil +} diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go new file mode 100644 index 000000000..6690a43db --- /dev/null +++ b/internal/processing/workers/fromclientapi_test.go @@ -0,0 +1,589 @@ +// 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 workers_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/stream" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FromClientAPITestSuite struct { + WorkersTestSuite +} + +func (suite *FromClientAPITestSuite) newStatus( + ctx context.Context, + account *gtsmodel.Account, + visibility gtsmodel.Visibility, + replyToStatus *gtsmodel.Status, + boostOfStatus *gtsmodel.Status, +) *gtsmodel.Status { + var ( + protocol = config.GetProtocol() + host = config.GetHost() + statusID = id.NewULID() + ) + + // Make a new status from given account. + newStatus := >smodel.Status{ + ID: statusID, + URI: protocol + "://" + host + "/users/" + account.Username + "/statuses/" + statusID, + URL: protocol + "://" + host + "/@" + account.Username + "/statuses/" + statusID, + Content: "pee pee poo poo", + Local: util.Ptr(true), + AccountURI: account.URI, + AccountID: account.ID, + Visibility: visibility, + ActivityStreamsType: ap.ObjectNote, + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + } + + if replyToStatus != nil { + // Status is a reply. + newStatus.InReplyToAccountID = replyToStatus.AccountID + newStatus.InReplyToID = replyToStatus.ID + newStatus.InReplyToURI = replyToStatus.URI + + // Mention the replied-to account. + mention := >smodel.Mention{ + ID: id.NewULID(), + StatusID: statusID, + OriginAccountID: account.ID, + OriginAccountURI: account.URI, + TargetAccountID: replyToStatus.AccountID, + } + + if err := suite.db.PutMention(ctx, mention); err != nil { + suite.FailNow(err.Error()) + } + newStatus.Mentions = []*gtsmodel.Mention{mention} + newStatus.MentionIDs = []string{mention.ID} + } + + if boostOfStatus != nil { + // Status is a boost. + + } + + // Put the status in the db, to mimic what would + // have already happened earlier up the flow. + if err := suite.db.PutStatus(ctx, newStatus); err != nil { + suite.FailNow(err.Error()) + } + + return newStatus +} + +func (suite *FromClientAPITestSuite) checkStreamed( + str *stream.Stream, + expectMessage bool, + expectPayload string, + expectEventType string, +) { + var msg *stream.Message +streamLoop: + for { + select { + case msg = <-str.Messages: + break streamLoop // Got it. + case <-time.After(5 * time.Second): + break streamLoop // Didn't get it. + } + } + + if expectMessage && msg == nil { + suite.FailNow("expected a message but message was nil") + } + + if !expectMessage && msg != nil { + suite.FailNow("expected no message but message was not nil") + } + + if expectPayload != "" && msg.Payload != expectPayload { + suite.FailNow("", "expected payload %s but payload was: %s", expectPayload, msg.Payload) + } + + if expectEventType != "" && msg.Event != expectEventType { + suite.FailNow("", "expected event type %s but event type was: %s", expectEventType, msg.Event) + } +} + +func (suite *FromClientAPITestSuite) statusJSON( + ctx context.Context, + status *gtsmodel.Status, + requestingAccount *gtsmodel.Account, +) string { + apiStatus, err := suite.typeconverter.StatusToAPIStatus( + ctx, + status, + requestingAccount, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + statusJSON, err := json.Marshal(apiStatus) + if err != nil { + suite.FailNow(err.Error()) + } + + return string(statusJSON) +} + +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_1"] + testList = suite.testLists["local_account_1_list_1"] + streams = suite.openStreams(ctx, receivingAccount, []string{testList.ID}) + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + notifStream = streams[stream.TimelineNotifications] + + // Admin account posts a new top-level status. + status = suite.newStatus( + ctx, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + ) + statusJSON = suite.statusJSON( + ctx, + status, + receivingAccount, + ) + ) + + // Update the follow from receiving account -> posting account so + // that receiving account wants notifs when posting account posts. + follow := new(gtsmodel.Follow) + *follow = *suite.testFollows["local_account_1_admin_account"] + + follow.Notify = util.Ptr(true) + if err := suite.db.UpdateFollow(ctx, follow); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := suite.processor.Workers().ProcessFromClientAPI( + ctx, + messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + OriginAccount: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check message in home stream. + suite.checkStreamed( + homeStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) + + // Check message in list stream. + suite.checkStreamed( + listStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) + + // Wait for a notification to appear for the status. + var notif *gtsmodel.Notification + if !testrig.WaitFor(func() bool { + var err error + notif, err = suite.db.GetNotification( + ctx, + gtsmodel.NotificationStatus, + receivingAccount.ID, + postingAccount.ID, + status.ID, + ) + return err == nil + }) { + suite.FailNow("timed out waiting for new status notification") + } + + apiNotif, err := suite.typeconverter.NotificationToAPINotification(ctx, notif) + if err != nil { + suite.FailNow(err.Error()) + } + + notifJSON, err := json.Marshal(apiNotif) + if err != nil { + suite.FailNow(err.Error()) + } + + // Check message in notification stream. + suite.checkStreamed( + notifStream, + true, + string(notifJSON), + stream.EventTypeNotification, + ) +} + +func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_1"] + testList = suite.testLists["local_account_1_list_1"] + streams = suite.openStreams(ctx, receivingAccount, []string{testList.ID}) + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + + // Admin account posts a reply to turtle. + // Since turtle is followed by zork, and + // the default replies policy for this list + // is to show replies to followed accounts, + // post should also show in the list stream. + status = suite.newStatus( + ctx, + postingAccount, + gtsmodel.VisibilityPublic, + suite.testStatuses["local_account_2_status_1"], + nil, + ) + statusJSON = suite.statusJSON( + ctx, + status, + receivingAccount, + ) + ) + + // Process the new status. + if err := suite.processor.Workers().ProcessFromClientAPI( + ctx, + messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + OriginAccount: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check message in home stream. + suite.checkStreamed( + homeStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) + + // Check message in list stream. + suite.checkStreamed( + listStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) +} + +func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyOK() { + // We're modifying the test list so take a copy. + testList := new(gtsmodel.List) + *testList = *suite.testLists["local_account_1_list_1"] + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_1"] + streams = suite.openStreams(ctx, receivingAccount, []string{testList.ID}) + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + + // Admin account posts a reply to turtle. + status = suite.newStatus( + ctx, + postingAccount, + gtsmodel.VisibilityPublic, + suite.testStatuses["local_account_2_status_1"], + nil, + ) + statusJSON = suite.statusJSON( + ctx, + status, + receivingAccount, + ) + ) + + // Modify replies policy of test list to show replies + // only to other accounts in the same list. Since turtle + // and admin are in the same list, this means the reply + // should be shown in the list. + testList.RepliesPolicy = gtsmodel.RepliesPolicyList + if err := suite.db.UpdateList(ctx, testList, "replies_policy"); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := suite.processor.Workers().ProcessFromClientAPI( + ctx, + messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + OriginAccount: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check message in home stream. + suite.checkStreamed( + homeStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) + + // Check message in list stream. + suite.checkStreamed( + listStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) +} + +func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyNo() { + // We're modifying the test list so take a copy. + testList := new(gtsmodel.List) + *testList = *suite.testLists["local_account_1_list_1"] + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_1"] + streams = suite.openStreams(ctx, receivingAccount, []string{testList.ID}) + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + + // Admin account posts a reply to turtle. + status = suite.newStatus( + ctx, + postingAccount, + gtsmodel.VisibilityPublic, + suite.testStatuses["local_account_2_status_1"], + nil, + ) + statusJSON = suite.statusJSON( + ctx, + status, + receivingAccount, + ) + ) + + // Modify replies policy of test list to show replies + // only to other accounts in the same list. We're + // about to remove turtle from the same list as admin, + // so the new post should not be streamed to the list. + testList.RepliesPolicy = gtsmodel.RepliesPolicyList + if err := suite.db.UpdateList(ctx, testList, "replies_policy"); err != nil { + suite.FailNow(err.Error()) + } + + // Remove turtle from the list. + if err := suite.db.DeleteListEntry(ctx, suite.testListEntries["local_account_1_list_1_entry_1"].ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := suite.processor.Workers().ProcessFromClientAPI( + ctx, + messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + OriginAccount: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check message in home stream. + suite.checkStreamed( + homeStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) + + // Check message NOT in list stream. + suite.checkStreamed( + listStream, + false, + "", + "", + ) +} + +func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPolicyNone() { + // We're modifying the test list so take a copy. + testList := new(gtsmodel.List) + *testList = *suite.testLists["local_account_1_list_1"] + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_1"] + streams = suite.openStreams(ctx, receivingAccount, []string{testList.ID}) + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + + // Admin account posts a reply to turtle. + status = suite.newStatus( + ctx, + postingAccount, + gtsmodel.VisibilityPublic, + suite.testStatuses["local_account_2_status_1"], + nil, + ) + statusJSON = suite.statusJSON( + ctx, + status, + receivingAccount, + ) + ) + + // Modify replies policy of test list. + // Since we're modifying the list to not + // show any replies, the post should not + // be streamed to the list. + testList.RepliesPolicy = gtsmodel.RepliesPolicyNone + if err := suite.db.UpdateList(ctx, testList, "replies_policy"); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := suite.processor.Workers().ProcessFromClientAPI( + ctx, + messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + OriginAccount: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check message in home stream. + suite.checkStreamed( + homeStream, + true, + statusJSON, + stream.EventTypeUpdate, + ) + + // Check message NOT in list stream. + suite.checkStreamed( + listStream, + false, + "", + "", + ) +} + +func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { + var ( + ctx = context.Background() + deletingAccount = suite.testAccounts["local_account_1"] + receivingAccount = suite.testAccounts["local_account_2"] + deletedStatus = suite.testStatuses["local_account_1_status_1"] + boostOfDeletedStatus = suite.testStatuses["admin_account_status_4"] + streams = suite.openStreams(ctx, receivingAccount, nil) + homeStream = streams[stream.TimelineHome] + ) + + // Delete the status from the db first, to mimic what + // would have already happened earlier up the flow + if err := suite.db.DeleteStatusByID(ctx, deletedStatus.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the status delete. + if err := suite.processor.Workers().ProcessFromClientAPI( + ctx, + messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityDelete, + GTSModel: deletedStatus, + OriginAccount: deletingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Stream should have the delete + // of admin's boost in it now. + suite.checkStreamed( + homeStream, + true, + boostOfDeletedStatus.ID, + stream.EventTypeDelete, + ) + + // Stream should also have the delete + // of the message itself in it. + suite.checkStreamed( + homeStream, + true, + deletedStatus.ID, + stream.EventTypeDelete, + ) + + // Boost should no longer be in the database. + if !testrig.WaitFor(func() bool { + _, err := suite.db.GetStatusByID(ctx, boostOfDeletedStatus.ID) + return errors.Is(err, db.ErrNoEntries) + }) { + suite.FailNow("timed out waiting for status delete") + } +} + +func TestFromClientAPITestSuite(t *testing.T) { + suite.Run(t, &FromClientAPITestSuite{}) +} diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go new file mode 100644 index 000000000..5fbb0066b --- /dev/null +++ b/internal/processing/workers/fromfediapi.go @@ -0,0 +1,540 @@ +// 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 workers + +import ( + "context" + "net/url" + + "codeberg.org/gruf/go-kv" + "codeberg.org/gruf/go-logger/v2/level" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/processing/account" + "github.com/superseriousbusiness/gotosocial/internal/state" +) + +// fediAPI wraps processing functions +// specifically for messages originating +// from the federation/ActivityPub API. +type fediAPI struct { + state *state.State + surface *surface + federate *federate + wipeStatus wipeStatus + account *account.Processor +} + +func (p *Processor) EnqueueFediAPI(ctx context.Context, msgs ...messages.FromFediAPI) { + log.Trace(ctx, "enqueuing") + _ = p.workers.Federator.MustEnqueueCtx(ctx, func(ctx context.Context) { + for _, msg := range msgs { + log.Trace(ctx, "processing: %+v", msg) + if err := p.ProcessFromFediAPI(ctx, msg); err != nil { + log.Errorf(ctx, "error processing fedi API message: %v", err) + } + } + }) +} + +func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg messages.FromFediAPI) error { + // Allocate new log fields slice + fields := make([]kv.Field, 3, 5) + fields[0] = kv.Field{"activityType", fMsg.APActivityType} + fields[1] = kv.Field{"objectType", fMsg.APObjectType} + fields[2] = kv.Field{"toAccount", fMsg.ReceivingAccount.Username} + + if fMsg.APIri != nil { + // An IRI was supplied, append to log + fields = append(fields, kv.Field{ + "iri", fMsg.APIri, + }) + } + + // Include GTSModel in logs if appropriate. + if fMsg.GTSModel != nil && + log.Level() >= level.DEBUG { + fields = append(fields, kv.Field{ + "model", fMsg.GTSModel, + }) + } + + l := log.WithContext(ctx).WithFields(fields...) + l.Info("processing from fedi API") + + switch fMsg.APActivityType { + + // CREATE SOMETHING + case ap.ActivityCreate: + switch fMsg.APObjectType { + + // CREATE NOTE/STATUS + case ap.ObjectNote: + return p.fediAPI.CreateStatus(ctx, fMsg) + + // CREATE FOLLOW (request) + case ap.ActivityFollow: + return p.fediAPI.CreateFollowReq(ctx, fMsg) + + // CREATE LIKE/FAVE + case ap.ActivityLike: + return p.fediAPI.CreateLike(ctx, fMsg) + + // CREATE ANNOUNCE/BOOST + case ap.ActivityAnnounce: + return p.fediAPI.CreateAnnounce(ctx, fMsg) + + // CREATE BLOCK + case ap.ActivityBlock: + return p.fediAPI.CreateBlock(ctx, fMsg) + + // CREATE FLAG/REPORT + case ap.ActivityFlag: + return p.fediAPI.CreateFlag(ctx, fMsg) + } + + // UPDATE SOMETHING + case ap.ActivityUpdate: + switch fMsg.APObjectType { //nolint:gocritic + + // UPDATE PROFILE/ACCOUNT + case ap.ObjectProfile: + return p.fediAPI.UpdateAccount(ctx, fMsg) + } + + // DELETE SOMETHING + case ap.ActivityDelete: + switch fMsg.APObjectType { + + // DELETE NOTE/STATUS + case ap.ObjectNote: + return p.fediAPI.DeleteStatus(ctx, fMsg) + + // DELETE PROFILE/ACCOUNT + case ap.ObjectProfile: + return p.fediAPI.DeleteAccount(ctx, fMsg) + } + } + + return nil +} + +func (p *fediAPI) CreateStatus(ctx context.Context, fMsg messages.FromFediAPI) error { + var ( + status *gtsmodel.Status + err error + + // Check the federatorMsg for either an already dereferenced + // and converted status pinned to the message, or a forwarded + // AP IRI that we still need to deref. + forwarded = (fMsg.GTSModel == nil) + ) + + if forwarded { + // Model was not set, deref with IRI. + // This will also cause the status to be inserted into the db. + status, err = p.statusFromAPIRI(ctx, fMsg) + } else { + // Model is set, ensure we have the most up-to-date model. + status, err = p.statusFromGTSModel(ctx, fMsg) + } + + if err != nil { + return gtserror.Newf("error extracting status from federatorMsg: %w", err) + } + + if status.Account == nil || status.Account.IsRemote() { + // Either no account attached yet, or a remote account. + // Both situations we need to parse account URI to fetch it. + accountURI, err := url.Parse(status.AccountURI) + if err != nil { + return err + } + + // Ensure that account for this status has been deref'd. + status.Account, _, err = p.federate.GetAccountByURI( + ctx, + fMsg.ReceivingAccount.Username, + accountURI, + ) + if err != nil { + return err + } + } + + // Ensure status ancestors dereferenced. We need at least the + // immediate parent (if present) to ascertain timelineability. + if err := p.federate.DereferenceStatusAncestors( + ctx, + fMsg.ReceivingAccount.Username, + status, + ); err != nil { + return err + } + + if status.InReplyToID != "" { + // Interaction counts changed on the replied status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + } + + if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + return gtserror.Newf("error timelining status: %w", err) + } + + return nil +} + +func (p *fediAPI) statusFromGTSModel(ctx context.Context, fMsg messages.FromFediAPI) (*gtsmodel.Status, error) { + // There should be a status pinned to the message: + // we've already checked to ensure this is not nil. + status, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + err := gtserror.New("Note was not parseable as *gtsmodel.Status") + return nil, err + } + + // AP statusable representation may have also + // been set on message (no problem if not). + statusable, _ := fMsg.APObjectModel.(ap.Statusable) + + // Call refresh on status to update + // it (deref remote) if necessary. + var err error + status, _, err = p.federate.RefreshStatus( + ctx, + fMsg.ReceivingAccount.Username, + status, + statusable, + false, // Don't force refresh. + ) + if err != nil { + return nil, gtserror.Newf("%w", err) + } + + return status, nil +} + +func (p *fediAPI) statusFromAPIRI(ctx context.Context, fMsg messages.FromFediAPI) (*gtsmodel.Status, error) { + // There should be a status IRI pinned to + // the federatorMsg for us to dereference. + if fMsg.APIri == nil { + err := gtserror.New( + "status was not pinned to federatorMsg, " + + "and neither was an IRI for us to dereference", + ) + return nil, err + } + + // Get the status + ensure we have + // the most up-to-date version. + status, _, err := p.federate.GetStatusByURI( + ctx, + fMsg.ReceivingAccount.Username, + fMsg.APIri, + ) + if err != nil { + return nil, gtserror.Newf("%w", err) + } + + return status, nil +} + +func (p *fediAPI) CreateFollowReq(ctx context.Context, fMsg messages.FromFediAPI) error { + followRequest, ok := fMsg.GTSModel.(*gtsmodel.FollowRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.FollowRequest", fMsg.GTSModel) + } + + if *followRequest.TargetAccount.Locked { + // Account on our instance is locked: + // just notify the follow request. + if err := p.surface.notifyFollowRequest(ctx, followRequest); err != nil { + return gtserror.Newf("error notifying follow request: %w", err) + } + + return nil + } + + // Account on our instance is not locked: + // Automatically accept the follow request + // and notify about the new follower. + follow, err := p.state.DB.AcceptFollowRequest( + ctx, + followRequest.AccountID, + followRequest.TargetAccountID, + ) + if err != nil { + return gtserror.Newf("error accepting follow request: %w", err) + } + + if err := p.federate.AcceptFollow(ctx, follow); err != nil { + return gtserror.Newf("error federating accept follow request: %w", err) + } + + if err := p.surface.notifyFollow(ctx, follow); err != nil { + return gtserror.Newf("error notifying follow: %w", err) + } + + return nil +} + +func (p *fediAPI) CreateLike(ctx context.Context, fMsg messages.FromFediAPI) error { + fave, ok := fMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", fMsg.GTSModel) + } + + if err := p.surface.notifyFave(ctx, fave); err != nil { + return gtserror.Newf("error notifying fave: %w", err) + } + + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID) + + return nil +} + +func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg messages.FromFediAPI) error { + status, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) + } + + // Dereference status that this status boosts. + if err := p.federate.DereferenceAnnounce( + ctx, + status, + fMsg.ReceivingAccount.Username, + ); err != nil { + return gtserror.Newf("error dereferencing announce: %w", err) + } + + // Generate an ID for the boost wrapper status. + statusID, err := id.NewULIDFromTime(status.CreatedAt) + if err != nil { + return gtserror.Newf("error generating id: %w", err) + } + status.ID = statusID + + // Store the boost wrapper status. + if err := p.state.DB.PutStatus(ctx, status); err != nil { + return gtserror.Newf("db error inserting status: %w", err) + } + + // Ensure boosted status ancestors dereferenced. We need at least + // the immediate parent (if present) to ascertain timelineability. + if err := p.federate.DereferenceStatusAncestors(ctx, + fMsg.ReceivingAccount.Username, + status.BoostOf, + ); err != nil { + return err + } + + // Timeline and notify the announce. + if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + return gtserror.Newf("error timelining status: %w", err) + } + + if err := p.surface.notifyAnnounce(ctx, status); err != nil { + return gtserror.Newf("error notifying status: %w", err) + } + + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.ID) + + return nil +} + +func (p *fediAPI) CreateBlock(ctx context.Context, fMsg messages.FromFediAPI) error { + block, ok := fMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Block", fMsg.GTSModel) + } + + // Remove each account's posts from the other's timelines. + // + // First home timelines. + if err := p.state.Timelines.Home.WipeItemsFromAccountID( + ctx, + block.AccountID, + block.TargetAccountID, + ); err != nil { + return gtserror.Newf("%w", err) + } + + if err := p.state.Timelines.Home.WipeItemsFromAccountID( + ctx, + block.TargetAccountID, + block.AccountID, + ); err != nil { + return gtserror.Newf("%w", err) + } + + // Now list timelines. + if err := p.state.Timelines.List.WipeItemsFromAccountID( + ctx, + block.AccountID, + block.TargetAccountID, + ); err != nil { + return gtserror.Newf("%w", err) + } + + if err := p.state.Timelines.List.WipeItemsFromAccountID( + ctx, + block.TargetAccountID, + block.AccountID, + ); err != nil { + return gtserror.Newf("%w", err) + } + + // Remove any follows that existed between blocker + blockee. + if err := p.state.DB.DeleteFollow( + ctx, + block.AccountID, + block.TargetAccountID, + ); err != nil { + return gtserror.Newf( + "db error deleting follow from %s targeting %s: %w", + block.AccountID, block.TargetAccountID, err, + ) + } + + if err := p.state.DB.DeleteFollow( + ctx, + block.TargetAccountID, + block.AccountID, + ); err != nil { + return gtserror.Newf( + "db error deleting follow from %s targeting %s: %w", + block.TargetAccountID, block.AccountID, err, + ) + } + + // Remove any follow requests that existed between blocker + blockee. + if err := p.state.DB.DeleteFollowRequest( + ctx, + block.AccountID, + block.TargetAccountID, + ); err != nil { + return gtserror.Newf( + "db error deleting follow request from %s targeting %s: %w", + block.AccountID, block.TargetAccountID, err, + ) + } + + if err := p.state.DB.DeleteFollowRequest( + ctx, + block.TargetAccountID, + block.AccountID, + ); err != nil { + return gtserror.Newf( + "db error deleting follow request from %s targeting %s: %w", + block.TargetAccountID, block.AccountID, err, + ) + } + + return nil +} + +func (p *fediAPI) CreateFlag(ctx context.Context, fMsg messages.FromFediAPI) error { + incomingReport, ok := fMsg.GTSModel.(*gtsmodel.Report) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Report", fMsg.GTSModel) + } + + // TODO: handle additional side effects of flag creation: + // - notify admins by dm / notification + + if err := p.surface.emailReportOpened(ctx, incomingReport); err != nil { + return gtserror.Newf("error sending report opened email: %w", err) + } + + return nil +} + +func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg messages.FromFediAPI) error { + // Parse the old/existing account model. + account, ok := fMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Account", fMsg.GTSModel) + } + + // Because this was an Update, the new Accountable should be set on the message. + apubAcc, ok := fMsg.APObjectModel.(ap.Accountable) + if !ok { + return gtserror.Newf("%T not parseable as ap.Accountable", fMsg.APObjectModel) + } + + // Fetch up-to-date bio, avatar, header, etc. + _, _, err := p.federate.RefreshAccount( + ctx, + fMsg.ReceivingAccount.Username, + account, + apubAcc, + true, // Force refresh. + ) + if err != nil { + return gtserror.Newf("error refreshing updated account: %w", err) + } + + return nil +} + +func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg messages.FromFediAPI) error { + // Delete attachments from this status, since this request + // comes from the federating API, and there's no way the + // poster can do a delete + redraft for it on our instance. + const deleteAttachments = true + + status, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) + } + + if err := p.wipeStatus(ctx, status, deleteAttachments); err != nil { + return gtserror.Newf("error wiping status: %w", err) + } + + if status.InReplyToID != "" { + // Interaction counts changed on the replied status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + } + + return nil +} + +func (p *fediAPI) DeleteAccount(ctx context.Context, fMsg messages.FromFediAPI) error { + account, ok := fMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Account", fMsg.GTSModel) + } + + if err := p.account.Delete(ctx, account, account.ID); err != nil { + return gtserror.Newf("error deleting account: %w", err) + } + + return nil +} diff --git a/internal/processing/fromfederator_test.go b/internal/processing/workers/fromfediapi_test.go similarity index 93% rename from internal/processing/fromfederator_test.go rename to internal/processing/workers/fromfediapi_test.go index 0b0e52811..f8e3941fc 100644 --- a/internal/processing/fromfederator_test.go +++ b/internal/processing/workers/fromfediapi_test.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package processing_test +package workers_test import ( "context" @@ -36,12 +36,12 @@ "github.com/superseriousbusiness/gotosocial/testrig" ) -type FromFederatorTestSuite struct { - ProcessingStandardTestSuite +type FromFediAPITestSuite struct { + WorkersTestSuite } // remote_account_1 boosts the first status of local_account_1 -func (suite *FromFederatorTestSuite) TestProcessFederationAnnounce() { +func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() { boostedStatus := suite.testStatuses["local_account_1_status_1"] boostingAccount := suite.testAccounts["remote_account_1"] announceStatus := >smodel.Status{} @@ -56,7 +56,7 @@ func (suite *FromFederatorTestSuite) TestProcessFederationAnnounce() { announceStatus.Account = boostingAccount announceStatus.Visibility = boostedStatus.Visibility - err := suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ + err := suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{ APObjectType: ap.ActivityAnnounce, APActivityType: ap.ActivityCreate, GTSModel: announceStatus, @@ -87,7 +87,7 @@ func (suite *FromFederatorTestSuite) TestProcessFederationAnnounce() { suite.False(*notif.Read) } -func (suite *FromFederatorTestSuite) TestProcessReplyMention() { +func (suite *FromFediAPITestSuite) TestProcessReplyMention() { repliedAccount := suite.testAccounts["local_account_1"] repliedStatus := suite.testStatuses["local_account_1_status_1"] replyingAccount := suite.testAccounts["remote_account_1"] @@ -128,7 +128,7 @@ func (suite *FromFederatorTestSuite) TestProcessReplyMention() { err = suite.db.PutStatus(context.Background(), replyingStatus) suite.NoError(err) - err = suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ + err = suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityCreate, GTSModel: replyingStatus, @@ -173,7 +173,7 @@ func (suite *FromFederatorTestSuite) TestProcessReplyMention() { suite.Equal(replyingAccount.ID, notifStreamed.Account.ID) } -func (suite *FromFederatorTestSuite) TestProcessFave() { +func (suite *FromFediAPITestSuite) TestProcessFave() { favedAccount := suite.testAccounts["local_account_1"] favedStatus := suite.testStatuses["local_account_1_status_1"] favingAccount := suite.testAccounts["remote_account_1"] @@ -197,7 +197,7 @@ func (suite *FromFederatorTestSuite) TestProcessFave() { err := suite.db.Put(context.Background(), fave) suite.NoError(err) - err = suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ + err = suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{ APObjectType: ap.ActivityLike, APActivityType: ap.ActivityCreate, GTSModel: fave, @@ -245,7 +245,7 @@ func (suite *FromFederatorTestSuite) TestProcessFave() { // // This tests for an issue we were seeing where Misskey sends out faves to inboxes of people that don't own // the fave, but just follow the actor who received the fave. -func (suite *FromFederatorTestSuite) TestProcessFaveWithDifferentReceivingAccount() { +func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount() { receivingAccount := suite.testAccounts["local_account_2"] favedAccount := suite.testAccounts["local_account_1"] favedStatus := suite.testStatuses["local_account_1_status_1"] @@ -270,7 +270,7 @@ func (suite *FromFederatorTestSuite) TestProcessFaveWithDifferentReceivingAccoun err := suite.db.Put(context.Background(), fave) suite.NoError(err) - err = suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ + err = suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{ APObjectType: ap.ActivityLike, APActivityType: ap.ActivityCreate, GTSModel: fave, @@ -304,7 +304,7 @@ func (suite *FromFederatorTestSuite) TestProcessFaveWithDifferentReceivingAccoun suite.Empty(wssStream.Messages) } -func (suite *FromFederatorTestSuite) TestProcessAccountDelete() { +func (suite *FromFediAPITestSuite) TestProcessAccountDelete() { ctx := context.Background() deletedAccount := suite.testAccounts["remote_account_1"] @@ -339,7 +339,7 @@ func (suite *FromFederatorTestSuite) TestProcessAccountDelete() { suite.NoError(err) // now they are mufos! - err = suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ + err = suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ObjectProfile, APActivityType: ap.ActivityDelete, GTSModel: deletedAccount, @@ -386,7 +386,7 @@ func (suite *FromFederatorTestSuite) TestProcessAccountDelete() { suite.Equal(dbAccount.ID, dbAccount.SuspensionOrigin) } -func (suite *FromFederatorTestSuite) TestProcessFollowRequestLocked() { +func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() { ctx := context.Background() originAccount := suite.testAccounts["remote_account_1"] @@ -414,7 +414,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestLocked() { err := suite.db.Put(ctx, satanFollowRequestTurtle) suite.NoError(err) - err = suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ + err = suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityCreate, GTSModel: satanFollowRequestTurtle, @@ -443,7 +443,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestLocked() { suite.Empty(suite.httpClient.SentMessages) } -func (suite *FromFederatorTestSuite) TestProcessFollowRequestUnlocked() { +func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() { ctx := context.Background() originAccount := suite.testAccounts["remote_account_1"] @@ -471,7 +471,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestUnlocked() { err := suite.db.Put(ctx, satanFollowRequestTurtle) suite.NoError(err) - err = suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ + err = suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ActivityFollow, APActivityType: ap.ActivityCreate, GTSModel: satanFollowRequestTurtle, @@ -539,13 +539,13 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestUnlocked() { } // TestCreateStatusFromIRI checks if a forwarded status can be dereferenced by the processor. -func (suite *FromFederatorTestSuite) TestCreateStatusFromIRI() { +func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() { ctx := context.Background() receivingAccount := suite.testAccounts["local_account_1"] statusCreator := suite.testAccounts["remote_account_2"] - err := suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ + err := suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityCreate, GTSModel: nil, // gtsmodel is nil because this is a forwarded status -- we want to dereference it using the iri @@ -561,5 +561,5 @@ func (suite *FromFederatorTestSuite) TestCreateStatusFromIRI() { } func TestFromFederatorTestSuite(t *testing.T) { - suite.Run(t, &FromFederatorTestSuite{}) + suite.Run(t, &FromFediAPITestSuite{}) } diff --git a/internal/processing/workers/surface.go b/internal/processing/workers/surface.go new file mode 100644 index 000000000..a3cf9a3e1 --- /dev/null +++ b/internal/processing/workers/surface.go @@ -0,0 +1,40 @@ +// 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 workers + +import ( + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/processing/stream" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/visibility" +) + +// surface wraps functions for 'surfacing' the result +// of processing a message, eg: +// - timelining a status +// - removing a status from timelines +// - sending a notification to a user +// - sending an email +type surface struct { + state *state.State + tc typeutils.TypeConverter + stream *stream.Processor + filter *visibility.Filter + emailSender email.Sender +} diff --git a/internal/processing/workers/surfaceemail.go b/internal/processing/workers/surfaceemail.go new file mode 100644 index 000000000..a6c97f48f --- /dev/null +++ b/internal/processing/workers/surfaceemail.go @@ -0,0 +1,160 @@ +// 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 workers + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +func (s *surface) emailReportOpened(ctx context.Context, report *gtsmodel.Report) error { + instance, err := s.state.DB.GetInstance(ctx, config.GetHost()) + if err != nil { + return gtserror.Newf("error getting instance: %w", err) + } + + toAddresses, err := s.state.DB.GetInstanceModeratorAddresses(ctx) + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + // No registered moderator addresses. + return nil + } + return gtserror.Newf("error getting instance moderator addresses: %w", err) + } + + if err := s.state.DB.PopulateReport(ctx, report); err != nil { + return gtserror.Newf("error populating report: %w", err) + } + + reportData := email.NewReportData{ + InstanceURL: instance.URI, + InstanceName: instance.Title, + ReportURL: instance.URI + "/settings/admin/reports/" + report.ID, + ReportDomain: report.Account.Domain, + ReportTargetDomain: report.TargetAccount.Domain, + } + + if err := s.emailSender.SendNewReportEmail(toAddresses, reportData); err != nil { + return gtserror.Newf("error emailing instance moderators: %w", err) + } + + return nil +} + +func (s *surface) emailReportClosed(ctx context.Context, report *gtsmodel.Report) error { + user, err := s.state.DB.GetUserByAccountID(ctx, report.Account.ID) + if err != nil { + return gtserror.Newf("db error getting user: %w", err) + } + + if user.ConfirmedAt.IsZero() || + !*user.Approved || + *user.Disabled || + user.Email == "" { + // Only email users who: + // - are confirmed + // - are approved + // - are not disabled + // - have an email address + return nil + } + + instance, err := s.state.DB.GetInstance(ctx, config.GetHost()) + if err != nil { + return gtserror.Newf("db error getting instance: %w", err) + } + + if err := s.state.DB.PopulateReport(ctx, report); err != nil { + return gtserror.Newf("error populating report: %w", err) + } + + reportClosedData := email.ReportClosedData{ + Username: report.Account.Username, + InstanceURL: instance.URI, + InstanceName: instance.Title, + ReportTargetUsername: report.TargetAccount.Username, + ReportTargetDomain: report.TargetAccount.Domain, + ActionTakenComment: report.ActionTaken, + } + + return s.emailSender.SendReportClosedEmail(user.Email, reportClosedData) +} + +func (s *surface) emailPleaseConfirm(ctx context.Context, user *gtsmodel.User, username string) error { + if user.UnconfirmedEmail == "" || + user.UnconfirmedEmail == user.Email { + // User has already confirmed this + // email address; nothing to do. + return nil + } + + instance, err := s.state.DB.GetInstance(ctx, config.GetHost()) + if err != nil { + return gtserror.Newf("db error getting instance: %w", err) + } + + // We need a token and a link for the + // user to click on. We'll use a uuid + // as our token since it's secure enough + // for this purpose. + var ( + confirmToken = uuid.NewString() + confirmLink = uris.GenerateURIForEmailConfirm(confirmToken) + ) + + // Assemble email contents and send the email. + if err := s.emailSender.SendConfirmEmail( + user.UnconfirmedEmail, + email.ConfirmData{ + Username: username, + InstanceURL: instance.URI, + InstanceName: instance.Title, + ConfirmLink: confirmLink, + }, + ); err != nil { + return err + } + + // Email sent, update the user entry + // with the new confirmation token. + now := time.Now() + user.ConfirmationToken = confirmToken + user.ConfirmationSentAt = now + user.LastEmailedAt = now + + if err := s.state.DB.UpdateUser( + ctx, + user, + "confirmation_token", + "confirmation_sent_at", + "last_emailed_at", + ); err != nil { + return gtserror.Newf("error updating user entry after email sent: %w", err) + } + + return nil +} diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go new file mode 100644 index 000000000..00e1205e6 --- /dev/null +++ b/internal/processing/workers/surfacenotify.go @@ -0,0 +1,221 @@ +// 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 workers + +import ( + "context" + "errors" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +// notifyMentions notifies each targeted account in +// the given mentions that they have a new mention. +func (s *surface) notifyMentions( + ctx context.Context, + mentions []*gtsmodel.Mention, +) error { + var errs = gtserror.NewMultiError(len(mentions)) + + for _, mention := range mentions { + if err := s.notify( + ctx, + gtsmodel.NotificationMention, + mention.TargetAccountID, + mention.OriginAccountID, + mention.StatusID, + ); err != nil { + errs.Append(err) + } + } + + return errs.Combine() +} + +// notifyFollowRequest notifies the target of the given +// follow request that they have a new follow request. +func (s *surface) notifyFollowRequest( + ctx context.Context, + followRequest *gtsmodel.FollowRequest, +) error { + return s.notify( + ctx, + gtsmodel.NotificationFollowRequest, + followRequest.TargetAccountID, + followRequest.AccountID, + "", + ) +} + +// notifyFollow notifies the target of the given follow that +// they have a new follow. It will also remove any previous +// notification of a follow request, essentially replacing +// that notification. +func (s *surface) notifyFollow( + ctx context.Context, + follow *gtsmodel.Follow, +) error { + // Check if previous follow req notif exists. + prevNotif, err := s.state.DB.GetNotification( + gtscontext.SetBarebones(ctx), + gtsmodel.NotificationFollowRequest, + follow.TargetAccountID, + follow.AccountID, + "", + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("db error checking for previous follow request notification: %w", err) + } + + if prevNotif != nil { + // Previous notif existed, delete it. + if err := s.state.DB.DeleteNotificationByID(ctx, prevNotif.ID); err != nil { + return gtserror.Newf("db error removing previous follow request notification %s: %w", prevNotif.ID, err) + } + } + + // Now notify the follow itself. + return s.notify( + ctx, + gtsmodel.NotificationFollow, + follow.TargetAccountID, + follow.AccountID, + "", + ) +} + +// notifyFave notifies the target of the given +// fave that their status has been liked/faved. +func (s *surface) notifyFave( + ctx context.Context, + fave *gtsmodel.StatusFave, +) error { + if fave.TargetAccountID == fave.AccountID { + // Self-fave, nothing to do. + return nil + } + + return s.notify( + ctx, + gtsmodel.NotificationFave, + fave.TargetAccountID, + fave.AccountID, + fave.StatusID, + ) +} + +// notifyAnnounce notifies the status boost target +// account that their status has been boosted. +func (s *surface) notifyAnnounce( + ctx context.Context, + status *gtsmodel.Status, +) error { + if status.BoostOfID == "" { + // Not a boost, nothing to do. + return nil + } + + if status.BoostOfAccountID == status.AccountID { + // Self-boost, nothing to do. + return nil + } + + return s.notify( + ctx, + gtsmodel.NotificationReblog, + status.BoostOfAccountID, + status.AccountID, + status.ID, + ) +} + +// notify creates, inserts, and streams a new +// notification to the target account if it +// doesn't yet exist with the given parameters. +// +// It filters out non-local target accounts, so +// it is safe to pass all sorts of notification +// targets into this function without filtering +// for non-local first. +// +// targetAccountID and originAccountID must be +// set, but statusID can be an empty string. +func (s *surface) notify( + ctx context.Context, + notificationType gtsmodel.NotificationType, + targetAccountID string, + originAccountID string, + statusID string, +) error { + targetAccount, err := s.state.DB.GetAccountByID(ctx, targetAccountID) + if err != nil { + return gtserror.Newf("error getting target account %s: %w", targetAccountID, err) + } + + if !targetAccount.IsLocal() { + // Nothing to do. + return nil + } + + // Make sure a notification doesn't + // already exist with these params. + if _, err := s.state.DB.GetNotification( + gtscontext.SetBarebones(ctx), + notificationType, + targetAccountID, + originAccountID, + statusID, + ); err == nil { + // Notification exists; + // nothing to do. + return nil + } else if !errors.Is(err, db.ErrNoEntries) { + // Real error. + return gtserror.Newf("error checking existence of notification: %w", err) + } + + // Notification doesn't yet exist, so + // we need to create + store one. + notif := >smodel.Notification{ + ID: id.NewULID(), + NotificationType: notificationType, + TargetAccountID: targetAccountID, + OriginAccountID: originAccountID, + StatusID: statusID, + } + + if err := s.state.DB.PutNotification(ctx, notif); err != nil { + return gtserror.Newf("error putting notification in database: %w", err) + } + + // Stream notification to the user. + apiNotif, err := s.tc.NotificationToAPINotification(ctx, notif) + if err != nil { + return gtserror.Newf("error converting notification to api representation: %w", err) + } + + if err := s.stream.Notify(apiNotif, targetAccount); err != nil { + return gtserror.Newf("error streaming notification to account: %w", err) + } + + return nil +} diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go new file mode 100644 index 000000000..827cbe2f8 --- /dev/null +++ b/internal/processing/workers/surfacetimeline.go @@ -0,0 +1,401 @@ +// 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 workers + +import ( + "context" + "errors" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/stream" + "github.com/superseriousbusiness/gotosocial/internal/timeline" +) + +// timelineAndNotifyStatus inserts the given status into the HOME +// and LIST timelines of accounts that follow the status author. +// +// It will also handle notifications for any mentions attached to +// the account, and notifications for any local accounts that want +// to know when this account posts. +func (s *surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.Status) error { + // Ensure status fully populated; including account, mentions, etc. + if err := s.state.DB.PopulateStatus(ctx, status); err != nil { + return gtserror.Newf("error populating status with id %s: %w", status.ID, err) + } + + // Get all local followers of the account that posted the status. + follows, err := s.state.DB.GetAccountLocalFollowers(ctx, status.AccountID) + if err != nil { + return gtserror.Newf("error getting local followers of account %s: %w", status.AccountID, err) + } + + // 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() { + follows = append(follows, >smodel.Follow{ + AccountID: status.AccountID, + Account: status.Account, + Notify: func() *bool { b := false; return &b }(), // Account shouldn't notify itself. + ShowReblogs: func() *bool { b := true; return &b }(), // Account should show own reblogs. + }) + } + + // Timeline the status for each local follower of this account. + // This will also handle notifying any followers with notify + // set to true on their follow. + if err := s.timelineAndNotifyStatusForFollowers(ctx, status, follows); err != nil { + return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err) + } + + // Notify each local account that's mentioned by this status. + if err := s.notifyMentions(ctx, status.Mentions); err != nil { + return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err) + } + + return nil +} + +// timelineAndNotifyStatusForFollowers iterates through the given +// slice of followers of the account that posted the given status, +// adding the status to list timelines + home timelines of each +// follower, as appropriate, and notifying each follower of the +// new status, if the status is eligible for notification. +func (s *surface) timelineAndNotifyStatusForFollowers( + ctx context.Context, + status *gtsmodel.Status, + follows []*gtsmodel.Follow, +) error { + var ( + errs = new(gtserror.MultiError) + boost = status.BoostOfID != "" + reply = status.InReplyToURI != "" + ) + + for _, follow := range follows { + // Do an initial rough-grained check to see if the + // status is timelineable for this follower at all + // based on its visibility and who it replies to etc. + timelineable, err := s.filter.StatusHomeTimelineable( + ctx, follow.Account, status, + ) + if err != nil { + errs.Appendf("error checking status %s hometimelineability: %w", status.ID, err) + continue + } + + if !timelineable { + // Nothing to do. + continue + } + + if boost && !*follow.ShowReblogs { + // Status is a boost, but the owner of + // this follow doesn't want to see boosts + // from this account. We can safely skip + // everything, then, because we also know + // that the follow owner won't want to be + // have the status put in any list timelines, + // or be notified about the status either. + continue + } + + // Add status to any relevant lists + // for this follow, if applicable. + s.listTimelineStatusForFollow( + ctx, + status, + follow, + errs, + ) + + // Add status to home timeline for owner + // of this follow, if applicable. + homeTimelined, err := s.timelineStatus( + ctx, + s.state.Timelines.Home.IngestOne, + follow.AccountID, // home timelines are keyed by account ID + follow.Account, + status, + stream.TimelineHome, + ) + if err != nil { + errs.Appendf("error home timelining status: %w", err) + continue + } + + if !homeTimelined { + // If status wasn't added to home + // timeline, we shouldn't notify it. + continue + } + + if !*follow.Notify { + // This follower doesn't have notifs + // set for this account's new posts. + continue + } + + if boost || reply { + // Don't notify for boosts or replies. + continue + } + + // If we reach here, we know: + // + // - This status is hometimelineable. + // - This status was added to the home timeline for this follower. + // - This follower wants to be notified when this account posts. + // - This is a top-level post (not a reply or boost). + // + // That means we can officially notify this one. + if err := s.notify( + ctx, + gtsmodel.NotificationStatus, + follow.AccountID, + status.AccountID, + status.ID, + ); err != nil { + errs.Appendf("error notifying account %s about new status: %w", follow.AccountID, err) + } + } + + return errs.Combine() +} + +// listTimelineStatusForFollow puts the given status +// in any eligible lists owned by the given follower. +func (s *surface) listTimelineStatusForFollow( + ctx context.Context, + status *gtsmodel.Status, + follow *gtsmodel.Follow, + errs *gtserror.MultiError, +) { + // To put this status in appropriate list timelines, + // we need to get each listEntry that pertains to + // this follow. Then, we want to iterate through all + // those list entries, and add the status to the list + // that the entry belongs to if it meets criteria for + // inclusion in the list. + + // Get every list entry that targets this follow's ID. + listEntries, err := s.state.DB.GetListEntriesForFollowID( + // We only need the list IDs. + gtscontext.SetBarebones(ctx), + follow.ID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs.Appendf("error getting list entries: %w", err) + return + } + + // Check eligibility for each list entry (if any). + for _, listEntry := range listEntries { + eligible, err := s.listEligible(ctx, listEntry, status) + if err != nil { + errs.Appendf("error checking list eligibility: %w", err) + continue + } + + if !eligible { + // Don't add this. + continue + } + + // At this point we are certain this status + // should be included in the timeline of the + // list that this list entry belongs to. + if _, err := s.timelineStatus( + ctx, + s.state.Timelines.List.IngestOne, + listEntry.ListID, // list timelines are keyed by list ID + follow.Account, + status, + stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list + ); err != nil { + errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) + // implicit continue + } + } +} + +// listEligible checks if the given status is eligible +// for inclusion in the list that that the given listEntry +// belongs to, based on the replies policy of the list. +func (s *surface) listEligible( + ctx context.Context, + listEntry *gtsmodel.ListEntry, + status *gtsmodel.Status, +) (bool, error) { + if status.InReplyToURI == "" { + // If status is not a reply, + // then it's all gravy baby. + return true, nil + } + + if status.InReplyToID == "" { + // Status is a reply but we don't + // have the replied-to account! + return false, nil + } + + // Status is a reply to a known account. + // We need to fetch the list that this + // entry belongs to, in order to check + // the list's replies policy. + list, err := s.state.DB.GetListByID( + ctx, listEntry.ListID, + ) + if err != nil { + err := gtserror.Newf("db error getting list %s: %w", listEntry.ListID, err) + return false, err + } + + switch list.RepliesPolicy { + case gtsmodel.RepliesPolicyNone: + // This list should not show + // replies at all, so skip it. + return false, nil + + case gtsmodel.RepliesPolicyList: + // This list should show replies + // only to other people in the list. + // + // Check if replied-to account is + // also included in this list. + includes, err := s.state.DB.ListIncludesAccount( + ctx, + list.ID, + status.InReplyToAccountID, + ) + + if err != nil { + err := gtserror.Newf( + "db error checking if account %s in list %s: %w", + status.InReplyToAccountID, listEntry.ListID, err, + ) + return false, err + } + + return includes, nil + + case gtsmodel.RepliesPolicyFollowed: + // This list should show replies + // only to people that the list + // owner also follows. + // + // Check if replied-to account is + // followed by list owner account. + follows, err := s.state.DB.IsFollowing( + ctx, + list.AccountID, + status.InReplyToAccountID, + ) + if err != nil { + err := gtserror.Newf( + "db error checking if account %s is followed by %s: %w", + status.InReplyToAccountID, list.AccountID, err, + ) + return false, err + } + + return follows, nil + + default: + // HUH?? + err := gtserror.Newf( + "reply policy '%s' not recognized on list %s", + list.RepliesPolicy, list.ID, + ) + return false, err + } +} + +// timelineStatus uses the provided ingest function to put the given +// status in a timeline with the given ID, if it's timelineable. +// +// If the status was inserted into the timeline, true will be returned +// + it will also be streamed to the user using the given streamType. +func (s *surface) timelineStatus( + ctx context.Context, + ingest func(context.Context, string, timeline.Timelineable) (bool, error), + timelineID string, + account *gtsmodel.Account, + status *gtsmodel.Status, + streamType string, +) (bool, error) { + // Ingest status into given timeline using provided function. + if inserted, err := ingest(ctx, timelineID, status); err != nil { + err = gtserror.Newf("error ingesting status %s: %w", status.ID, err) + return false, err + } else if !inserted { + // Nothing more to do. + return false, nil + } + + // The status was inserted so stream it to the user. + apiStatus, err := s.tc.StatusToAPIStatus(ctx, status, account) + if err != nil { + err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) + return true, err + } + + if err := s.stream.Update(apiStatus, account, []string{streamType}); err != nil { + err = gtserror.Newf("error streaming update for status %s: %w", status.ID, err) + return true, err + } + + return true, nil +} + +// deleteStatusFromTimelines completely removes the given status from all timelines. +// It will also stream deletion of the status to all open streams. +func (s *surface) deleteStatusFromTimelines(ctx context.Context, statusID string) error { + if err := s.state.Timelines.Home.WipeItemFromAllTimelines(ctx, statusID); err != nil { + return err + } + + if err := s.state.Timelines.List.WipeItemFromAllTimelines(ctx, statusID); err != nil { + return err + } + + return s.stream.Delete(statusID) +} + +// invalidateStatusFromTimelines does cache invalidation on the given status by +// unpreparing it from all timelines, forcing it to be prepared again (with updated +// stats, boost counts, etc) next time it's fetched by the timeline owner. This goes +// both for the status itself, and for any boosts of the status. +func (s *surface) invalidateStatusFromTimelines(ctx context.Context, statusID string) { + if err := s.state.Timelines.Home.UnprepareItemFromAllTimelines(ctx, statusID); err != nil { + log. + WithContext(ctx). + WithField("statusID", statusID). + Errorf("error unpreparing status from home timelines: %v", err) + } + + if err := s.state.Timelines.List.UnprepareItemFromAllTimelines(ctx, statusID); err != nil { + log. + WithContext(ctx). + WithField("statusID", statusID). + Errorf("error unpreparing status from list timelines: %v", err) + } +} diff --git a/internal/processing/workers/wipestatus.go b/internal/processing/workers/wipestatus.go new file mode 100644 index 000000000..0891d9e24 --- /dev/null +++ b/internal/processing/workers/wipestatus.go @@ -0,0 +1,119 @@ +// 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 workers + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/processing/media" + "github.com/superseriousbusiness/gotosocial/internal/state" +) + +// wipeStatus encapsulates common logic used to totally delete a status +// + all its attachments, notifications, boosts, and timeline entries. +type wipeStatus func(context.Context, *gtsmodel.Status, bool) error + +// wipeStatusF returns a wipeStatus util function. +func wipeStatusF(state *state.State, media *media.Processor, surface *surface) wipeStatus { + return func( + ctx context.Context, + statusToDelete *gtsmodel.Status, + deleteAttachments bool, + ) error { + errs := new(gtserror.MultiError) + + // Either delete all attachments for this status, + // or simply unattach + clean them separately later. + // + // Reason to unattach rather than delete is that + // the poster might want to reattach them to another + // status immediately (in case of delete + redraft) + if deleteAttachments { + // todo:state.DB.DeleteAttachmentsForStatus + for _, a := range statusToDelete.AttachmentIDs { + if err := media.Delete(ctx, a); err != nil { + errs.Appendf("error deleting media: %w", err) + } + } + } else { + // todo:state.DB.UnattachAttachmentsForStatus + for _, a := range statusToDelete.AttachmentIDs { + if _, err := media.Unattach(ctx, statusToDelete.Account, a); err != nil { + errs.Appendf("error unattaching media: %w", err) + } + } + } + + // delete all mention entries generated by this status + // todo:state.DB.DeleteMentionsForStatus + for _, id := range statusToDelete.MentionIDs { + if err := state.DB.DeleteMentionByID(ctx, id); err != nil { + errs.Appendf("error deleting status mention: %w", err) + } + } + + // delete all notification entries generated by this status + if err := state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil { + errs.Appendf("error deleting status notifications: %w", err) + } + + // delete all bookmarks that point to this status + if err := state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil { + errs.Appendf("error deleting status bookmarks: %w", err) + } + + // delete all faves of this status + if err := state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil { + errs.Appendf("error deleting status faves: %w", err) + } + + // delete all boosts for this status + remove them from timelines + boosts, err := state.DB.GetStatusBoosts( + // we MUST set a barebones context here, + // as depending on where it came from the + // original BoostOf may already be gone. + gtscontext.SetBarebones(ctx), + statusToDelete.ID) + if err != nil { + errs.Appendf("error fetching status boosts: %w", err) + } + for _, b := range boosts { + if err := surface.deleteStatusFromTimelines(ctx, b.ID); err != nil { + errs.Appendf("error deleting boost from timelines: %w", err) + } + if err := state.DB.DeleteStatusByID(ctx, b.ID); err != nil { + errs.Appendf("error deleting boost: %w", err) + } + } + + // delete this status from any and all timelines + if err := surface.deleteStatusFromTimelines(ctx, statusToDelete.ID); err != nil { + errs.Appendf("error deleting status from timelines: %w", err) + } + + // finally, delete the status itself + if err := state.DB.DeleteStatusByID(ctx, statusToDelete.ID); err != nil { + errs.Appendf("error deleting status: %w", err) + } + + return errs.Combine() + } +} diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go new file mode 100644 index 000000000..24b18a405 --- /dev/null +++ b/internal/processing/workers/workers.go @@ -0,0 +1,92 @@ +// 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 workers + +import ( + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/processing/account" + "github.com/superseriousbusiness/gotosocial/internal/processing/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/stream" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/visibility" + "github.com/superseriousbusiness/gotosocial/internal/workers" +) + +type Processor struct { + workers *workers.Workers + clientAPI *clientAPI + fediAPI *fediAPI +} + +func New( + state *state.State, + federator federation.Federator, + tc typeutils.TypeConverter, + filter *visibility.Filter, + emailSender email.Sender, + account *account.Processor, + media *media.Processor, + stream *stream.Processor, +) Processor { + // Init surface logic + // wrapper struct. + surface := &surface{ + state: state, + tc: tc, + stream: stream, + filter: filter, + emailSender: emailSender, + } + + // Init federate logic + // wrapper struct. + federate := &federate{ + Federator: federator, + state: state, + tc: tc, + } + + // Init shared logic wipe + // status util func. + wipeStatus := wipeStatusF( + state, + media, + surface, + ) + + return Processor{ + workers: &state.Workers, + clientAPI: &clientAPI{ + state: state, + tc: tc, + surface: surface, + federate: federate, + wipeStatus: wipeStatus, + account: account, + }, + fediAPI: &fediAPI{ + state: state, + surface: surface, + federate: federate, + wipeStatus: wipeStatus, + account: account, + }, + } +} diff --git a/internal/processing/workers/workers_test.go b/internal/processing/workers/workers_test.go new file mode 100644 index 000000000..2d5a7f5d3 --- /dev/null +++ b/internal/processing/workers/workers_test.go @@ -0,0 +1,169 @@ +// 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 workers_test + +import ( + "context" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/stream" + "github.com/superseriousbusiness/gotosocial/internal/transport" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/visibility" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type WorkersTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + storage *storage.Driver + state state.State + mediaManager *media.Manager + typeconverter typeutils.TypeConverter + httpClient *testrig.MockHTTPClient + transportController transport.Controller + federator federation.Federator + oauthServer oauth.Server + emailSender email.Sender + + // 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 + testFollows map[string]*gtsmodel.Follow + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testTags map[string]*gtsmodel.Tag + testMentions map[string]*gtsmodel.Mention + testAutheds map[string]*oauth.Auth + testBlocks map[string]*gtsmodel.Block + testActivities map[string]testrig.ActivityWithSignature + testLists map[string]*gtsmodel.List + testListEntries map[string]*gtsmodel.ListEntry + + processor *processing.Processor +} + +func (suite *WorkersTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testFollows = testrig.NewTestFollows() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() + suite.testTags = testrig.NewTestTags() + suite.testMentions = testrig.NewTestMentions() + suite.testAutheds = map[string]*oauth.Auth{ + "local_account_1": { + Application: suite.testApplications["local_account_1"], + User: suite.testUsers["local_account_1"], + Account: suite.testAccounts["local_account_1"], + }, + } + suite.testBlocks = testrig.NewTestBlocks() + suite.testLists = testrig.NewTestLists() + suite.testListEntries = testrig.NewTestListEntries() +} + +func (suite *WorkersTestSuite) SetupTest() { + suite.state.Caches.Init() + testrig.StartWorkers(&suite.state) + + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.state.DB = suite.db + suite.testActivities = testrig.NewTestActivities(suite.testAccounts) + suite.storage = testrig.NewInMemoryStorage() + suite.state.Storage = suite.storage + suite.typeconverter = testrig.NewTestTypeConverter(suite.db) + + testrig.StartTimelines( + &suite.state, + visibility.NewFilter(&suite.state), + suite.typeconverter, + ) + + suite.httpClient = testrig.NewMockHTTPClient(nil, "../../../testrig/media") + suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople() + suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses() + + suite.transportController = testrig.NewTestTransportController(&suite.state, suite.httpClient) + suite.mediaManager = testrig.NewTestMediaManager(&suite.state) + suite.federator = testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.emailSender = testrig.NewEmailSender("../../../web/template/", nil) + + suite.processor = processing.NewProcessor(suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaManager, &suite.state, suite.emailSender) + suite.state.Workers.EnqueueClientAPI = suite.processor.Workers().EnqueueClientAPI + suite.state.Workers.EnqueueFediAPI = suite.processor.Workers().EnqueueFediAPI + + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") +} + +func (suite *WorkersTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) + testrig.StopWorkers(&suite.state) +} + +func (suite *WorkersTestSuite) openStreams(ctx context.Context, account *gtsmodel.Account, listIDs []string) map[string]*stream.Stream { + streams := make(map[string]*stream.Stream) + + for _, streamType := range []string{ + stream.TimelineHome, + stream.TimelinePublic, + stream.TimelineNotifications, + } { + stream, err := suite.processor.Stream().Open(ctx, account, streamType) + if err != nil { + suite.FailNow(err.Error()) + } + + streams[streamType] = stream + } + + for _, listID := range listIDs { + streamType := stream.TimelineList + ":" + listID + + stream, err := suite.processor.Stream().Open(ctx, account, streamType) + if err != nil { + suite.FailNow(err.Error()) + } + + streams[streamType] = stream + } + + return streams +} diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index cb69cba5d..73992fc0e 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -156,7 +156,7 @@ type TypeConverter interface { // URI of the status as object, and addressing the Delete appropriately. StatusToASDelete(ctx context.Context, status *gtsmodel.Status) (vocab.ActivityStreamsDelete, error) // FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation - FollowToAS(ctx context.Context, f *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (vocab.ActivityStreamsFollow, error) + FollowToAS(ctx context.Context, f *gtsmodel.Follow) (vocab.ActivityStreamsFollow, error) // MentionToAS converts a gts model mention into an activity streams Mention, suitable for federation MentionToAS(ctx context.Context, m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error) // EmojiToAS converts a gts emoji into a mastodon ns Emoji, suitable for federation diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 60ab24383..f10205b13 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -774,10 +774,14 @@ func (c *converter) StatusToASDelete(ctx context.Context, s *gtsmodel.Status) (v return delete, nil } -func (c *converter) FollowToAS(ctx context.Context, f *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (vocab.ActivityStreamsFollow, error) { - // parse out the various URIs we need for this - // origin account (who's doing the follow) - originAccountURI, err := url.Parse(originAccount.URI) +func (c *converter) FollowToAS(ctx context.Context, f *gtsmodel.Follow) (vocab.ActivityStreamsFollow, error) { + if err := c.db.PopulateFollow(ctx, f); err != nil { + return nil, gtserror.Newf("error populating follow: %w", err) + } + + // Parse out the various URIs we need for this + // origin account (who's doing the follow). + originAccountURI, err := url.Parse(f.Account.URI) if err != nil { return nil, fmt.Errorf("followtoasfollow: error parsing origin account uri: %s", err) } @@ -785,7 +789,7 @@ func (c *converter) FollowToAS(ctx context.Context, f *gtsmodel.Follow, originAc originActor.AppendIRI(originAccountURI) // target account (who's being followed) - targetAccountURI, err := url.Parse(targetAccount.URI) + targetAccountURI, err := url.Parse(f.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("followtoasfollow: error parsing target account uri: %s", err) } diff --git a/internal/visibility/home_timeline.go b/internal/visibility/home_timeline.go index e3af03d83..d8bbc3979 100644 --- a/internal/visibility/home_timeline.go +++ b/internal/visibility/home_timeline.go @@ -24,6 +24,7 @@ "github.com/superseriousbusiness/gotosocial/internal/cache" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -97,33 +98,17 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A } var ( - next *gtsmodel.Status + next = status oneAuthor = true // Assume one author until proven otherwise. included bool converstn bool ) - for next = status; next.InReplyToURI != ""; { - // Fetch next parent to lookup. - parentID := next.InReplyToID - if parentID == "" { - log.Warnf(ctx, "status not yet deref'd: %s", next.InReplyToURI) - return false, cache.SentinelError - } - - // Get the next parent in the chain from DB. - next, err = f.state.DB.GetStatusByID( - gtscontext.SetBarebones(ctx), - parentID, - ) - if err != nil { - return false, fmt.Errorf("isStatusHomeTimelineable: error getting status parent %s: %w", parentID, err) - } - + for { // Populate account mention objects before account mention checks. next.Mentions, err = f.state.DB.GetMentions(ctx, next.MentionIDs) if err != nil { - return false, fmt.Errorf("isStatusHomeTimelineable: error populating status parent %s mentions: %w", parentID, err) + return false, gtserror.Newf("error populating status %s mentions: %w", next.ID, err) } if (next.AccountID == owner.ID) || @@ -139,7 +124,7 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A // is it between accounts on owner timeline that they follow? converstn, err = f.isVisibleConversation(ctx, owner, next) if err != nil { - return false, fmt.Errorf("isStatusHomeTimelineable: error checking conversation visibility: %w", err) + return false, gtserror.Newf("error checking conversation visibility: %w", err) } if converstn { @@ -152,6 +137,26 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A // Check if this continues to be a single-author thread. oneAuthor = (next.AccountID == status.AccountID) } + + if next.InReplyToURI == "" { + // Reached the top of the thread. + break + } + + // Fetch next parent in thread. + parentID := next.InReplyToID + if parentID == "" { + log.Warnf(ctx, "status not yet deref'd: %s", next.InReplyToURI) + return false, cache.SentinelError + } + + next, err = f.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + parentID, + ) + if err != nil { + return false, gtserror.Newf("error getting status parent %s: %w", parentID, err) + } } if next != status && !oneAuthor && !included && !converstn { @@ -177,7 +182,7 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A status.AccountID, ) if err != nil { - return false, fmt.Errorf("isStatusHomeTimelineable: error checking follow %s->%s: %w", owner.ID, status.AccountID, err) + return false, gtserror.Newf("error checking follow %s->%s: %w", owner.ID, status.AccountID, err) } if !follow { diff --git a/internal/workers/workers.go b/internal/workers/workers.go index aa8e40e1c..965cf1d2a 100644 --- a/internal/workers/workers.go +++ b/internal/workers/workers.go @@ -43,7 +43,7 @@ type Workers struct { // 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) + EnqueueFediAPI func(context.Context, ...messages.FromFediAPI) // Media manager worker pools. Media runners.WorkerPool diff --git a/testrig/processor.go b/testrig/processor.go index 1839b482a..0c6d97253 100644 --- a/testrig/processor.go +++ b/testrig/processor.go @@ -28,7 +28,7 @@ // NewTestProcessor returns a Processor suitable for testing purposes func NewTestProcessor(state *state.State, federator federation.Federator, emailSender email.Sender, mediaManager *media.Manager) *processing.Processor { p := processing.NewProcessor(NewTestTypeConverter(state.DB), federator, NewTestOauthServer(state.DB), mediaManager, state, emailSender) - state.Workers.EnqueueClientAPI = p.EnqueueClientAPI - state.Workers.EnqueueFederator = p.EnqueueFederator + state.Workers.EnqueueClientAPI = p.Workers().EnqueueClientAPI + state.Workers.EnqueueFediAPI = p.Workers().EnqueueFediAPI return p } diff --git a/testrig/util.go b/testrig/util.go index 4e52d12b5..483064e0a 100644 --- a/testrig/util.go +++ b/testrig/util.go @@ -37,7 +37,7 @@ func StartWorkers(state *state.State) { state.Workers.EnqueueClientAPI = func(context.Context, ...messages.FromClientAPI) {} - state.Workers.EnqueueFederator = func(context.Context, ...messages.FromFederator) {} + state.Workers.EnqueueFediAPI = func(context.Context, ...messages.FromFediAPI) {} _ = state.Workers.Scheduler.Start(nil) _ = state.Workers.ClientAPI.Start(1, 10)