[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 @@
processor := processing.NewProcessor(typeConverter, federator, oauthServer, mediaManager, &state, emailSender)
// Set state client / federator worker enqueue functions
state.Workers.EnqueueClientAPI = processor.EnqueueClientAPI
state.Workers.EnqueueFederator = processor.EnqueueFederator
state.Workers.EnqueueClientAPI = processor.Workers().EnqueueClientAPI
state.Workers.EnqueueFediAPI = processor.Workers().EnqueueFediAPI
/*
HTTP router initialization

View file

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

View file

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

View file

@ -143,11 +143,7 @@ func (l *listDB) PopulateList(ctx context.Context, list *gtsmodel.List) error {
}
}
if err := errs.Combine(); err != nil {
return gtserror.Newf("%w", err)
}
return nil
return errs.Combine()
}
func (l *listDB) PutList(ctx context.Context, list *gtsmodel.List) error {
@ -503,6 +499,22 @@ func (l *listDB) DeleteListEntriesForFollowID(ctx context.Context, followID stri
return nil
}
func (l *listDB) ListIncludesAccount(ctx context.Context, listID string, accountID string) (bool, error) {
exists, err := l.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("list_entries"), bun.Ident("list_entry")).
Join(
"JOIN ? AS ? ON ? = ?",
bun.Ident("follows"), bun.Ident("follow"),
bun.Ident("list_entry.follow_id"), bun.Ident("follow.id"),
).
Where("? = ?", bun.Ident("list_entry.list_id"), listID).
Where("? = ?", bun.Ident("follow.target_account_id"), accountID).
Exists(ctx)
return exists, l.db.ProcessError(err)
}
// collate will collect the values of type T from an expected slice of length 'len',
// passing the expected index to each call of 'get' and deduplicating the end result.
func collate[T comparable](get func(int) T, len int) []T {

View file

@ -310,6 +310,27 @@ func (suite *ListTestSuite) TestDeleteListEntriesForFollowID() {
suite.checkList(testList, dbList)
}
func (suite *ListTestSuite) TestListIncludesAccount() {
ctx := context.Background()
testList, _ := suite.testStructs()
for accountID, expected := range map[string]bool{
suite.testAccounts["admin_account"].ID: true,
suite.testAccounts["local_account_1"].ID: false,
suite.testAccounts["local_account_2"].ID: true,
"01H7074GEZJ56J5C86PFB0V2CT": false,
} {
includes, err := suite.db.ListIncludesAccount(ctx, testList.ID, accountID)
if err != nil {
suite.FailNow(err.Error())
}
if includes != expected {
suite.FailNow("", "expected %t for accountID %s got %t", expected, accountID, includes)
}
}
}
func TestListTestSuite(t *testing.T) {
suite.Run(t, new(ListTestSuite))
}

View file

@ -20,10 +20,10 @@
import (
"context"
"errors"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun"
@ -139,27 +139,44 @@ func (r *relationshipDB) getBlock(ctx context.Context, lookup string, dbQuery fu
return block, nil
}
// Set the block source account
block.Account, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
block.AccountID,
)
if err != nil {
return nil, fmt.Errorf("error getting block source account: %w", err)
}
// Set the block target account
block.TargetAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
block.TargetAccountID,
)
if err != nil {
return nil, fmt.Errorf("error getting block target account: %w", err)
if err := r.state.DB.PopulateBlock(ctx, block); err != nil {
return nil, err
}
return block, nil
}
func (r *relationshipDB) PopulateBlock(ctx context.Context, block *gtsmodel.Block) error {
var (
err error
errs = gtserror.NewMultiError(2)
)
if block.Account == nil {
// Block origin account is not set, fetch from database.
block.Account, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
block.AccountID,
)
if err != nil {
errs.Appendf("error populating block account: %w", err)
}
}
if block.TargetAccount == nil {
// Block target account is not set, fetch from database.
block.TargetAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
block.TargetAccountID,
)
if err != nil {
errs.Appendf("error populating block target account: %w", err)
}
}
return errs.Combine()
}
func (r *relationshipDB) PutBlock(ctx context.Context, block *gtsmodel.Block) error {
return r.state.Caches.GTS.Block().Store(block, func() error {
_, err := r.db.NewInsert().Model(block).Exec(ctx)

View file

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

View file

@ -20,11 +20,11 @@
import (
"context"
"errors"
"fmt"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun"
@ -127,27 +127,44 @@ func (r *relationshipDB) getFollowRequest(ctx context.Context, lookup string, db
return followReq, nil
}
// Set the follow request source account
followReq.Account, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
followReq.AccountID,
)
if err != nil {
return nil, fmt.Errorf("error getting follow request source account: %w", err)
}
// Set the follow request target account
followReq.TargetAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
followReq.TargetAccountID,
)
if err != nil {
return nil, fmt.Errorf("error getting follow request target account: %w", err)
if err := r.state.DB.PopulateFollowRequest(ctx, followReq); err != nil {
return nil, err
}
return followReq, nil
}
func (r *relationshipDB) PopulateFollowRequest(ctx context.Context, follow *gtsmodel.FollowRequest) error {
var (
err error
errs = gtserror.NewMultiError(2)
)
if follow.Account == nil {
// Follow account is not set, fetch from the database.
follow.Account, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
follow.AccountID,
)
if err != nil {
errs.Appendf("error populating follow request account: %w", err)
}
}
if follow.TargetAccount == nil {
// Follow target account is not set, fetch from the database.
follow.TargetAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
follow.TargetAccountID,
)
if err != nil {
errs.Appendf("error populating follow target request account: %w", err)
}
}
return errs.Combine()
}
func (r *relationshipDB) PutFollowRequest(ctx context.Context, follow *gtsmodel.FollowRequest) error {
return r.state.Caches.GTS.FollowRequest().Store(follow, func() error {
_, err := r.db.NewInsert().Model(follow).Exec(ctx)

View file

@ -20,11 +20,11 @@
import (
"context"
"errors"
"fmt"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
@ -135,37 +135,72 @@ func (r *reportDB) getReport(ctx context.Context, lookup string, dbQuery func(*g
return nil, err
}
// Set the report author account
report.Account, err = r.state.DB.GetAccountByID(ctx, report.AccountID)
if err != nil {
return nil, fmt.Errorf("error getting report account: %w", err)
if gtscontext.Barebones(ctx) {
// Only a barebones model was requested.
return report, nil
}
// Set the report target account
report.TargetAccount, err = r.state.DB.GetAccountByID(ctx, report.TargetAccountID)
if err != nil {
return nil, fmt.Errorf("error getting report target account: %w", err)
}
if len(report.StatusIDs) > 0 {
// Fetch reported statuses
report.Statuses, err = r.state.DB.GetStatusesByIDs(ctx, report.StatusIDs)
if err != nil {
return nil, fmt.Errorf("error getting status mentions: %w", err)
}
}
if report.ActionTakenByAccountID != "" {
// Set the report action taken by account
report.ActionTakenByAccount, err = r.state.DB.GetAccountByID(ctx, report.ActionTakenByAccountID)
if err != nil {
return nil, fmt.Errorf("error getting report action taken by account: %w", err)
}
if err := r.state.DB.PopulateReport(ctx, report); err != nil {
return nil, err
}
return report, nil
}
func (r *reportDB) PopulateReport(ctx context.Context, report *gtsmodel.Report) error {
var (
err error
errs = gtserror.NewMultiError(4)
)
if report.Account == nil {
// Report account is not set, fetch from the database.
report.Account, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
report.AccountID,
)
if err != nil {
errs.Appendf("error populating report account: %w", err)
}
}
if report.TargetAccount == nil {
// Report target account is not set, fetch from the database.
report.TargetAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
report.TargetAccountID,
)
if err != nil {
errs.Appendf("error populating report target account: %w", err)
}
}
if l := len(report.StatusIDs); l > 0 && l != len(report.Statuses) {
// Report target statuses not set, fetch from the database.
report.Statuses, err = r.state.DB.GetStatusesByIDs(
gtscontext.SetBarebones(ctx),
report.StatusIDs,
)
if err != nil {
errs.Appendf("error populating report statuses: %w", err)
}
}
if report.ActionTakenByAccountID != "" &&
report.ActionTakenByAccount == nil {
// Report action account is not set, fetch from the database.
report.ActionTakenByAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
report.ActionTakenByAccountID,
)
if err != nil {
errs.Appendf("error populating report action taken by account: %w", err)
}
}
return errs.Combine()
}
func (r *reportDB) PutReport(ctx context.Context, report *gtsmodel.Report) error {
return r.state.Caches.GTS.Report().Store(report, func() error {
_, err := r.db.NewInsert().Model(report).Exec(ctx)

View file

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

View file

@ -64,4 +64,7 @@ type List interface {
// DeleteListEntryForFollowID deletes all list entries with the given followID.
DeleteListEntriesForFollowID(ctx context.Context, followID string) error
// ListIncludesAccount returns true if the given listID includes the given accountID.
ListIncludesAccount(ctx context.Context, listID string, accountID string) (bool, error)
}

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(ctx context.Context, account1 string, account2 string) (*gtsmodel.Block, error)
// PopulateBlock populates the struct pointers on the given block.
PopulateBlock(ctx context.Context, block *gtsmodel.Block) error
// PutBlock attempts to place the given account block in the database.
PutBlock(ctx context.Context, block *gtsmodel.Block) error
@ -77,6 +80,9 @@ type Relationship interface {
// GetFollowRequest retrieves a follow request if it exists between source and target accounts.
GetFollowRequest(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.FollowRequest, error)
// PopulateFollowRequest populates the struct pointers on the given follow request.
PopulateFollowRequest(ctx context.Context, follow *gtsmodel.FollowRequest) error
// IsFollowing returns true if sourceAccount follows target account, or an error if something goes wrong while finding out.
IsFollowing(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error)

View file

@ -27,17 +27,24 @@
type Report interface {
// GetReportByID gets one report by its db id
GetReportByID(ctx context.Context, id string) (*gtsmodel.Report, error)
// GetReports gets limit n reports using the given parameters.
// Parameters that are empty / zero are ignored.
GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, error)
// PopulateReport populates the struct pointers on the given report.
PopulateReport(ctx context.Context, report *gtsmodel.Report) error
// PutReport puts the given report in the database.
PutReport(ctx context.Context, report *gtsmodel.Report) error
// UpdateReport updates one report by its db id.
// The given columns will be updated; if no columns are
// provided, then all columns will be updated.
// updated_at will also be updated, no need to pass this
// as a specific column.
UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, error)
// DeleteReportByID deletes report with the given id.
DeleteReportByID(ctx context.Context, id string) error
}

View file

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

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.
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityAnnounce,
APActivityType: ap.ActivityCreate,
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)
}
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityBlock,
APActivityType: ap.ActivityCreate,
GTSModel: block,
@ -233,7 +233,7 @@ func (f *federatingDB) createStatusable(
if forward {
// Pass the statusable URI (APIri) into the processor worker
// and do the rest of the processing asynchronously.
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate,
APIri: statusableURI,
@ -291,7 +291,7 @@ func (f *federatingDB) createStatusable(
// Do the rest of the processing asynchronously. The processor
// will handle inserting/updating + further dereferencing the status.
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate,
APIri: nil,
@ -344,7 +344,7 @@ func (f *federatingDB) activityFollow(ctx context.Context, asType vocab.Type, re
return fmt.Errorf("activityFollow: database error inserting follow request: %s", err)
}
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityCreate,
GTSModel: followRequest,
@ -381,7 +381,7 @@ func (f *federatingDB) activityLike(ctx context.Context, asType vocab.Type, rece
return fmt.Errorf("activityLike: database error inserting fave: %w", err)
}
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityCreate,
GTSModel: fave,
@ -412,7 +412,7 @@ func (f *federatingDB) activityFlag(ctx context.Context, asType vocab.Type, rece
return fmt.Errorf("activityFlag: database error inserting report: %w", err)
}
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ActivityFlag,
APActivityType: ap.ActivityCreate,
GTSModel: report,

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...
if s, err := f.state.DB.GetStatusByURI(ctx, id.String()); err == nil && requestingAccount.ID == s.AccountID {
l.Debugf("uri is for STATUS with id: %s", s.ID)
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityDelete,
GTSModel: s,
@ -59,7 +59,7 @@ func (f *federatingDB) Delete(ctx context.Context, id *url.URL) error {
if a, err := f.state.DB.GetAccountByURI(ctx, id.String()); err == nil && requestingAccount.ID == a.ID {
l.Debugf("uri is for ACCOUNT with id %s", a.ID)
f.state.Workers.EnqueueFederator(ctx, messages.FromFederator{
f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{
APObjectType: ap.ObjectProfile,
APActivityType: ap.ActivityDelete,
GTSModel: a,

View file

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

View file

@ -52,7 +52,7 @@ func (suite *RejectTestSuite) TestRejectFollowRequest() {
err := suite.db.Put(ctx, fr)
suite.NoError(err)
asFollow, err := suite.tc.FollowToAS(ctx, suite.tc.FollowRequestToFollow(ctx, fr), followingAccount, followedAccount)
asFollow, err := suite.tc.FollowToAS(ctx, suite.tc.FollowRequestToFollow(ctx, fr))
suite.NoError(err)
rejectingAccountURI := testrig.URLMustParse(followedAccount.URI)

View file

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

View file

@ -21,16 +21,34 @@
"net/http"
)
// New returns a new error, prepended with caller function name if gtserror.Caller is enabled.
// New returns a new error, prepended with caller
// function name if gtserror.Caller is enabled.
func New(msg string) error {
return newAt(3, msg)
}
// Newf returns a new formatted error, prepended with caller function name if gtserror.Caller is enabled.
// Newf returns a new formatted error, prepended with
// caller function name if gtserror.Caller is enabled.
func Newf(msgf string, args ...any) error {
return newfAt(3, msgf, args...)
}
// NewfAt returns a new formatted error with the given
// calldepth+1, useful when you want to wrap an error
// from within an anonymous function or utility function,
// but preserve the name in the error of the wrapping
// function that did the calling.
//
// Provide calldepth 2 to prepend only the name of the
// current containing function, 3 to prepend the name
// of the function containing *that* function, and so on.
//
// This function is just exposed for dry-dick optimization
// purposes. Most callers should just call Newf instead.
func NewfAt(calldepth int, msgf string, args ...any) error {
return newfAt(calldepth+1, msgf, args...)
}
// NewResponseError crafts an error from provided HTTP response
// including the method, status and body (if any provided). This
// will also wrap the returned error using WithStatusCode() and

View file

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

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
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/log"
mm "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
"github.com/superseriousbusiness/gotosocial/internal/processing/admin"
@ -38,19 +34,23 @@
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
"github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
"github.com/superseriousbusiness/gotosocial/internal/processing/user"
"github.com/superseriousbusiness/gotosocial/internal/processing/workers"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
)
// Processor groups together processing functions and
// sub processors for handling actions + events coming
// from either the client or federating APIs.
//
// Many of the functions available through this struct
// or sub processors will trigger asynchronous processing
// via the workers contained in state.
type Processor struct {
federator federation.Federator
tc typeutils.TypeConverter
oauthServer oauth.Server
mediaManager *mm.Manager
state *state.State
emailSender email.Sender
filter *visibility.Filter
tc typeutils.TypeConverter
oauthServer oauth.Server
state *state.State
/*
SUB-PROCESSORS
@ -68,6 +68,7 @@ type Processor struct {
stream stream.Processor
timeline timeline.Processor
user user.Processor
workers workers.Processor
}
func (p *Processor) Account() *account.Processor {
@ -118,6 +119,10 @@ func (p *Processor) User() *user.Processor {
return &p.user
}
func (p *Processor) Workers() *workers.Processor {
return &p.workers
}
// NewProcessor returns a new Processor.
func NewProcessor(
tc typeutils.TypeConverter,
@ -127,57 +132,53 @@ func NewProcessor(
state *state.State,
emailSender email.Sender,
) *Processor {
parseMentionFunc := GetParseMentionFunc(state.DB, federator)
filter := visibility.NewFilter(state)
var (
parseMentionFunc = GetParseMentionFunc(state.DB, federator)
filter = visibility.NewFilter(state)
)
processor := &Processor{
federator: federator,
tc: tc,
oauthServer: oauthServer,
mediaManager: mediaManager,
state: state,
filter: filter,
emailSender: emailSender,
tc: tc,
oauthServer: oauthServer,
state: state,
}
// Instantiate sub processors.
processor.account = account.New(state, tc, mediaManager, oauthServer, federator, filter, parseMentionFunc)
//
// Start with sub processors that will
// be required by the workers processor.
accountProcessor := account.New(state, tc, mediaManager, oauthServer, federator, filter, parseMentionFunc)
mediaProcessor := media.New(state, tc, mediaManager, federator.TransportController())
streamProcessor := stream.New(state, oauthServer)
// Instantiate the rest of the sub
// processors + pin them to this struct.
processor.account = accountProcessor
processor.admin = admin.New(state, tc, mediaManager, federator.TransportController(), emailSender)
processor.fedi = fedi.New(state, tc, federator, filter)
processor.list = list.New(state, tc)
processor.markers = markers.New(state, tc)
processor.media = media.New(state, tc, mediaManager, federator.TransportController())
processor.media = mediaProcessor
processor.report = report.New(state, tc)
processor.timeline = timeline.New(state, tc, filter)
processor.search = search.New(state, federator, tc, filter)
processor.status = status.New(state, federator, tc, filter, parseMentionFunc)
processor.stream = stream.New(state, oauthServer)
processor.stream = streamProcessor
processor.user = user.New(state, emailSender)
// Workers processor handles asynchronous
// worker jobs; instantiate it separately
// and pass subset of sub processors it needs.
processor.workers = workers.New(
state,
federator,
tc,
filter,
emailSender,
&accountProcessor,
&mediaProcessor,
&streamProcessor,
)
return processor
}
func (p *Processor) EnqueueClientAPI(ctx context.Context, msgs ...messages.FromClientAPI) {
log.Trace(ctx, "enqueuing")
_ = p.state.Workers.ClientAPI.MustEnqueueCtx(ctx, func(ctx context.Context) {
for _, msg := range msgs {
log.Trace(ctx, "processing: %+v", msg)
if err := p.ProcessFromClientAPI(ctx, msg); err != nil {
log.Errorf(ctx, "error processing client API message: %v", err)
}
}
})
}
func (p *Processor) EnqueueFederator(ctx context.Context, msgs ...messages.FromFederator) {
log.Trace(ctx, "enqueuing")
_ = p.state.Workers.Federator.MustEnqueueCtx(ctx, func(ctx context.Context) {
for _, msg := range msgs {
log.Trace(ctx, "processing: %+v", msg)
if err := p.ProcessFromFederator(ctx, msg); err != nil {
log.Errorf(ctx, "error processing federator message: %v", err)
}
}
})
}

View file

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

View file

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

View file

@ -23,67 +23,13 @@
"fmt"
"time"
"github.com/google/uuid"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/uris"
)
var oneWeek = 168 * time.Hour
// EmailSendConfirmation sends an email address confirmation request email to the given user.
func (p *Processor) EmailSendConfirmation(ctx context.Context, user *gtsmodel.User, username string) error {
if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email {
// user has already confirmed this email address, so there's nothing to do
return nil
}
// We need a token and a link for the user to click on.
// We'll use a uuid as our token since it's basically impossible to guess.
// From the uuid package we use (which uses crypto/rand under the hood):
// Randomly generated UUIDs have 122 random bits. One's annual risk of being
// hit by a meteorite is estimated to be one chance in 17 billion, that
// means the probability is about 0.00000000006 (6 × 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
// in a 'confirm your email address' type email.
func (p *Processor) EmailConfirm(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) {

View file

@ -19,7 +19,6 @@
import (
"context"
"fmt"
"testing"
"time"
@ -30,36 +29,6 @@ type EmailConfirmTestSuite struct {
UserStandardTestSuite
}
func (suite *EmailConfirmTestSuite) TestSendConfirmEmail() {
user := suite.testUsers["local_account_1"]
// set a bunch of stuff on the user as though zork hasn't been confirmed (perish the thought)
user.UnconfirmedEmail = "some.email@example.org"
user.Email = ""
user.ConfirmedAt = time.Time{}
user.ConfirmationSentAt = time.Time{}
user.ConfirmationToken = ""
err := suite.user.EmailSendConfirmation(context.Background(), user, "the_mighty_zork")
suite.NoError(err)
// zork should have an email now
suite.Len(suite.sentEmails, 1)
email, ok := suite.sentEmails["some.email@example.org"]
suite.True(ok)
// a token should be set on zork
token := user.ConfirmationToken
suite.NotEmpty(token)
// email should contain the token
emailShould := fmt.Sprintf("To: some.email@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello the_mighty_zork!\r\n\r\nYou are receiving this mail because you've requested an account on http://localhost:8080.\r\n\r\nWe just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:\r\n\r\nhttp://localhost:8080/confirm_email?token=%s\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of http://localhost:8080\r\n\r\n", token)
suite.Equal(emailShould, email)
// confirmationSentAt should be recent
suite.WithinDuration(time.Now(), user.ConfirmationSentAt, 1*time.Minute)
}
func (suite *EmailConfirmTestSuite) TestConfirmEmail() {
ctx := context.Background()

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

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.
StatusToASDelete(ctx context.Context, status *gtsmodel.Status) (vocab.ActivityStreamsDelete, error)
// FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation
FollowToAS(ctx context.Context, f *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (vocab.ActivityStreamsFollow, error)
FollowToAS(ctx context.Context, f *gtsmodel.Follow) (vocab.ActivityStreamsFollow, error)
// MentionToAS converts a gts model mention into an activity streams Mention, suitable for federation
MentionToAS(ctx context.Context, m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error)
// EmojiToAS converts a gts emoji into a mastodon ns Emoji, suitable for federation

View file

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

View file

@ -24,6 +24,7 @@
"github.com/superseriousbusiness/gotosocial/internal/cache"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
@ -97,33 +98,17 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A
}
var (
next *gtsmodel.Status
next = status
oneAuthor = true // Assume one author until proven otherwise.
included bool
converstn bool
)
for next = status; next.InReplyToURI != ""; {
// Fetch next parent to lookup.
parentID := next.InReplyToID
if parentID == "" {
log.Warnf(ctx, "status not yet deref'd: %s", next.InReplyToURI)
return false, cache.SentinelError
}
// Get the next parent in the chain from DB.
next, err = f.state.DB.GetStatusByID(
gtscontext.SetBarebones(ctx),
parentID,
)
if err != nil {
return false, fmt.Errorf("isStatusHomeTimelineable: error getting status parent %s: %w", parentID, err)
}
for {
// Populate account mention objects before account mention checks.
next.Mentions, err = f.state.DB.GetMentions(ctx, next.MentionIDs)
if err != nil {
return false, fmt.Errorf("isStatusHomeTimelineable: error populating status parent %s mentions: %w", parentID, err)
return false, gtserror.Newf("error populating status %s mentions: %w", next.ID, err)
}
if (next.AccountID == owner.ID) ||
@ -139,7 +124,7 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A
// is it between accounts on owner timeline that they follow?
converstn, err = f.isVisibleConversation(ctx, owner, next)
if err != nil {
return false, fmt.Errorf("isStatusHomeTimelineable: error checking conversation visibility: %w", err)
return false, gtserror.Newf("error checking conversation visibility: %w", err)
}
if converstn {
@ -152,6 +137,26 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A
// Check if this continues to be a single-author thread.
oneAuthor = (next.AccountID == status.AccountID)
}
if next.InReplyToURI == "" {
// Reached the top of the thread.
break
}
// Fetch next parent in thread.
parentID := next.InReplyToID
if parentID == "" {
log.Warnf(ctx, "status not yet deref'd: %s", next.InReplyToURI)
return false, cache.SentinelError
}
next, err = f.state.DB.GetStatusByID(
gtscontext.SetBarebones(ctx),
parentID,
)
if err != nil {
return false, gtserror.Newf("error getting status parent %s: %w", parentID, err)
}
}
if next != status && !oneAuthor && !included && !converstn {
@ -177,7 +182,7 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A
status.AccountID,
)
if err != nil {
return false, fmt.Errorf("isStatusHomeTimelineable: error checking follow %s->%s: %w", owner.ID, status.AccountID, err)
return false, gtserror.Newf("error checking follow %s->%s: %w", owner.ID, status.AccountID, err)
}
if !follow {

View file

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

View file

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

View file

@ -37,7 +37,7 @@
func StartWorkers(state *state.State) {
state.Workers.EnqueueClientAPI = func(context.Context, ...messages.FromClientAPI) {}
state.Workers.EnqueueFederator = func(context.Context, ...messages.FromFederator) {}
state.Workers.EnqueueFediAPI = func(context.Context, ...messages.FromFediAPI) {}
_ = state.Workers.Scheduler.Start(nil)
_ = state.Workers.ClientAPI.Start(1, 10)