mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[feature] Add List functionality (#1802)
* start working on lists * further list work * test list db functions nicely * more work on lists * peepoopeepoo * poke * start list timeline func * we're getting there lads * couldn't be me working on stuff... could it? * hook up handlers * fiddling * weeee * woah * screaming, pissing * fix streaming being a whiny baby * lint, small test fix, swagger * tidying up, testing * fucked! by the linter * move timelines to state like a boss * add timeline start to tests using state * invalidate lists
This commit is contained in:
@@ -88,6 +88,13 @@ func (suite *AccountStandardTestSuite) SetupTest() {
|
||||
suite.db = testrig.NewTestDB(&suite.state)
|
||||
suite.state.DB = suite.db
|
||||
suite.tc = testrig.NewTestTypeConverter(suite.db)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
|
107
internal/processing/account/lists.go
Normal file
107
internal/processing/account/lists.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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 account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"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"
|
||||
)
|
||||
|
||||
var noLists = make([]*apimodel.List, 0)
|
||||
|
||||
// ListsGet returns all lists owned by requestingAccount, which contain a follow for targetAccountID.
|
||||
func (p *Processor) ListsGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]*apimodel.List, gtserror.WithCode) {
|
||||
targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.NewErrorNotFound(errors.New("account not found"))
|
||||
}
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err))
|
||||
}
|
||||
|
||||
visible, err := p.filter.AccountVisible(ctx, requestingAccount, targetAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err))
|
||||
}
|
||||
|
||||
if !visible {
|
||||
return nil, gtserror.NewErrorNotFound(errors.New("account not found"))
|
||||
}
|
||||
|
||||
// Requester has to follow targetAccount
|
||||
// for them to be in any of their lists.
|
||||
follow, err := p.state.DB.GetFollow(
|
||||
// Don't populate follow.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
requestingAccount.ID,
|
||||
targetAccountID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err))
|
||||
}
|
||||
|
||||
if follow == nil {
|
||||
return noLists, nil // by definition we know they're in no lists
|
||||
}
|
||||
|
||||
listEntries, err := p.state.DB.GetListEntriesForFollowID(
|
||||
// Don't populate entries.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
follow.ID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error: %w", err))
|
||||
}
|
||||
|
||||
count := len(listEntries)
|
||||
if count == 0 {
|
||||
return noLists, nil
|
||||
}
|
||||
|
||||
apiLists := make([]*apimodel.List, 0, count)
|
||||
for _, listEntry := range listEntries {
|
||||
list, err := p.state.DB.GetListByID(
|
||||
// Don't populate list.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
listEntry.ListID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping list %s due to error %q", listEntry.ListID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
apiList, err := p.tc.ListToAPIList(ctx, list)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping list %s due to error %q", listEntry.ListID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
apiLists = append(apiLists, apiList)
|
||||
}
|
||||
|
||||
return apiLists, nil
|
||||
}
|
@@ -217,10 +217,10 @@ func (p *Processor) processCreateBlockFromClientAPI(ctx context.Context, clientM
|
||||
}
|
||||
|
||||
// remove any of the blocking account's statuses from the blocked account's timeline, and vice versa
|
||||
if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil {
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil {
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@@ -20,6 +20,7 @@ package processing_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
@@ -36,24 +37,21 @@ 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() {
|
||||
ctx := context.Background()
|
||||
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]
|
||||
)
|
||||
|
||||
// let's say that the admin account posts a new status: it should end up in the
|
||||
// timeline of any account that follows it and has a stream open
|
||||
postingAccount := suite.testAccounts["admin_account"]
|
||||
receivingAccount := suite.testAccounts["local_account_1"]
|
||||
|
||||
// open a home timeline stream for zork
|
||||
wssStream, errWithCode := suite.processor.Stream().Open(ctx, receivingAccount, stream.TimelineHome)
|
||||
suite.NoError(errWithCode)
|
||||
|
||||
// open another stream for zork, but for a different timeline;
|
||||
// this shouldn't get stuff streamed into it, since it's for the public timeline
|
||||
irrelevantStream, errWithCode := suite.processor.Stream().Open(ctx, receivingAccount, stream.TimelinePublic)
|
||||
suite.NoError(errWithCode)
|
||||
|
||||
// make a new status from admin account
|
||||
// Make a new status from admin account.
|
||||
newStatus := >smodel.Status{
|
||||
ID: "01FN4B2F88TF9676DYNXWE1WSS",
|
||||
URI: "http://localhost:8080/users/admin/statuses/01FN4B2F88TF9676DYNXWE1WSS",
|
||||
@@ -82,87 +80,110 @@ func (suite *FromClientAPITestSuite) TestProcessStreamNewStatus() {
|
||||
ActivityStreamsType: ap.ObjectNote,
|
||||
}
|
||||
|
||||
// put the status in the db first, to mimic what would have already happened earlier up the flow
|
||||
err := suite.db.PutStatus(ctx, newStatus)
|
||||
suite.NoError(err)
|
||||
// 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
|
||||
err = suite.processor.ProcessFromClientAPI(ctx, messages.FromClientAPI{
|
||||
// Process the new status.
|
||||
if err := suite.processor.ProcessFromClientAPI(ctx, messages.FromClientAPI{
|
||||
APObjectType: ap.ObjectNote,
|
||||
APActivityType: ap.ActivityCreate,
|
||||
GTSModel: newStatus,
|
||||
OriginAccount: postingAccount,
|
||||
})
|
||||
suite.NoError(err)
|
||||
}); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// zork's stream should have the newly created status in it now
|
||||
msg := <-wssStream.Messages
|
||||
suite.Equal(stream.EventTypeUpdate, msg.Event)
|
||||
suite.NotEmpty(msg.Payload)
|
||||
suite.EqualValues([]string{stream.TimelineHome}, msg.Stream)
|
||||
statusStreamed := &apimodel.Status{}
|
||||
err = json.Unmarshal([]byte(msg.Payload), statusStreamed)
|
||||
suite.NoError(err)
|
||||
suite.Equal("01FN4B2F88TF9676DYNXWE1WSS", statusStreamed.ID)
|
||||
suite.Equal("this status should stream :)", statusStreamed.Content)
|
||||
// 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.
|
||||
|
||||
// and stream should now be empty
|
||||
suite.Empty(wssStream.Messages)
|
||||
// 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)
|
||||
|
||||
// the irrelevant messages stream should also be empty
|
||||
suite.Empty(irrelevantStream.Messages)
|
||||
// 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() {
|
||||
ctx := context.Background()
|
||||
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]
|
||||
)
|
||||
|
||||
deletingAccount := suite.testAccounts["local_account_1"]
|
||||
receivingAccount := suite.testAccounts["local_account_2"]
|
||||
// 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())
|
||||
}
|
||||
|
||||
deletedStatus := suite.testStatuses["local_account_1_status_1"]
|
||||
boostOfDeletedStatus := suite.testStatuses["admin_account_status_4"]
|
||||
|
||||
// open a home timeline stream for turtle, who follows zork
|
||||
wssStream, errWithCode := suite.processor.Stream().Open(ctx, receivingAccount, stream.TimelineHome)
|
||||
suite.NoError(errWithCode)
|
||||
|
||||
// delete the status from the db first, to mimic what would have already happened earlier up the flow
|
||||
err := suite.db.DeleteStatusByID(ctx, deletedStatus.ID)
|
||||
suite.NoError(err)
|
||||
|
||||
// process the status delete
|
||||
err = suite.processor.ProcessFromClientAPI(ctx, messages.FromClientAPI{
|
||||
// Process the status delete.
|
||||
if err := suite.processor.ProcessFromClientAPI(ctx, messages.FromClientAPI{
|
||||
APObjectType: ap.ObjectNote,
|
||||
APActivityType: ap.ActivityDelete,
|
||||
GTSModel: deletedStatus,
|
||||
OriginAccount: deletingAccount,
|
||||
})
|
||||
suite.NoError(err)
|
||||
}); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// turtle's stream should have the delete of admin's boost in it now
|
||||
msg := <-wssStream.Messages
|
||||
// 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)
|
||||
|
||||
// turtle's stream should also have the delete of the message itself in it
|
||||
msg = <-wssStream.Messages
|
||||
// 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(wssStream.Messages)
|
||||
// Stream should now be empty.
|
||||
suite.Empty(homeStream.Messages)
|
||||
|
||||
// the boost should no longer be in the database
|
||||
_, err = suite.db.GetStatusByID(ctx, boostOfDeletedStatus.ID)
|
||||
suite.ErrorIs(err, db.ErrNoEntries)
|
||||
// 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() {
|
||||
ctx := context.Background()
|
||||
postingAccount := suite.testAccounts["admin_account"]
|
||||
receivingAccount := suite.testAccounts["local_account_1"]
|
||||
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.
|
||||
@@ -204,8 +225,9 @@ func (suite *FromClientAPITestSuite) TestProcessNewStatusWithNotification() {
|
||||
|
||||
// Put the status in the db first, to mimic what
|
||||
// would have already happened earlier up the flow.
|
||||
err := suite.db.PutStatus(ctx, newStatus)
|
||||
suite.NoError(err)
|
||||
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{
|
||||
@@ -230,6 +252,19 @@ func (suite *FromClientAPITestSuite) TestProcessNewStatusWithNotification() {
|
||||
}) {
|
||||
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) {
|
||||
|
@@ -30,12 +30,14 @@ import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/stream"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
)
|
||||
|
||||
// timelineAndNotifyStatus processes the given new status and inserts it into
|
||||
// the HOME 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 a notif when this account posts.
|
||||
// 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 {
|
||||
@@ -89,10 +91,43 @@ func (p *Processor) timelineAndNotifyStatusForFollowers(ctx context.Context, sta
|
||||
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.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: 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.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: error list timelining status: %w", err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Add status to home timeline for this
|
||||
// follower, and stream it if applicable.
|
||||
if timelined, err := p.timelineStatusForAccount(ctx, follow.Account, status); err != nil {
|
||||
errs.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: error timelining status: %w", err))
|
||||
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.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: error home timelining status: %w", err))
|
||||
continue
|
||||
} else if !timelined {
|
||||
// Status wasn't added to home tomeline,
|
||||
@@ -133,13 +168,21 @@ func (p *Processor) timelineAndNotifyStatusForFollowers(ctx context.Context, sta
|
||||
return errs.Combine()
|
||||
}
|
||||
|
||||
// timelineStatusForAccount puts the given status in the HOME timeline
|
||||
// of the account with given accountID, if it's HomeTimelineable.
|
||||
// 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 home timeline of the given account,
|
||||
// true will be returned + it will also be streamed via websockets to the user.
|
||||
func (p *Processor) timelineStatusForAccount(ctx context.Context, account *gtsmodel.Account, status *gtsmodel.Status) (bool, error) {
|
||||
// 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 = fmt.Errorf("timelineStatusForAccount: error getting timelineability for status for timeline with id %s: %w", account.ID, err)
|
||||
return false, err
|
||||
@@ -148,8 +191,8 @@ func (p *Processor) timelineStatusForAccount(ctx context.Context, account *gtsmo
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Insert status in the home timeline of account.
|
||||
if inserted, err := p.statusTimelines.IngestOne(ctx, account.ID, status); err != nil {
|
||||
// Ingest status into given timeline using provided function.
|
||||
if inserted, err := ingest(ctx, timelineID, status); err != nil {
|
||||
err = fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %w", status.ID, err)
|
||||
return false, err
|
||||
} else if !inserted {
|
||||
@@ -164,7 +207,7 @@ func (p *Processor) timelineStatusForAccount(ctx context.Context, account *gtsmo
|
||||
return true, err
|
||||
}
|
||||
|
||||
if err := p.stream.Update(apiStatus, account, stream.TimelineHome); err != nil {
|
||||
if err := p.stream.Update(apiStatus, account, []string{streamType}); err != nil {
|
||||
err = fmt.Errorf("timelineStatusForAccount: error streaming update for status %s: %w", status.ID, err)
|
||||
return true, err
|
||||
}
|
||||
@@ -401,7 +444,7 @@ func (p *Processor) wipeStatus(ctx context.Context, statusToDelete *gtsmodel.Sta
|
||||
// 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, status *gtsmodel.Status) error {
|
||||
if err := p.statusTimelines.WipeItemFromAllTimelines(ctx, status.ID); err != nil {
|
||||
if err := p.state.Timelines.Home.WipeItemFromAllTimelines(ctx, status.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@@ -342,10 +342,10 @@ func (p *Processor) processCreateBlockFromFederator(ctx context.Context, federat
|
||||
}
|
||||
|
||||
// remove any of the blocking account's statuses from the blocked account's timeline, and vice versa
|
||||
if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil {
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil {
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO: same with notifications
|
||||
|
50
internal/processing/list/create.go
Normal file
50
internal/processing/list/create.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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 list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
)
|
||||
|
||||
// Create creates one a new list for the given account, using the provided parameters.
|
||||
// These params should have already been validated by the time they reach this function.
|
||||
func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, title string, repliesPolicy gtsmodel.RepliesPolicy) (*apimodel.List, gtserror.WithCode) {
|
||||
list := >smodel.List{
|
||||
ID: id.NewULID(),
|
||||
Title: title,
|
||||
AccountID: account.ID,
|
||||
RepliesPolicy: repliesPolicy,
|
||||
}
|
||||
|
||||
if err := p.state.DB.PutList(ctx, list); err != nil {
|
||||
if errors.Is(err, db.ErrAlreadyExists) {
|
||||
err = errors.New("you already have a list with this title")
|
||||
return nil, gtserror.NewErrorConflict(err, err.Error())
|
||||
}
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.apiList(ctx, list)
|
||||
}
|
46
internal/processing/list/delete.go
Normal file
46
internal/processing/list/delete.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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 list
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
// Delete deletes one list for the given account.
|
||||
func (p *Processor) Delete(ctx context.Context, account *gtsmodel.Account, id string) gtserror.WithCode {
|
||||
list, errWithCode := p.getList(
|
||||
// Use barebones ctx; no embedded
|
||||
// structs necessary for this call.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
account.ID,
|
||||
id,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return errWithCode
|
||||
}
|
||||
|
||||
if err := p.state.DB.DeleteListByID(ctx, list.ID); err != nil {
|
||||
return gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
155
internal/processing/list/get.go
Normal file
155
internal/processing/list/get.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// 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 list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"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/util"
|
||||
)
|
||||
|
||||
// Get returns the api model of one list with the given ID.
|
||||
func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.List, gtserror.WithCode) {
|
||||
list, errWithCode := p.getList(
|
||||
// Use barebones ctx; no embedded
|
||||
// structs necessary for this call.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
account.ID,
|
||||
id,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
return p.apiList(ctx, list)
|
||||
}
|
||||
|
||||
// GetMultiple returns multiple lists created by the given account, sorted by list ID DESC (newest first).
|
||||
func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.List, gtserror.WithCode) {
|
||||
lists, err := p.state.DB.GetListsForAccountID(
|
||||
// Use barebones ctx; no embedded
|
||||
// structs necessary for simple GET.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
account.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
apiLists := make([]*apimodel.List, 0, len(lists))
|
||||
for _, list := range lists {
|
||||
apiList, errWithCode := p.apiList(ctx, list)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
apiLists = append(apiLists, apiList)
|
||||
}
|
||||
|
||||
return apiLists, nil
|
||||
}
|
||||
|
||||
// GetListAccounts returns accounts that are in the given list, owned by the given account.
|
||||
// The additional parameters can be used for paging.
|
||||
func (p *Processor) GetListAccounts(
|
||||
ctx context.Context,
|
||||
account *gtsmodel.Account,
|
||||
listID string,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
// Ensure list exists + is owned by requesting account.
|
||||
if _, errWithCode := p.getList(ctx, account.ID, listID); errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
// To know which accounts are in the list,
|
||||
// we need to first get requested list entries.
|
||||
listEntries, err := p.state.DB.GetListEntries(ctx, listID, maxID, sinceID, minID, limit)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = fmt.Errorf("GetListAccounts: error getting list entries: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(listEntries)
|
||||
if count == 0 {
|
||||
// No list entries means no accounts.
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, count)
|
||||
nextMaxIDValue string
|
||||
prevMinIDValue string
|
||||
)
|
||||
|
||||
// For each list entry, we want the account it points to.
|
||||
// To get this, we need to first get the follow that the
|
||||
// list entry pertains to, then extract the target account
|
||||
// from that follow.
|
||||
//
|
||||
// We do paging not by account ID, but by list entry ID.
|
||||
for i, listEntry := range listEntries {
|
||||
if i == count-1 {
|
||||
nextMaxIDValue = listEntry.ID
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
prevMinIDValue = listEntry.ID
|
||||
}
|
||||
|
||||
if err := p.state.DB.PopulateListEntry(ctx, listEntry); err != nil {
|
||||
log.Debugf(ctx, "skipping list entry because of error populating it: %q", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := p.state.DB.PopulateFollow(ctx, listEntry.Follow); err != nil {
|
||||
log.Debugf(ctx, "skipping list entry because of error populating follow: %q", err)
|
||||
continue
|
||||
}
|
||||
|
||||
apiAccount, err := p.tc.AccountToAPIAccountPublic(ctx, listEntry.Follow.TargetAccount)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping list entry because of error converting follow target account: %q", err)
|
||||
continue
|
||||
}
|
||||
|
||||
items[i] = apiAccount
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/lists/" + listID + "/accounts",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
35
internal/processing/list/list.go
Normal file
35
internal/processing/list/list.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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 list
|
||||
|
||||
import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
state *state.State
|
||||
tc typeutils.TypeConverter
|
||||
}
|
||||
|
||||
func New(state *state.State, tc typeutils.TypeConverter) Processor {
|
||||
return Processor{
|
||||
state: state,
|
||||
tc: tc,
|
||||
}
|
||||
}
|
73
internal/processing/list/update.go
Normal file
73
internal/processing/list/update.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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 list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
// Update updates one list for the given account, using the provided parameters.
|
||||
// These params should have already been validated by the time they reach this function.
|
||||
func (p *Processor) Update(
|
||||
ctx context.Context,
|
||||
account *gtsmodel.Account,
|
||||
id string,
|
||||
title *string,
|
||||
repliesPolicy *gtsmodel.RepliesPolicy,
|
||||
) (*apimodel.List, gtserror.WithCode) {
|
||||
list, errWithCode := p.getList(
|
||||
// Use barebones ctx; no embedded
|
||||
// structs necessary for this call.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
account.ID,
|
||||
id,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
// Only update columns we're told to update.
|
||||
columns := make([]string, 0, 2)
|
||||
|
||||
if title != nil {
|
||||
list.Title = *title
|
||||
columns = append(columns, "title")
|
||||
}
|
||||
|
||||
if repliesPolicy != nil {
|
||||
list.RepliesPolicy = *repliesPolicy
|
||||
columns = append(columns, "replies_policy")
|
||||
}
|
||||
|
||||
if err := p.state.DB.UpdateList(ctx, list, columns...); err != nil {
|
||||
if errors.Is(err, db.ErrAlreadyExists) {
|
||||
err = errors.New("you already have a list with this title")
|
||||
return nil, gtserror.NewErrorConflict(err, err.Error())
|
||||
}
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.apiList(ctx, list)
|
||||
}
|
151
internal/processing/list/updateentries.go
Normal file
151
internal/processing/list/updateentries.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// 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 list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
)
|
||||
|
||||
// AddToList adds targetAccountIDs to the given list, if valid.
|
||||
func (p *Processor) AddToList(ctx context.Context, account *gtsmodel.Account, listID string, targetAccountIDs []string) gtserror.WithCode {
|
||||
// Ensure this list exists + account owns it.
|
||||
list, errWithCode := p.getList(ctx, account.ID, listID)
|
||||
if errWithCode != nil {
|
||||
return errWithCode
|
||||
}
|
||||
|
||||
// Pre-assemble list of entries to add. We *could* add these
|
||||
// one by one as we iterate through accountIDs, but according
|
||||
// to the Mastodon API we should only add them all once we know
|
||||
// they're all valid, no partial updates.
|
||||
listEntries := make([]*gtsmodel.ListEntry, 0, len(targetAccountIDs))
|
||||
|
||||
// Check each targetAccountID is valid.
|
||||
// - Follow must exist.
|
||||
// - Follow must not already be in the given list.
|
||||
for _, targetAccountID := range targetAccountIDs {
|
||||
// Ensure follow exists.
|
||||
follow, err := p.state.DB.GetFollow(ctx, account.ID, targetAccountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
err = fmt.Errorf("you do not follow account %s", targetAccountID)
|
||||
return gtserror.NewErrorNotFound(err, err.Error())
|
||||
}
|
||||
return gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Ensure followID not already in list.
|
||||
// This particular call to isInList will
|
||||
// never error, so just check entryID.
|
||||
entryID, _ := isInList(
|
||||
list,
|
||||
follow.ID,
|
||||
func(listEntry *gtsmodel.ListEntry) (string, error) {
|
||||
// Looking for the listEntry follow ID.
|
||||
return listEntry.FollowID, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Empty entryID means entry with given
|
||||
// followID wasn't found in the list.
|
||||
if entryID != "" {
|
||||
err = fmt.Errorf("account with id %s is already in list %s with entryID %s", targetAccountID, listID, entryID)
|
||||
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
|
||||
}
|
||||
|
||||
// Entry wasn't in the list, we can add it.
|
||||
listEntries = append(listEntries, >smodel.ListEntry{
|
||||
ID: id.NewULID(),
|
||||
ListID: listID,
|
||||
FollowID: follow.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// If we get to here we can assume all
|
||||
// entries are valid, so try to add them.
|
||||
if err := p.state.DB.PutListEntries(ctx, listEntries); err != nil {
|
||||
if errors.Is(err, db.ErrAlreadyExists) {
|
||||
err = fmt.Errorf("one or more errors inserting list entries: %w", err)
|
||||
return gtserror.NewErrorUnprocessableEntity(err, err.Error())
|
||||
}
|
||||
return gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFromList removes targetAccountIDs from the given list, if valid.
|
||||
func (p *Processor) RemoveFromList(ctx context.Context, account *gtsmodel.Account, listID string, targetAccountIDs []string) gtserror.WithCode {
|
||||
// Ensure this list exists + account owns it.
|
||||
list, errWithCode := p.getList(ctx, account.ID, listID)
|
||||
if errWithCode != nil {
|
||||
return errWithCode
|
||||
}
|
||||
|
||||
// For each targetAccountID, we want to check if
|
||||
// a follow with that targetAccountID is in the
|
||||
// given list. If it is in there, we want to remove
|
||||
// it from the list.
|
||||
for _, targetAccountID := range targetAccountIDs {
|
||||
// Check if targetAccountID is
|
||||
// on a follow in the list.
|
||||
entryID, err := isInList(
|
||||
list,
|
||||
targetAccountID,
|
||||
func(listEntry *gtsmodel.ListEntry) (string, error) {
|
||||
// We need the follow so populate this
|
||||
// entry, if it's not already populated.
|
||||
if err := p.state.DB.PopulateListEntry(ctx, listEntry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Looking for the list entry targetAccountID.
|
||||
return listEntry.Follow.TargetAccountID, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Error may be returned here if there was an issue
|
||||
// populating the list entry. We only return on proper
|
||||
// DB errors, we can just skip no entry errors.
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = fmt.Errorf("error checking if targetAccountID %s was in list %s: %w", targetAccountID, listID, err)
|
||||
return gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if entryID == "" {
|
||||
// There was an errNoEntries or targetAccount
|
||||
// wasn't in this list anyway, so we can skip it.
|
||||
continue
|
||||
}
|
||||
|
||||
// TargetAccount was in the list, remove the entry.
|
||||
if err := p.state.DB.DeleteListEntry(ctx, entryID); err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = fmt.Errorf("error removing list entry %s from list %s: %w", entryID, listID, err)
|
||||
return gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
85
internal/processing/list/util.go
Normal file
85
internal/processing/list/util.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// 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 list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
// getList is a shortcut to get one list from the database and
|
||||
// check that it's owned by the given accountID. Will return
|
||||
// appropriate errors so caller doesn't need to bother.
|
||||
func (p *Processor) getList(ctx context.Context, accountID string, listID string) (*gtsmodel.List, gtserror.WithCode) {
|
||||
list, err := p.state.DB.GetListByID(ctx, listID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// List doesn't seem to exist.
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
// Real database error.
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if list.AccountID != accountID {
|
||||
err = fmt.Errorf("list with id %s does not belong to account %s", list.ID, accountID)
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// apiList is a shortcut to return the API version of the given
|
||||
// list, or return an appropriate error if conversion fails.
|
||||
func (p *Processor) apiList(ctx context.Context, list *gtsmodel.List) (*apimodel.List, gtserror.WithCode) {
|
||||
apiList, err := p.tc.ListToAPIList(ctx, list)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting list to api: %w", err))
|
||||
}
|
||||
|
||||
return apiList, nil
|
||||
}
|
||||
|
||||
// isInList check if thisID is equal to the result of thatID
|
||||
// for any entry in the given list.
|
||||
//
|
||||
// Will return the id of the listEntry if true, empty if false,
|
||||
// or an error if the result of thatID returns an error.
|
||||
func isInList(
|
||||
list *gtsmodel.List,
|
||||
thisID string,
|
||||
getThatID func(listEntry *gtsmodel.ListEntry) (string, error),
|
||||
) (string, error) {
|
||||
for _, listEntry := range list.ListEntries {
|
||||
thatID, err := getThatID(listEntry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if thisID == thatID {
|
||||
return listEntry.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
@@ -1,52 +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"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
)
|
||||
|
||||
type NotificationTestSuite struct {
|
||||
ProcessingStandardTestSuite
|
||||
}
|
||||
|
||||
// get a notification where someone has liked our status
|
||||
func (suite *NotificationTestSuite) TestGetNotifications() {
|
||||
receivingAccount := suite.testAccounts["local_account_1"]
|
||||
notifsResponse, err := suite.processor.NotificationsGet(context.Background(), suite.testAutheds["local_account_1"], "", "", "", 10, nil)
|
||||
suite.NoError(err)
|
||||
suite.Len(notifsResponse.Items, 1)
|
||||
notif, ok := notifsResponse.Items[0].(*apimodel.Notification)
|
||||
if !ok {
|
||||
panic("notif in response wasn't *apimodel.Notification")
|
||||
}
|
||||
|
||||
suite.NotNil(notif.Status)
|
||||
suite.NotNil(notif.Status)
|
||||
suite.NotNil(notif.Status.Account)
|
||||
suite.Equal(receivingAccount.ID, notif.Status.Account.ID)
|
||||
suite.Equal(`<http://localhost:8080/api/v1/notifications?limit=10&max_id=01F8Q0ANPTWW10DAKTX7BRPBJP>; rel="next", <http://localhost:8080/api/v1/notifications?limit=10&min_id=01F8Q0ANPTWW10DAKTX7BRPBJP>; rel="prev"`, notifsResponse.LinkHeader)
|
||||
}
|
||||
|
||||
func TestNotificationTestSuite(t *testing.T) {
|
||||
suite.Run(t, &NotificationTestSuite{})
|
||||
}
|
@@ -29,39 +29,41 @@ import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/admin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/fedi"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/list"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/report"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
|
||||
"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/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
federator federation.Federator
|
||||
tc typeutils.TypeConverter
|
||||
oauthServer oauth.Server
|
||||
mediaManager mm.Manager
|
||||
statusTimelines timeline.Manager
|
||||
state *state.State
|
||||
emailSender email.Sender
|
||||
filter *visibility.Filter
|
||||
federator federation.Federator
|
||||
tc typeutils.TypeConverter
|
||||
oauthServer oauth.Server
|
||||
mediaManager mm.Manager
|
||||
state *state.State
|
||||
emailSender email.Sender
|
||||
filter *visibility.Filter
|
||||
|
||||
/*
|
||||
SUB-PROCESSORS
|
||||
*/
|
||||
|
||||
account account.Processor
|
||||
admin admin.Processor
|
||||
fedi fedi.Processor
|
||||
media media.Processor
|
||||
report report.Processor
|
||||
status status.Processor
|
||||
stream stream.Processor
|
||||
user user.Processor
|
||||
account account.Processor
|
||||
admin admin.Processor
|
||||
fedi fedi.Processor
|
||||
list list.Processor
|
||||
media media.Processor
|
||||
report report.Processor
|
||||
status status.Processor
|
||||
stream stream.Processor
|
||||
timeline timeline.Processor
|
||||
user user.Processor
|
||||
}
|
||||
|
||||
func (p *Processor) Account() *account.Processor {
|
||||
@@ -76,6 +78,10 @@ func (p *Processor) Fedi() *fedi.Processor {
|
||||
return &p.fedi
|
||||
}
|
||||
|
||||
func (p *Processor) List() *list.Processor {
|
||||
return &p.list
|
||||
}
|
||||
|
||||
func (p *Processor) Media() *media.Processor {
|
||||
return &p.media
|
||||
}
|
||||
@@ -92,6 +98,10 @@ func (p *Processor) Stream() *stream.Processor {
|
||||
return &p.stream
|
||||
}
|
||||
|
||||
func (p *Processor) Timeline() *timeline.Processor {
|
||||
return &p.timeline
|
||||
}
|
||||
|
||||
func (p *Processor) User() *user.Processor {
|
||||
return &p.user
|
||||
}
|
||||
@@ -114,23 +124,19 @@ func NewProcessor(
|
||||
tc: tc,
|
||||
oauthServer: oauthServer,
|
||||
mediaManager: mediaManager,
|
||||
statusTimelines: timeline.NewManager(
|
||||
StatusGrabFunction(state.DB),
|
||||
StatusFilterFunction(state.DB, filter),
|
||||
StatusPrepareFunction(state.DB, tc),
|
||||
StatusSkipInsertFunction(),
|
||||
),
|
||||
state: state,
|
||||
filter: filter,
|
||||
emailSender: emailSender,
|
||||
state: state,
|
||||
filter: filter,
|
||||
emailSender: emailSender,
|
||||
}
|
||||
|
||||
// sub processors
|
||||
// Instantiate sub processors.
|
||||
processor.account = account.New(state, tc, mediaManager, oauthServer, federator, filter, parseMentionFunc)
|
||||
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.media = media.New(state, tc, mediaManager, federator.TransportController())
|
||||
processor.report = report.New(state, tc)
|
||||
processor.timeline = timeline.New(state, tc, filter)
|
||||
processor.status = status.New(state, federator, tc, filter, parseMentionFunc)
|
||||
processor.stream = stream.New(state, oauthServer)
|
||||
processor.user = user.New(state, emailSender)
|
||||
@@ -161,13 +167,3 @@ func (p *Processor) EnqueueFederator(ctx context.Context, msgs ...messages.FromF
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Start starts the Processor.
|
||||
func (p *Processor) Start() error {
|
||||
return p.statusTimelines.Start()
|
||||
}
|
||||
|
||||
// Stop stops the processor cleanly.
|
||||
func (p *Processor) Stop() error {
|
||||
return p.statusTimelines.Stop()
|
||||
}
|
||||
|
@@ -18,6 +18,8 @@
|
||||
package processing_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
@@ -28,8 +30,10 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -61,6 +65,7 @@ type ProcessingStandardTestSuite struct {
|
||||
testAutheds map[string]*oauth.Auth
|
||||
testBlocks map[string]*gtsmodel.Block
|
||||
testActivities map[string]testrig.ActivityWithSignature
|
||||
testLists map[string]*gtsmodel.List
|
||||
|
||||
processor *processing.Processor
|
||||
}
|
||||
@@ -84,6 +89,7 @@ func (suite *ProcessingStandardTestSuite) SetupSuite() {
|
||||
},
|
||||
}
|
||||
suite.testBlocks = testrig.NewTestBlocks()
|
||||
suite.testLists = testrig.NewTestLists()
|
||||
}
|
||||
|
||||
func (suite *ProcessingStandardTestSuite) SetupTest() {
|
||||
@@ -99,6 +105,13 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
|
||||
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()
|
||||
@@ -115,16 +128,40 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
|
||||
|
||||
testrig.StandardDBSetup(suite.db, suite.testAccounts)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
|
||||
if err := suite.processor.Start(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ProcessingStandardTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
if err := suite.processor.Stop(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
testrig.StopWorkers(&suite.state)
|
||||
}
|
||||
|
||||
func (suite *ProcessingStandardTestSuite) 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
|
||||
}
|
||||
|
@@ -88,6 +88,12 @@ func (suite *StatusStandardTestSuite) SetupTest() {
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, suite.tc, suite.mediaManager)
|
||||
|
||||
filter := visibility.NewFilter(&suite.state)
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
filter,
|
||||
testrig.NewTestTypeConverter(suite.db),
|
||||
)
|
||||
|
||||
suite.status = status.New(&suite.state, suite.federator, suite.typeConverter, filter, processing.GetParseMentionFunc(suite.db, suite.federator))
|
||||
|
||||
testrig.StandardDBSetup(suite.db, suite.testAccounts)
|
||||
|
@@ -1,309 +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"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
||||
)
|
||||
|
||||
const boostReinsertionDepth = 50
|
||||
|
||||
// StatusGrabFunction returns a function that satisfies the GrabFunction interface in internal/timeline.
|
||||
func StatusGrabFunction(database db.DB) timeline.GrabFunction {
|
||||
return func(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
|
||||
statuses, err := database.GetHomeTimeline(ctx, timelineAccountID, maxID, sinceID, minID, limit, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, true, nil // we just don't have enough statuses left in the db so return stop = true
|
||||
}
|
||||
return nil, false, fmt.Errorf("statusGrabFunction: error getting statuses from db: %w", err)
|
||||
}
|
||||
|
||||
items := make([]timeline.Timelineable, len(statuses))
|
||||
for i, s := range statuses {
|
||||
items[i] = s
|
||||
}
|
||||
|
||||
return items, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// StatusFilterFunction returns a function that satisfies the FilterFunction interface in internal/timeline.
|
||||
func StatusFilterFunction(database db.DB, filter *visibility.Filter) timeline.FilterFunction {
|
||||
return func(ctx context.Context, timelineAccountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
|
||||
status, ok := item.(*gtsmodel.Status)
|
||||
if !ok {
|
||||
return false, errors.New("StatusFilterFunction: could not convert item to *gtsmodel.Status")
|
||||
}
|
||||
|
||||
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("StatusFilterFunction: error getting account with id %s: %w", timelineAccountID, err)
|
||||
}
|
||||
|
||||
timelineable, err := filter.StatusHomeTimelineable(ctx, requestingAccount, status)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("StatusFilterFunction: error checking hometimelineability of status %s for account %s: %w", status.ID, timelineAccountID, err)
|
||||
}
|
||||
|
||||
return timelineable, nil
|
||||
}
|
||||
}
|
||||
|
||||
// StatusPrepareFunction returns a function that satisfies the PrepareFunction interface in internal/timeline.
|
||||
func StatusPrepareFunction(database db.DB, tc typeutils.TypeConverter) timeline.PrepareFunction {
|
||||
return func(ctx context.Context, timelineAccountID string, itemID string) (timeline.Preparable, error) {
|
||||
status, err := database.GetStatusByID(ctx, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StatusPrepareFunction: error getting status with id %s: %w", itemID, err)
|
||||
}
|
||||
|
||||
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StatusPrepareFunction: error getting account with id %s: %w", timelineAccountID, err)
|
||||
}
|
||||
|
||||
return tc.StatusToAPIStatus(ctx, status, requestingAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// StatusSkipInsertFunction returns a function that satisifes the SkipInsertFunction interface in internal/timeline.
|
||||
func StatusSkipInsertFunction() timeline.SkipInsertFunction {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
newItemID string,
|
||||
newItemAccountID string,
|
||||
newItemBoostOfID string,
|
||||
newItemBoostOfAccountID string,
|
||||
nextItemID string,
|
||||
nextItemAccountID string,
|
||||
nextItemBoostOfID string,
|
||||
nextItemBoostOfAccountID string,
|
||||
depth int,
|
||||
) (bool, error) {
|
||||
// make sure we don't insert a duplicate
|
||||
if newItemID == nextItemID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// check if it's a boost
|
||||
if newItemBoostOfID != "" {
|
||||
// skip if we've recently put another boost of this status in the timeline
|
||||
if newItemBoostOfID == nextItemBoostOfID {
|
||||
if depth < boostReinsertionDepth {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// skip if we've recently put the original status in the timeline
|
||||
if newItemBoostOfID == nextItemID {
|
||||
if depth < boostReinsertionDepth {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// insert the item
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
statuses, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("HomeTimelineGet: error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, count)
|
||||
nextMaxIDValue string
|
||||
prevMinIDValue string
|
||||
)
|
||||
|
||||
for i, item := range statuses {
|
||||
if i == count-1 {
|
||||
nextMaxIDValue = item.GetID()
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
prevMinIDValue = item.GetID()
|
||||
}
|
||||
|
||||
items[i] = item
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/timelines/home",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit, local)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// No statuses (left) in public timeline.
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
// An actual error has occurred.
|
||||
err = fmt.Errorf("PublicTimelineGet: db error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, 0, count)
|
||||
nextMaxIDValue string
|
||||
prevMinIDValue string
|
||||
)
|
||||
|
||||
for i, s := range statuses {
|
||||
// Set next + prev values before filtering and API
|
||||
// converting, so caller can still page properly.
|
||||
if i == count-1 {
|
||||
nextMaxIDValue = s.ID
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
prevMinIDValue = s.ID
|
||||
}
|
||||
|
||||
timelineable, err := p.filter.StatusPublicTimelineable(ctx, authed.Account, s)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because of an error checking StatusPublicTimelineable: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !timelineable {
|
||||
continue
|
||||
}
|
||||
|
||||
apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, authed.Account)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, apiStatus)
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/timelines/public",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// There are just no entries (left).
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
// An actual error has occurred.
|
||||
err = fmt.Errorf("FavedTimelineGet: db error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
filtered, err := p.filterFavedStatuses(ctx, authed, statuses)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("FavedTimelineGet: error filtering statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
items := make([]interface{}, len(filtered))
|
||||
for i, item := range filtered {
|
||||
items[i] = item
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/favourites",
|
||||
NextMaxIDValue: nextMaxID,
|
||||
PrevMinIDValue: prevMinID,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Processor) filterFavedStatuses(ctx context.Context, authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) {
|
||||
apiStatuses := make([]*apimodel.Status, 0, len(statuses))
|
||||
|
||||
for _, s := range statuses {
|
||||
if _, err := p.state.DB.GetAccountByID(ctx, s.AccountID); err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
log.Debugf(ctx, "skipping status %s because account %s can't be found in the db", s.ID, s.AccountID)
|
||||
continue
|
||||
}
|
||||
err = fmt.Errorf("filterFavedStatuses: db error getting status author: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
timelineable, err := p.filter.StatusVisible(ctx, authed.Account, s)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because of an error checking status visibility: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
if !timelineable {
|
||||
continue
|
||||
}
|
||||
|
||||
apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, authed.Account)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
apiStatuses = append(apiStatuses, apiStatus)
|
||||
}
|
||||
|
||||
return apiStatuses, nil
|
||||
}
|
@@ -31,60 +31,65 @@ import (
|
||||
)
|
||||
|
||||
// Open returns a new Stream for the given account, which will contain a channel for passing messages back to the caller.
|
||||
func (p *Processor) Open(ctx context.Context, account *gtsmodel.Account, streamTimeline string) (*stream.Stream, gtserror.WithCode) {
|
||||
func (p *Processor) Open(ctx context.Context, account *gtsmodel.Account, streamType string) (*stream.Stream, gtserror.WithCode) {
|
||||
l := log.WithContext(ctx).WithFields(kv.Fields{
|
||||
{"account", account.ID},
|
||||
{"streamType", streamTimeline},
|
||||
{"streamType", streamType},
|
||||
}...)
|
||||
l.Debug("received open stream request")
|
||||
|
||||
// each stream needs a unique ID so we know to close it
|
||||
streamID, err := id.NewRandomULID()
|
||||
var (
|
||||
streamID string
|
||||
err error
|
||||
)
|
||||
|
||||
// Each stream needs a unique ID so we know to close it.
|
||||
streamID, err = id.NewRandomULID()
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error generating stream id: %s", err))
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error generating stream id: %w", err))
|
||||
}
|
||||
|
||||
// Each stream can be subscibed to multiple timelines.
|
||||
// Each stream can be subscibed to multiple types.
|
||||
// Record them in a set, and include the initial one
|
||||
// if it was given to us
|
||||
timelines := map[string]bool{}
|
||||
if streamTimeline != "" {
|
||||
timelines[streamTimeline] = true
|
||||
// if it was given to us.
|
||||
streamTypes := map[string]any{}
|
||||
if streamType != "" {
|
||||
streamTypes[streamType] = true
|
||||
}
|
||||
|
||||
thisStream := &stream.Stream{
|
||||
ID: streamID,
|
||||
Timelines: timelines,
|
||||
Messages: make(chan *stream.Message, 100),
|
||||
Hangup: make(chan interface{}, 1),
|
||||
Connected: true,
|
||||
newStream := &stream.Stream{
|
||||
ID: streamID,
|
||||
StreamTypes: streamTypes,
|
||||
Messages: make(chan *stream.Message, 100),
|
||||
Hangup: make(chan interface{}, 1),
|
||||
Connected: true,
|
||||
}
|
||||
go p.waitToCloseStream(account, thisStream)
|
||||
go p.waitToCloseStream(account, newStream)
|
||||
|
||||
v, ok := p.streamMap.Load(account.ID)
|
||||
if !ok || v == nil {
|
||||
// there is no entry in the streamMap for this account yet, so make one and store it
|
||||
streamsForAccount := &stream.StreamsForAccount{
|
||||
Streams: []*stream.Stream{
|
||||
thisStream,
|
||||
},
|
||||
}
|
||||
p.streamMap.Store(account.ID, streamsForAccount)
|
||||
} else {
|
||||
// there is an entry in the streamMap for this account
|
||||
// parse the interface as a streamsForAccount
|
||||
if ok {
|
||||
// There is an entry in the streamMap
|
||||
// for this account. Parse it out.
|
||||
streamsForAccount, ok := v.(*stream.StreamsForAccount)
|
||||
if !ok {
|
||||
return nil, gtserror.NewErrorInternalError(errors.New("stream map error"))
|
||||
}
|
||||
|
||||
// append this stream to it
|
||||
// Append new stream to existing entry.
|
||||
streamsForAccount.Lock()
|
||||
streamsForAccount.Streams = append(streamsForAccount.Streams, thisStream)
|
||||
streamsForAccount.Streams = append(streamsForAccount.Streams, newStream)
|
||||
streamsForAccount.Unlock()
|
||||
} else {
|
||||
// There is no entry in the streamMap for
|
||||
// this account yet. Create one and store it.
|
||||
p.streamMap.Store(account.ID, &stream.StreamsForAccount{
|
||||
Streams: []*stream.Stream{
|
||||
newStream,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return thisStream, nil
|
||||
return newStream, nil
|
||||
}
|
||||
|
||||
// waitToCloseStream waits until the hangup channel is closed for the given stream.
|
||||
|
@@ -18,7 +18,6 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
@@ -40,37 +39,38 @@ func New(state *state.State, oauthServer oauth.Server) Processor {
|
||||
}
|
||||
|
||||
// toAccount streams the given payload with the given event type to any streams currently open for the given account ID.
|
||||
func (p *Processor) toAccount(payload string, event string, timelines []string, accountID string) error {
|
||||
func (p *Processor) toAccount(payload string, event string, streamTypes []string, accountID string) error {
|
||||
// Load all streams open for this account.
|
||||
v, ok := p.streamMap.Load(accountID)
|
||||
if !ok {
|
||||
// no open connections so nothing to stream
|
||||
return nil
|
||||
}
|
||||
|
||||
streamsForAccount, ok := v.(*stream.StreamsForAccount)
|
||||
if !ok {
|
||||
return errors.New("stream map error")
|
||||
return nil // No entry = nothing to stream.
|
||||
}
|
||||
streamsForAccount := v.(*stream.StreamsForAccount) //nolint:forcetypeassert
|
||||
|
||||
streamsForAccount.Lock()
|
||||
defer streamsForAccount.Unlock()
|
||||
|
||||
for _, s := range streamsForAccount.Streams {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
if !s.Connected {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, t := range timelines {
|
||||
if _, found := s.Timelines[t]; found {
|
||||
typeLoop:
|
||||
for _, streamType := range streamTypes {
|
||||
if _, found := s.StreamTypes[streamType]; found {
|
||||
s.Messages <- &stream.Message{
|
||||
Stream: []string{string(t)},
|
||||
Stream: []string{streamType},
|
||||
Event: string(event),
|
||||
Payload: payload,
|
||||
}
|
||||
// break out to the outer loop, to avoid sending duplicates
|
||||
// of the same event to the same stream
|
||||
break
|
||||
|
||||
// Break out to the outer loop,
|
||||
// to avoid sending duplicates of
|
||||
// the same event to the same stream.
|
||||
break typeLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -27,11 +27,11 @@ import (
|
||||
)
|
||||
|
||||
// Update streams the given update to any open, appropriate streams belonging to the given account.
|
||||
func (p *Processor) Update(s *apimodel.Status, account *gtsmodel.Account, timeline string) error {
|
||||
func (p *Processor) Update(s *apimodel.Status, account *gtsmodel.Account, streamTypes []string) error {
|
||||
bytes, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling status to json: %s", err)
|
||||
}
|
||||
|
||||
return p.toAccount(string(bytes), stream.EventTypeUpdate, []string{timeline}, account.ID)
|
||||
return p.toAccount(string(bytes), stream.EventTypeUpdate, streamTypes, account.ID)
|
||||
}
|
||||
|
71
internal/processing/timeline/common.go
Normal file
71
internal/processing/timeline/common.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 timeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
)
|
||||
|
||||
// SkipInsert returns a function that satisifes SkipInsertFunction.
|
||||
func SkipInsert() timeline.SkipInsertFunction {
|
||||
// Gap to allow between a status or boost of status,
|
||||
// and reinsertion of a new boost of that status.
|
||||
// This is useful to avoid a heavily boosted status
|
||||
// showing up way too often in a user's timeline.
|
||||
const boostReinsertionDepth = 50
|
||||
|
||||
return func(
|
||||
ctx context.Context,
|
||||
newItemID string,
|
||||
newItemAccountID string,
|
||||
newItemBoostOfID string,
|
||||
newItemBoostOfAccountID string,
|
||||
nextItemID string,
|
||||
nextItemAccountID string,
|
||||
nextItemBoostOfID string,
|
||||
nextItemBoostOfAccountID string,
|
||||
depth int,
|
||||
) (bool, error) {
|
||||
if newItemID == nextItemID {
|
||||
// Don't insert duplicates.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if newItemBoostOfID != "" {
|
||||
if newItemBoostOfID == nextItemBoostOfID &&
|
||||
depth < boostReinsertionDepth {
|
||||
// Don't insert boosts of items
|
||||
// we've seen boosted recently.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if newItemBoostOfID == nextItemID &&
|
||||
depth < boostReinsertionDepth {
|
||||
// Don't insert boosts of items when
|
||||
// we've seen the original recently.
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with insertion
|
||||
// (that's what she said!).
|
||||
return false, nil
|
||||
}
|
||||
}
|
73
internal/processing/timeline/faved.go
Normal file
73
internal/processing/timeline/faved.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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 timeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = fmt.Errorf("FavedTimelineGet: db error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
items := make([]interface{}, 0, count)
|
||||
for _, s := range statuses {
|
||||
visible, err := p.filter.StatusVisible(ctx, authed.Account, s)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because of an error checking status visibility: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
|
||||
apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, authed.Account)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, apiStatus)
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/favourites",
|
||||
NextMaxIDValue: nextMaxID,
|
||||
PrevMinIDValue: prevMinID,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
133
internal/processing/timeline/home.go
Normal file
133
internal/processing/timeline/home.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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 timeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
||||
)
|
||||
|
||||
// HomeTimelineGrab returns a function that satisfies GrabFunction for home timelines.
|
||||
func HomeTimelineGrab(state *state.State) timeline.GrabFunction {
|
||||
return func(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
|
||||
statuses, err := state.DB.GetHomeTimeline(ctx, accountID, maxID, sinceID, minID, limit, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, true, nil // we just don't have enough statuses left in the db so return stop = true
|
||||
}
|
||||
return nil, false, fmt.Errorf("HomeTimelineGrab: error getting statuses from db: %w", err)
|
||||
}
|
||||
|
||||
items := make([]timeline.Timelineable, len(statuses))
|
||||
for i, s := range statuses {
|
||||
items[i] = s
|
||||
}
|
||||
|
||||
return items, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// HomeTimelineFilter returns a function that satisfies FilterFunction for home timelines.
|
||||
func HomeTimelineFilter(state *state.State, filter *visibility.Filter) timeline.FilterFunction {
|
||||
return func(ctx context.Context, accountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
|
||||
status, ok := item.(*gtsmodel.Status)
|
||||
if !ok {
|
||||
return false, errors.New("HomeTimelineFilter: could not convert item to *gtsmodel.Status")
|
||||
}
|
||||
|
||||
requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("HomeTimelineFilter: error getting account with id %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
timelineable, err := filter.StatusHomeTimelineable(ctx, requestingAccount, status)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("HomeTimelineFilter: error checking hometimelineability of status %s for account %s: %w", status.ID, accountID, err)
|
||||
}
|
||||
|
||||
return timelineable, nil
|
||||
}
|
||||
}
|
||||
|
||||
// HomeTimelineStatusPrepare returns a function that satisfies PrepareFunction for home timelines.
|
||||
func HomeTimelineStatusPrepare(state *state.State, tc typeutils.TypeConverter) timeline.PrepareFunction {
|
||||
return func(ctx context.Context, accountID string, itemID string) (timeline.Preparable, error) {
|
||||
status, err := state.DB.GetStatusByID(ctx, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StatusPrepare: error getting status with id %s: %w", itemID, err)
|
||||
}
|
||||
|
||||
requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("StatusPrepare: error getting account with id %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
return tc.StatusToAPIStatus(ctx, status, requestingAccount)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
statuses, err := p.state.Timelines.Home.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("HomeTimelineGet: error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, count)
|
||||
nextMaxIDValue string
|
||||
prevMinIDValue string
|
||||
)
|
||||
|
||||
for i, item := range statuses {
|
||||
if i == count-1 {
|
||||
nextMaxIDValue = item.GetID()
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
prevMinIDValue = item.GetID()
|
||||
}
|
||||
|
||||
items[i] = item
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/timelines/home",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
157
internal/processing/timeline/list.go
Normal file
157
internal/processing/timeline/list.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// 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 timeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
||||
)
|
||||
|
||||
// ListTimelineGrab returns a function that satisfies GrabFunction for list timelines.
|
||||
func ListTimelineGrab(state *state.State) timeline.GrabFunction {
|
||||
return func(ctx context.Context, listID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
|
||||
statuses, err := state.DB.GetListTimeline(ctx, listID, maxID, sinceID, minID, limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, true, nil // we just don't have enough statuses left in the db so return stop = true
|
||||
}
|
||||
return nil, false, fmt.Errorf("ListTimelineGrab: error getting statuses from db: %w", err)
|
||||
}
|
||||
|
||||
items := make([]timeline.Timelineable, len(statuses))
|
||||
for i, s := range statuses {
|
||||
items[i] = s
|
||||
}
|
||||
|
||||
return items, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// HomeTimelineFilter returns a function that satisfies FilterFunction for list timelines.
|
||||
func ListTimelineFilter(state *state.State, filter *visibility.Filter) timeline.FilterFunction {
|
||||
return func(ctx context.Context, listID string, item timeline.Timelineable) (shouldIndex bool, err error) {
|
||||
status, ok := item.(*gtsmodel.Status)
|
||||
if !ok {
|
||||
return false, errors.New("ListTimelineFilter: could not convert item to *gtsmodel.Status")
|
||||
}
|
||||
|
||||
list, err := state.DB.GetListByID(ctx, listID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ListTimelineFilter: error getting list with id %s: %w", listID, err)
|
||||
}
|
||||
|
||||
requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ListTimelineFilter: error getting account with id %s: %w", list.AccountID, err)
|
||||
}
|
||||
|
||||
timelineable, err := filter.StatusHomeTimelineable(ctx, requestingAccount, status)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ListTimelineFilter: error checking hometimelineability of status %s for account %s: %w", status.ID, list.AccountID, err)
|
||||
}
|
||||
|
||||
return timelineable, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ListTimelineStatusPrepare returns a function that satisfies PrepareFunction for list timelines.
|
||||
func ListTimelineStatusPrepare(state *state.State, tc typeutils.TypeConverter) timeline.PrepareFunction {
|
||||
return func(ctx context.Context, listID string, itemID string) (timeline.Preparable, error) {
|
||||
status, err := state.DB.GetStatusByID(ctx, itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListTimelineStatusPrepare: error getting status with id %s: %w", itemID, err)
|
||||
}
|
||||
|
||||
list, err := state.DB.GetListByID(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListTimelineStatusPrepare: error getting list with id %s: %w", listID, err)
|
||||
}
|
||||
|
||||
requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListTimelineStatusPrepare: error getting account with id %s: %w", list.AccountID, err)
|
||||
}
|
||||
|
||||
return tc.StatusToAPIStatus(ctx, status, requestingAccount)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) ListTimelineGet(ctx context.Context, authed *oauth.Auth, listID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
// Ensure list exists + is owned by this account.
|
||||
list, err := p.state.DB.GetListByID(ctx, listID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if list.AccountID != authed.Account.ID {
|
||||
err = fmt.Errorf("list with id %s does not belong to account %s", list.ID, authed.Account.ID)
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
|
||||
statuses, err := p.state.Timelines.List.GetTimeline(ctx, listID, maxID, sinceID, minID, limit, false)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("ListTimelineGet: error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, count)
|
||||
nextMaxIDValue string
|
||||
prevMinIDValue string
|
||||
)
|
||||
|
||||
for i, item := range statuses {
|
||||
if i == count-1 {
|
||||
nextMaxIDValue = item.GetID()
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
prevMinIDValue = item.GetID()
|
||||
}
|
||||
|
||||
items[i] = item
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/timelines/list/" + listID,
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
@@ -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
|
||||
package timeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -33,12 +33,7 @@ import (
|
||||
|
||||
func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, excludeTypes []string) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
notifs, err := p.state.DB.GetAccountNotifications(ctx, authed.Account.ID, maxID, sinceID, minID, limit, excludeTypes)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// No notifs (left).
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
// An actual error has occurred.
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = fmt.Errorf("NotificationsGet: db error getting notifications: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
@@ -73,6 +68,7 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
|
||||
log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %s", n.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
@@ -85,6 +81,7 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
|
||||
log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %s", n.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !visible {
|
||||
continue
|
||||
}
|
88
internal/processing/timeline/public.go
Normal file
88
internal/processing/timeline/public.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// 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 timeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
func (p *Processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit, local)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = fmt.Errorf("PublicTimelineGet: db error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, 0, count)
|
||||
nextMaxIDValue string
|
||||
prevMinIDValue string
|
||||
)
|
||||
|
||||
for i, s := range statuses {
|
||||
// Set next + prev values before filtering and API
|
||||
// converting, so caller can still page properly.
|
||||
if i == count-1 {
|
||||
nextMaxIDValue = s.ID
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
prevMinIDValue = s.ID
|
||||
}
|
||||
|
||||
timelineable, err := p.filter.StatusPublicTimelineable(ctx, authed.Account, s)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because of an error checking StatusPublicTimelineable: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !timelineable {
|
||||
continue
|
||||
}
|
||||
|
||||
apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, authed.Account)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, apiStatus)
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "api/v1/timelines/public",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
38
internal/processing/timeline/timeline.go
Normal file
38
internal/processing/timeline/timeline.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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 timeline
|
||||
|
||||
import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
state *state.State
|
||||
tc typeutils.TypeConverter
|
||||
filter *visibility.Filter
|
||||
}
|
||||
|
||||
func New(state *state.State, tc typeutils.TypeConverter, filter *visibility.Filter) Processor {
|
||||
return Processor{
|
||||
state: state,
|
||||
tc: tc,
|
||||
filter: filter,
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user