[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
This commit is contained in:
tobi 2023-08-09 19:14:33 +02:00 committed by GitHub
parent dbf487effb
commit 9770d54237
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 4110 additions and 2660 deletions

View file

@ -177,8 +177,8 @@ var Start action.GTSAction = func(ctx context.Context) error {
processor := processing.NewProcessor(typeConverter, federator, oauthServer, mediaManager, &state, emailSender) processor := processing.NewProcessor(typeConverter, federator, oauthServer, mediaManager, &state, emailSender)
// Set state client / federator worker enqueue functions // Set state client / federator worker enqueue functions
state.Workers.EnqueueClientAPI = processor.EnqueueClientAPI state.Workers.EnqueueClientAPI = processor.Workers().EnqueueClientAPI
state.Workers.EnqueueFederator = processor.EnqueueFederator state.Workers.EnqueueFediAPI = processor.Workers().EnqueueFediAPI
/* /*
HTTP router initialization HTTP router initialization

View file

@ -290,11 +290,7 @@ func (a *accountDB) PopulateAccount(ctx context.Context, account *gtsmodel.Accou
} }
} }
if err := errs.Combine(); err != nil { return errs.Combine()
return gtserror.Newf("%w", err)
}
return nil
} }
func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) error { func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) error {

View file

@ -198,11 +198,7 @@ func (i *instanceDB) populateInstance(ctx context.Context, instance *gtsmodel.In
} }
} }
if err := errs.Combine(); err != nil { return errs.Combine()
return gtserror.Newf("%w", err)
}
return nil
} }
func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error { func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error {

View file

@ -143,11 +143,7 @@ func (l *listDB) PopulateList(ctx context.Context, list *gtsmodel.List) error {
} }
} }
if err := errs.Combine(); err != nil { return errs.Combine()
return gtserror.Newf("%w", err)
}
return nil
} }
func (l *listDB) PutList(ctx context.Context, list *gtsmodel.List) error { 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 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', // 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. // 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 { func collate[T comparable](get func(int) T, len int) []T {

View file

@ -310,6 +310,27 @@ func (suite *ListTestSuite) TestDeleteListEntriesForFollowID() {
suite.checkList(testList, dbList) 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) { func TestListTestSuite(t *testing.T) {
suite.Run(t, new(ListTestSuite)) suite.Run(t, new(ListTestSuite))
} }

View file

@ -20,10 +20,10 @@ package bundb
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@ -139,27 +139,44 @@ func (r *relationshipDB) getBlock(ctx context.Context, lookup string, dbQuery fu
return block, nil return block, nil
} }
// Set the block source account if err := r.state.DB.PopulateBlock(ctx, block); err != nil {
block.Account, err = r.state.DB.GetAccountByID( return nil, err
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)
} }
return block, nil 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 { func (r *relationshipDB) PutBlock(ctx context.Context, block *gtsmodel.Block) error {
return r.state.Caches.GTS.Block().Store(block, func() error { return r.state.Caches.GTS.Block().Store(block, func() error {
_, err := r.db.NewInsert().Model(block).Exec(ctx) _, err := r.db.NewInsert().Model(block).Exec(ctx)

View file

@ -185,11 +185,7 @@ func (r *relationshipDB) PopulateFollow(ctx context.Context, follow *gtsmodel.Fo
} }
} }
if err := errs.Combine(); err != nil { return errs.Combine()
return gtserror.Newf("%w", err)
}
return nil
} }
func (r *relationshipDB) PutFollow(ctx context.Context, follow *gtsmodel.Follow) error { func (r *relationshipDB) PutFollow(ctx context.Context, follow *gtsmodel.Follow) error {

View file

@ -20,11 +20,11 @@ package bundb
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@ -127,27 +127,44 @@ func (r *relationshipDB) getFollowRequest(ctx context.Context, lookup string, db
return followReq, nil return followReq, nil
} }
// Set the follow request source account if err := r.state.DB.PopulateFollowRequest(ctx, followReq); err != nil {
followReq.Account, err = r.state.DB.GetAccountByID( return nil, err
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)
} }
return followReq, nil 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 { func (r *relationshipDB) PutFollowRequest(ctx context.Context, follow *gtsmodel.FollowRequest) error {
return r.state.Caches.GTS.FollowRequest().Store(follow, func() error { return r.state.Caches.GTS.FollowRequest().Store(follow, func() error {
_, err := r.db.NewInsert().Model(follow).Exec(ctx) _, err := r.db.NewInsert().Model(follow).Exec(ctx)

View file

@ -20,11 +20,11 @@ package bundb
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state" "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 return nil, err
} }
// Set the report author account if gtscontext.Barebones(ctx) {
report.Account, err = r.state.DB.GetAccountByID(ctx, report.AccountID) // Only a barebones model was requested.
if err != nil { return report, nil
return nil, fmt.Errorf("error getting report account: %w", err)
} }
// Set the report target account if err := r.state.DB.PopulateReport(ctx, report); err != nil {
report.TargetAccount, err = r.state.DB.GetAccountByID(ctx, report.TargetAccountID) return nil, err
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)
}
} }
return report, nil 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 { func (r *reportDB) PutReport(ctx context.Context, report *gtsmodel.Report) error {
return r.state.Caches.GTS.Report().Store(report, func() error { return r.state.Caches.GTS.Report().Store(report, func() error {
_, err := r.db.NewInsert().Model(report).Exec(ctx) _, err := r.db.NewInsert().Model(report).Exec(ctx)

View file

@ -197,11 +197,7 @@ func (s *statusFaveDB) PopulateStatusFave(ctx context.Context, statusFave *gtsmo
} }
} }
if err := errs.Combine(); err != nil { return errs.Combine()
return gtserror.Newf("%w", err)
}
return nil
} }
func (s *statusFaveDB) PutStatusFave(ctx context.Context, fave *gtsmodel.StatusFave) error { func (s *statusFaveDB) PutStatusFave(ctx context.Context, fave *gtsmodel.StatusFave) error {

View file

@ -64,4 +64,7 @@ type List interface {
// DeleteListEntryForFollowID deletes all list entries with the given followID. // DeleteListEntryForFollowID deletes all list entries with the given followID.
DeleteListEntriesForFollowID(ctx context.Context, followID string) error 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)
} }

View file

@ -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 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) 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 attempts to place the given account block in the database.
PutBlock(ctx context.Context, block *gtsmodel.Block) error 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 retrieves a follow request if it exists between source and target accounts.
GetFollowRequest(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.FollowRequest, error) 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 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) IsFollowing(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error)

View file

@ -27,17 +27,24 @@ import (
type Report interface { type Report interface {
// GetReportByID gets one report by its db id // GetReportByID gets one report by its db id
GetReportByID(ctx context.Context, id string) (*gtsmodel.Report, error) GetReportByID(ctx context.Context, id string) (*gtsmodel.Report, error)
// GetReports gets limit n reports using the given parameters. // GetReports gets limit n reports using the given parameters.
// Parameters that are empty / zero are ignored. // 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) 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 puts the given report in the database.
PutReport(ctx context.Context, report *gtsmodel.Report) error PutReport(ctx context.Context, report *gtsmodel.Report) error
// UpdateReport updates one report by its db id. // UpdateReport updates one report by its db id.
// The given columns will be updated; if no columns are // The given columns will be updated; if no columns are
// provided, then all columns will be updated. // provided, then all columns will be updated.
// updated_at will also be updated, no need to pass this // updated_at will also be updated, no need to pass this
// as a specific column. // as a specific column.
UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, error) UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, error)
// DeleteReportByID deletes report with the given id. // DeleteReportByID deletes report with the given id.
DeleteReportByID(ctx context.Context, id string) error DeleteReportByID(ctx context.Context, id string) error
} }

View file

@ -72,7 +72,7 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
return err return err
} }
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityFollow, APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityAccept, APActivityType: ap.ActivityAccept,
GTSModel: follow, GTSModel: follow,
@ -107,7 +107,7 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
return err return err
} }
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityFollow, APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityAccept, APActivityType: ap.ActivityAccept,
GTSModel: follow, GTSModel: follow,

View file

@ -56,7 +56,7 @@ func (f *federatingDB) Announce(ctx context.Context, announce vocab.ActivityStre
} }
// This is a new boost. Process side effects asynchronously. // 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, APObjectType: ap.ActivityAnnounce,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: boost, GTSModel: boost,

View file

@ -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) 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, APObjectType: ap.ActivityBlock,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: block, GTSModel: block,
@ -233,7 +233,7 @@ func (f *federatingDB) createStatusable(
if forward { if forward {
// Pass the statusable URI (APIri) into the processor worker // Pass the statusable URI (APIri) into the processor worker
// and do the rest of the processing asynchronously. // 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, APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
APIri: statusableURI, APIri: statusableURI,
@ -291,7 +291,7 @@ func (f *federatingDB) createStatusable(
// Do the rest of the processing asynchronously. The processor // Do the rest of the processing asynchronously. The processor
// will handle inserting/updating + further dereferencing the status. // 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, APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
APIri: nil, 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) 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, APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: followRequest, 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) 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, APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: fave, 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) 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, APObjectType: ap.ActivityFlag,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: report, GTSModel: report,

View file

@ -49,7 +49,7 @@ func (f *federatingDB) Delete(ctx context.Context, id *url.URL) error {
// so we have to try a few different things... // 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 { 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) 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, APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityDelete, APActivityType: ap.ActivityDelete,
GTSModel: s, 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 { 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) 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, APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityDelete, APActivityType: ap.ActivityDelete,
GTSModel: a, GTSModel: a,

View file

@ -36,7 +36,7 @@ type FederatingDBTestSuite struct {
suite.Suite suite.Suite
db db.DB db db.DB
tc typeutils.TypeConverter tc typeutils.TypeConverter
fromFederator chan messages.FromFederator fromFederator chan messages.FromFediAPI
federatingDB federatingdb.DB federatingDB federatingdb.DB
state state.State state state.State
@ -69,8 +69,8 @@ func (suite *FederatingDBTestSuite) SetupTest() {
suite.state.Caches.Init() suite.state.Caches.Init()
testrig.StartWorkers(&suite.state) testrig.StartWorkers(&suite.state)
suite.fromFederator = make(chan messages.FromFederator, 10) suite.fromFederator = make(chan messages.FromFediAPI, 10)
suite.state.Workers.EnqueueFederator = func(ctx context.Context, msgs ...messages.FromFederator) { suite.state.Workers.EnqueueFediAPI = func(ctx context.Context, msgs ...messages.FromFediAPI) {
for _, msg := range msgs { for _, msg := range msgs {
suite.fromFederator <- msg suite.fromFederator <- msg
} }

View file

@ -52,7 +52,7 @@ func (suite *RejectTestSuite) TestRejectFollowRequest() {
err := suite.db.Put(ctx, fr) err := suite.db.Put(ctx, fr)
suite.NoError(err) 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) suite.NoError(err)
rejectingAccountURI := testrig.URLMustParse(followedAccount.URI) rejectingAccountURI := testrig.URLMustParse(followedAccount.URI)

View file

@ -93,7 +93,7 @@ func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gts
// was delivered along with the Update, for further asynchronous // was delivered along with the Update, for further asynchronous
// updating of eg., avatar/header, emojis, etc. The actual db // updating of eg., avatar/header, emojis, etc. The actual db
// inserts/updates will take place there. // inserts/updates will take place there.
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{ f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ObjectProfile, APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityUpdate, APActivityType: ap.ActivityUpdate,
GTSModel: requestingAcct, GTSModel: requestingAcct,

View file

@ -21,16 +21,34 @@ import (
"net/http" "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 { func New(msg string) error {
return newAt(3, msg) 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 { func Newf(msgf string, args ...any) error {
return newfAt(3, msgf, args...) 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 // NewResponseError crafts an error from provided HTTP response
// including the method, status and body (if any provided). This // including the method, status and body (if any provided). This
// will also wrap the returned error using WithStatusCode() and // will also wrap the returned error using WithStatusCode() and

View file

@ -32,8 +32,8 @@ type FromClientAPI struct {
TargetAccount *gtsmodel.Account TargetAccount *gtsmodel.Account
} }
// FromFederator wraps a message that travels from the federator into the processor. // FromFediAPI wraps a message that travels from the federating API into the processor.
type FromFederator struct { type FromFediAPI struct {
APObjectType string APObjectType string
APActivityType string APActivityType string
APIri *url.URL APIri *url.URL

File diff suppressed because it is too large Load diff

View file

@ -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 <http://www.gnu.org/licenses/>.
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 := &gtsmodel.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 := &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())
}
// Make a new status from admin account.
newStatus := &gtsmodel.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{})
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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, &gtsmodel.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 := &gtsmodel.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)
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View file

@ -18,13 +18,9 @@
package processing package processing
import ( import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/log"
mm "github.com/superseriousbusiness/gotosocial/internal/media" mm "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/account"
"github.com/superseriousbusiness/gotosocial/internal/processing/admin" "github.com/superseriousbusiness/gotosocial/internal/processing/admin"
@ -38,19 +34,23 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/stream" "github.com/superseriousbusiness/gotosocial/internal/processing/stream"
"github.com/superseriousbusiness/gotosocial/internal/processing/timeline" "github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
"github.com/superseriousbusiness/gotosocial/internal/processing/user" "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/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/visibility" "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 { type Processor struct {
federator federation.Federator tc typeutils.TypeConverter
tc typeutils.TypeConverter oauthServer oauth.Server
oauthServer oauth.Server state *state.State
mediaManager *mm.Manager
state *state.State
emailSender email.Sender
filter *visibility.Filter
/* /*
SUB-PROCESSORS SUB-PROCESSORS
@ -68,6 +68,7 @@ type Processor struct {
stream stream.Processor stream stream.Processor
timeline timeline.Processor timeline timeline.Processor
user user.Processor user user.Processor
workers workers.Processor
} }
func (p *Processor) Account() *account.Processor { func (p *Processor) Account() *account.Processor {
@ -118,6 +119,10 @@ func (p *Processor) User() *user.Processor {
return &p.user return &p.user
} }
func (p *Processor) Workers() *workers.Processor {
return &p.workers
}
// NewProcessor returns a new Processor. // NewProcessor returns a new Processor.
func NewProcessor( func NewProcessor(
tc typeutils.TypeConverter, tc typeutils.TypeConverter,
@ -127,57 +132,53 @@ func NewProcessor(
state *state.State, state *state.State,
emailSender email.Sender, emailSender email.Sender,
) *Processor { ) *Processor {
parseMentionFunc := GetParseMentionFunc(state.DB, federator) var (
parseMentionFunc = GetParseMentionFunc(state.DB, federator)
filter := visibility.NewFilter(state) filter = visibility.NewFilter(state)
)
processor := &Processor{ processor := &Processor{
federator: federator, tc: tc,
tc: tc, oauthServer: oauthServer,
oauthServer: oauthServer, state: state,
mediaManager: mediaManager,
state: state,
filter: filter,
emailSender: emailSender,
} }
// Instantiate sub processors. // 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.admin = admin.New(state, tc, mediaManager, federator.TransportController(), emailSender)
processor.fedi = fedi.New(state, tc, federator, filter) processor.fedi = fedi.New(state, tc, federator, filter)
processor.list = list.New(state, tc) processor.list = list.New(state, tc)
processor.markers = markers.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.report = report.New(state, tc)
processor.timeline = timeline.New(state, tc, filter) processor.timeline = timeline.New(state, tc, filter)
processor.search = search.New(state, federator, tc, filter) processor.search = search.New(state, federator, tc, filter)
processor.status = status.New(state, federator, tc, filter, parseMentionFunc) processor.status = status.New(state, federator, tc, filter, parseMentionFunc)
processor.stream = stream.New(state, oauthServer) processor.stream = streamProcessor
processor.user = user.New(state, emailSender) 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 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)
}
}
})
}

View file

@ -123,8 +123,8 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) suite.emailSender = testrig.NewEmailSender("../../web/template/", nil)
suite.processor = processing.NewProcessor(suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaManager, &suite.state, suite.emailSender) 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.EnqueueClientAPI = suite.processor.Workers().EnqueueClientAPI
suite.state.Workers.EnqueueFederator = suite.processor.EnqueueFederator suite.state.Workers.EnqueueFediAPI = suite.processor.Workers().EnqueueFediAPI
testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardDBSetup(suite.db, suite.testAccounts)
testrig.StandardStorageSetup(suite.storage, "../../testrig/media") testrig.StandardStorageSetup(suite.storage, "../../testrig/media")

View file

@ -28,13 +28,14 @@ import (
type Processor struct { type Processor struct {
state *state.State state *state.State
oauthServer oauth.Server oauthServer oauth.Server
streamMap sync.Map streamMap *sync.Map
} }
func New(state *state.State, oauthServer oauth.Server) Processor { func New(state *state.State, oauthServer oauth.Server) Processor {
return Processor{ return Processor{
state: state, state: state,
oauthServer: oauthServer, oauthServer: oauthServer,
streamMap: &sync.Map{},
} }
} }

View file

@ -23,67 +23,13 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/google/uuid"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/uris"
) )
var oneWeek = 168 * time.Hour 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 × 1011),
// 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 := &gtsmodel.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 // EmailConfirm processes an email confirmation request, usually initiated as a result of clicking on a link
// in a 'confirm your email address' type email. // in a 'confirm your email address' type email.
func (p *Processor) EmailConfirm(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) { func (p *Processor) EmailConfirm(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) {

View file

@ -19,7 +19,6 @@ package user_test
import ( import (
"context" "context"
"fmt"
"testing" "testing"
"time" "time"
@ -30,36 +29,6 @@ type EmailConfirmTestSuite struct {
UserStandardTestSuite 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() { func (suite *EmailConfirmTestSuite) TestConfirmEmail() {
ctx := context.Background() ctx := context.Background()

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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 := &gtsmodel.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 := &gtsmodel.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{})
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -15,7 +15,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
package processing_test package workers_test
import ( import (
"context" "context"
@ -36,12 +36,12 @@ import (
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
type FromFederatorTestSuite struct { type FromFediAPITestSuite struct {
ProcessingStandardTestSuite WorkersTestSuite
} }
// remote_account_1 boosts the first status of local_account_1 // 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"] boostedStatus := suite.testStatuses["local_account_1_status_1"]
boostingAccount := suite.testAccounts["remote_account_1"] boostingAccount := suite.testAccounts["remote_account_1"]
announceStatus := &gtsmodel.Status{} announceStatus := &gtsmodel.Status{}
@ -56,7 +56,7 @@ func (suite *FromFederatorTestSuite) TestProcessFederationAnnounce() {
announceStatus.Account = boostingAccount announceStatus.Account = boostingAccount
announceStatus.Visibility = boostedStatus.Visibility announceStatus.Visibility = boostedStatus.Visibility
err := suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ err := suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{
APObjectType: ap.ActivityAnnounce, APObjectType: ap.ActivityAnnounce,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: announceStatus, GTSModel: announceStatus,
@ -87,7 +87,7 @@ func (suite *FromFederatorTestSuite) TestProcessFederationAnnounce() {
suite.False(*notif.Read) suite.False(*notif.Read)
} }
func (suite *FromFederatorTestSuite) TestProcessReplyMention() { func (suite *FromFediAPITestSuite) TestProcessReplyMention() {
repliedAccount := suite.testAccounts["local_account_1"] repliedAccount := suite.testAccounts["local_account_1"]
repliedStatus := suite.testStatuses["local_account_1_status_1"] repliedStatus := suite.testStatuses["local_account_1_status_1"]
replyingAccount := suite.testAccounts["remote_account_1"] replyingAccount := suite.testAccounts["remote_account_1"]
@ -128,7 +128,7 @@ func (suite *FromFederatorTestSuite) TestProcessReplyMention() {
err = suite.db.PutStatus(context.Background(), replyingStatus) err = suite.db.PutStatus(context.Background(), replyingStatus)
suite.NoError(err) suite.NoError(err)
err = suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ err = suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{
APObjectType: ap.ObjectNote, APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: replyingStatus, GTSModel: replyingStatus,
@ -173,7 +173,7 @@ func (suite *FromFederatorTestSuite) TestProcessReplyMention() {
suite.Equal(replyingAccount.ID, notifStreamed.Account.ID) suite.Equal(replyingAccount.ID, notifStreamed.Account.ID)
} }
func (suite *FromFederatorTestSuite) TestProcessFave() { func (suite *FromFediAPITestSuite) TestProcessFave() {
favedAccount := suite.testAccounts["local_account_1"] favedAccount := suite.testAccounts["local_account_1"]
favedStatus := suite.testStatuses["local_account_1_status_1"] favedStatus := suite.testStatuses["local_account_1_status_1"]
favingAccount := suite.testAccounts["remote_account_1"] favingAccount := suite.testAccounts["remote_account_1"]
@ -197,7 +197,7 @@ func (suite *FromFederatorTestSuite) TestProcessFave() {
err := suite.db.Put(context.Background(), fave) err := suite.db.Put(context.Background(), fave)
suite.NoError(err) suite.NoError(err)
err = suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ err = suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{
APObjectType: ap.ActivityLike, APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: fave, 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 // 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. // 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"] receivingAccount := suite.testAccounts["local_account_2"]
favedAccount := suite.testAccounts["local_account_1"] favedAccount := suite.testAccounts["local_account_1"]
favedStatus := suite.testStatuses["local_account_1_status_1"] favedStatus := suite.testStatuses["local_account_1_status_1"]
@ -270,7 +270,7 @@ func (suite *FromFederatorTestSuite) TestProcessFaveWithDifferentReceivingAccoun
err := suite.db.Put(context.Background(), fave) err := suite.db.Put(context.Background(), fave)
suite.NoError(err) suite.NoError(err)
err = suite.processor.ProcessFromFederator(context.Background(), messages.FromFederator{ err = suite.processor.Workers().ProcessFromFediAPI(context.Background(), messages.FromFediAPI{
APObjectType: ap.ActivityLike, APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: fave, GTSModel: fave,
@ -304,7 +304,7 @@ func (suite *FromFederatorTestSuite) TestProcessFaveWithDifferentReceivingAccoun
suite.Empty(wssStream.Messages) suite.Empty(wssStream.Messages)
} }
func (suite *FromFederatorTestSuite) TestProcessAccountDelete() { func (suite *FromFediAPITestSuite) TestProcessAccountDelete() {
ctx := context.Background() ctx := context.Background()
deletedAccount := suite.testAccounts["remote_account_1"] deletedAccount := suite.testAccounts["remote_account_1"]
@ -339,7 +339,7 @@ func (suite *FromFederatorTestSuite) TestProcessAccountDelete() {
suite.NoError(err) suite.NoError(err)
// now they are mufos! // now they are mufos!
err = suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ err = suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ObjectProfile, APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityDelete, APActivityType: ap.ActivityDelete,
GTSModel: deletedAccount, GTSModel: deletedAccount,
@ -386,7 +386,7 @@ func (suite *FromFederatorTestSuite) TestProcessAccountDelete() {
suite.Equal(dbAccount.ID, dbAccount.SuspensionOrigin) suite.Equal(dbAccount.ID, dbAccount.SuspensionOrigin)
} }
func (suite *FromFederatorTestSuite) TestProcessFollowRequestLocked() { func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() {
ctx := context.Background() ctx := context.Background()
originAccount := suite.testAccounts["remote_account_1"] originAccount := suite.testAccounts["remote_account_1"]
@ -414,7 +414,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestLocked() {
err := suite.db.Put(ctx, satanFollowRequestTurtle) err := suite.db.Put(ctx, satanFollowRequestTurtle)
suite.NoError(err) suite.NoError(err)
err = suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ err = suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityFollow, APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: satanFollowRequestTurtle, GTSModel: satanFollowRequestTurtle,
@ -443,7 +443,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestLocked() {
suite.Empty(suite.httpClient.SentMessages) suite.Empty(suite.httpClient.SentMessages)
} }
func (suite *FromFederatorTestSuite) TestProcessFollowRequestUnlocked() { func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() {
ctx := context.Background() ctx := context.Background()
originAccount := suite.testAccounts["remote_account_1"] originAccount := suite.testAccounts["remote_account_1"]
@ -471,7 +471,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestUnlocked() {
err := suite.db.Put(ctx, satanFollowRequestTurtle) err := suite.db.Put(ctx, satanFollowRequestTurtle)
suite.NoError(err) suite.NoError(err)
err = suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ err = suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityFollow, APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: satanFollowRequestTurtle, GTSModel: satanFollowRequestTurtle,
@ -539,13 +539,13 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestUnlocked() {
} }
// TestCreateStatusFromIRI checks if a forwarded status can be dereferenced by the processor. // TestCreateStatusFromIRI checks if a forwarded status can be dereferenced by the processor.
func (suite *FromFederatorTestSuite) TestCreateStatusFromIRI() { func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() {
ctx := context.Background() ctx := context.Background()
receivingAccount := suite.testAccounts["local_account_1"] receivingAccount := suite.testAccounts["local_account_1"]
statusCreator := suite.testAccounts["remote_account_2"] statusCreator := suite.testAccounts["remote_account_2"]
err := suite.processor.ProcessFromFederator(ctx, messages.FromFederator{ err := suite.processor.Workers().ProcessFromFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ObjectNote, APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: nil, // gtsmodel is nil because this is a forwarded status -- we want to dereference it using the iri 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) { func TestFromFederatorTestSuite(t *testing.T) {
suite.Run(t, &FromFederatorTestSuite{}) suite.Run(t, &FromFediAPITestSuite{})
} }

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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 := &gtsmodel.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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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, &gtsmodel.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)
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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()
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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,
},
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -156,7 +156,7 @@ type TypeConverter interface {
// URI of the status as object, and addressing the Delete appropriately. // URI of the status as object, and addressing the Delete appropriately.
StatusToASDelete(ctx context.Context, status *gtsmodel.Status) (vocab.ActivityStreamsDelete, error) StatusToASDelete(ctx context.Context, status *gtsmodel.Status) (vocab.ActivityStreamsDelete, error)
// FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation // 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 converts a gts model mention into an activity streams Mention, suitable for federation
MentionToAS(ctx context.Context, m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error) MentionToAS(ctx context.Context, m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error)
// EmojiToAS converts a gts emoji into a mastodon ns Emoji, suitable for federation // EmojiToAS converts a gts emoji into a mastodon ns Emoji, suitable for federation

View file

@ -774,10 +774,14 @@ func (c *converter) StatusToASDelete(ctx context.Context, s *gtsmodel.Status) (v
return delete, nil return delete, nil
} }
func (c *converter) FollowToAS(ctx context.Context, f *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (vocab.ActivityStreamsFollow, error) { func (c *converter) FollowToAS(ctx context.Context, f *gtsmodel.Follow) (vocab.ActivityStreamsFollow, error) {
// parse out the various URIs we need for this if err := c.db.PopulateFollow(ctx, f); err != nil {
// origin account (who's doing the follow) return nil, gtserror.Newf("error populating follow: %w", err)
originAccountURI, err := url.Parse(originAccount.URI) }
// 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 { if err != nil {
return nil, fmt.Errorf("followtoasfollow: error parsing origin account uri: %s", err) 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) originActor.AppendIRI(originAccountURI)
// target account (who's being followed) // target account (who's being followed)
targetAccountURI, err := url.Parse(targetAccount.URI) targetAccountURI, err := url.Parse(f.TargetAccount.URI)
if err != nil { if err != nil {
return nil, fmt.Errorf("followtoasfollow: error parsing target account uri: %s", err) return nil, fmt.Errorf("followtoasfollow: error parsing target account uri: %s", err)
} }

View file

@ -24,6 +24,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/cache" "github.com/superseriousbusiness/gotosocial/internal/cache"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
) )
@ -97,33 +98,17 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A
} }
var ( var (
next *gtsmodel.Status next = status
oneAuthor = true // Assume one author until proven otherwise. oneAuthor = true // Assume one author until proven otherwise.
included bool included bool
converstn bool converstn bool
) )
for next = status; next.InReplyToURI != ""; { for {
// 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)
}
// Populate account mention objects before account mention checks. // Populate account mention objects before account mention checks.
next.Mentions, err = f.state.DB.GetMentions(ctx, next.MentionIDs) next.Mentions, err = f.state.DB.GetMentions(ctx, next.MentionIDs)
if err != nil { 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) || 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? // is it between accounts on owner timeline that they follow?
converstn, err = f.isVisibleConversation(ctx, owner, next) converstn, err = f.isVisibleConversation(ctx, owner, next)
if err != nil { 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 { 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. // Check if this continues to be a single-author thread.
oneAuthor = (next.AccountID == status.AccountID) 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 { if next != status && !oneAuthor && !included && !converstn {
@ -177,7 +182,7 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A
status.AccountID, status.AccountID,
) )
if err != nil { 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 { if !follow {

View file

@ -43,7 +43,7 @@ type Workers struct {
// these are pointers to Processor{}.Enqueue___() msg functions. // these are pointers to Processor{}.Enqueue___() msg functions.
// This prevents dependency cycling as Processor depends on Workers. // This prevents dependency cycling as Processor depends on Workers.
EnqueueClientAPI func(context.Context, ...messages.FromClientAPI) EnqueueClientAPI func(context.Context, ...messages.FromClientAPI)
EnqueueFederator func(context.Context, ...messages.FromFederator) EnqueueFediAPI func(context.Context, ...messages.FromFediAPI)
// Media manager worker pools. // Media manager worker pools.
Media runners.WorkerPool Media runners.WorkerPool

View file

@ -28,7 +28,7 @@ import (
// NewTestProcessor returns a Processor suitable for testing purposes // NewTestProcessor returns a Processor suitable for testing purposes
func NewTestProcessor(state *state.State, federator federation.Federator, emailSender email.Sender, mediaManager *media.Manager) *processing.Processor { 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) p := processing.NewProcessor(NewTestTypeConverter(state.DB), federator, NewTestOauthServer(state.DB), mediaManager, state, emailSender)
state.Workers.EnqueueClientAPI = p.EnqueueClientAPI state.Workers.EnqueueClientAPI = p.Workers().EnqueueClientAPI
state.Workers.EnqueueFederator = p.EnqueueFederator state.Workers.EnqueueFediAPI = p.Workers().EnqueueFediAPI
return p return p
} }

View file

@ -37,7 +37,7 @@ import (
func StartWorkers(state *state.State) { func StartWorkers(state *state.State) {
state.Workers.EnqueueClientAPI = func(context.Context, ...messages.FromClientAPI) {} 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.Scheduler.Start(nil)
_ = state.Workers.ClientAPI.Start(1, 10) _ = state.Workers.ClientAPI.Start(1, 10)