diff options
Diffstat (limited to 'internal/visibility')
-rw-r--r-- | internal/visibility/account.go | 151 | ||||
-rw-r--r-- | internal/visibility/boostable.go | 62 | ||||
-rw-r--r-- | internal/visibility/boostable_test.go (renamed from internal/visibility/statusboostable_test.go) | 26 | ||||
-rw-r--r-- | internal/visibility/filter.go | 46 | ||||
-rw-r--r-- | internal/visibility/filter_test.go | 12 | ||||
-rw-r--r-- | internal/visibility/home_timeline.go | 165 | ||||
-rw-r--r-- | internal/visibility/home_timeline_test.go (renamed from internal/visibility/statushometimelineable_test.go) | 62 | ||||
-rw-r--r-- | internal/visibility/public_timeline.go | 121 | ||||
-rw-r--r-- | internal/visibility/relevantaccounts.go | 230 | ||||
-rw-r--r-- | internal/visibility/status.go | 217 | ||||
-rw-r--r-- | internal/visibility/status_test.go (renamed from internal/visibility/statusvisible_test.go) | 54 | ||||
-rw-r--r-- | internal/visibility/statusboostable.go | 60 | ||||
-rw-r--r-- | internal/visibility/statushometimelineable.go | 126 | ||||
-rw-r--r-- | internal/visibility/statuspublictimelineable.go | 72 | ||||
-rw-r--r-- | internal/visibility/statusvisible.go | 252 |
15 files changed, 819 insertions, 837 deletions
diff --git a/internal/visibility/account.go b/internal/visibility/account.go new file mode 100644 index 000000000..ca532f5dd --- /dev/null +++ b/internal/visibility/account.go @@ -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 visibility + +import ( + "context" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// AccountVisible will check if given account is visible to requester, accounting for requester with no auth (i.e is nil), suspensions, disabled local users and account blocks. +func (f *Filter) AccountVisible(ctx context.Context, requester *gtsmodel.Account, account *gtsmodel.Account) (bool, error) { + // By default we assume no auth. + requesterID := noauth + + if requester != nil { + // Use provided account ID. + requesterID = requester.ID + } + + visibility, err := f.state.Caches.Visibility.Load("Type.RequesterID.ItemID", func() (*cache.CachedVisibility, error) { + // Visibility not yet cached, perform visibility lookup. + visible, err := f.isAccountVisibleTo(ctx, requester, account) + if err != nil { + return nil, err + } + + // Return visibility value. + return &cache.CachedVisibility{ + ItemID: account.ID, + RequesterID: requesterID, + Type: cache.VisibilityTypeAccount, + Value: visible, + }, nil + }, "account", requesterID, account.ID) + if err != nil { + return false, err + } + + return visibility.Value, nil +} + +// isAccountVisibleTo will check if account is visible to requester. It is the "meat" of the logic to Filter{}.AccountVisible() which is called within cache loader callback. +func (f *Filter) isAccountVisibleTo(ctx context.Context, requester *gtsmodel.Account, account *gtsmodel.Account) (bool, error) { + // Check whether target account is visible to anyone. + visible, err := f.isAccountVisible(ctx, account) + if err != nil { + return false, fmt.Errorf("isAccountVisibleTo: error checking account %s visibility: %w", account.ID, err) + } + + if !visible { + log.Trace(ctx, "target account is not visible to anyone") + return false, nil + } + + if requester == nil { + // It seems stupid, but when un-authed all accounts are + // visible to allow for federation to work correctly. + return true, nil + } + + // If requester is not visible, they cannot *see* either. + visible, err = f.isAccountVisible(ctx, requester) + if err != nil { + return false, fmt.Errorf("isAccountVisibleTo: error checking account %s visibility: %w", account.ID, err) + } + + if !visible { + log.Trace(ctx, "requesting account cannot see other accounts") + return false, nil + } + + // Check whether either blocks the other. + blocked, err := f.state.DB.IsEitherBlocked(ctx, + requester.ID, + account.ID, + ) + if err != nil { + return false, fmt.Errorf("isAccountVisibleTo: error checking account blocks: %w", err) + } + + if blocked { + log.Trace(ctx, "block exists between accounts") + return false, nil + } + + return true, nil +} + +// isAccountVisible will check if given account should be visible at all, e.g. it may not be if suspended or disabled. +func (f *Filter) isAccountVisible(ctx context.Context, account *gtsmodel.Account) (bool, error) { + if account.IsLocal() { + // This is a local account. + + if account.Username == config.GetHost() { + // This is the instance actor account. + return true, nil + } + + // Fetch the local user model for this account. + user, err := f.state.DB.GetUserByAccountID(ctx, account.ID) + if err != nil { + return false, err + } + + // Make sure that user is active (i.e. not disabled, not approved etc). + if *user.Disabled || !*user.Approved || user.ConfirmedAt.IsZero() { + log.Trace(ctx, "local account not active") + return false, nil + } + } else { + // This is a remote account. + + // Check whether remote account's domain is blocked. + blocked, err := f.state.DB.IsDomainBlocked(ctx, account.Domain) + if err != nil { + return false, err + } + + if blocked { + log.Trace(ctx, "remote account domain blocked") + return false, nil + } + } + + if !account.SuspendedAt.IsZero() { + log.Trace(ctx, "account suspended") + return false, nil + } + + return true, nil +} diff --git a/internal/visibility/boostable.go b/internal/visibility/boostable.go new file mode 100644 index 000000000..7c8bda324 --- /dev/null +++ b/internal/visibility/boostable.go @@ -0,0 +1,62 @@ +// 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 visibility + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// StatusBoostable checks if given status is boostable by requester, checking boolean status visibility to requester and ultimately the AP status visibility setting. +func (f *Filter) StatusBoostable(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + if status.Visibility == gtsmodel.VisibilityDirect { + log.Trace(ctx, "direct statuses are not boostable") + return false, nil + } + + // Check whether status is visible to requesting account. + visible, err := f.StatusVisible(ctx, requester, status) + if err != nil { + return false, err + } + + if !visible { + log.Trace(ctx, "status not visible to requesting account") + return false, nil + } + + if requester.ID == status.AccountID { + // Status author can always boost non-directs. + return true, nil + } + + if status.Visibility == gtsmodel.VisibilityFollowersOnly || + status.Visibility == gtsmodel.VisibilityMutualsOnly { + log.Trace(ctx, "unauthored %s status not boostable", status.Visibility) + return false, nil + } + + if !*status.Boostable { + log.Trace(ctx, "status marked not boostable") + return false, nil + } + + return true, nil +} diff --git a/internal/visibility/statusboostable_test.go b/internal/visibility/boostable_test.go index cdadd82a3..fd29e7305 100644 --- a/internal/visibility/statusboostable_test.go +++ b/internal/visibility/boostable_test.go @@ -33,7 +33,7 @@ func (suite *StatusBoostableTestSuite) TestOwnPublicBoostable() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(boostable) @@ -44,7 +44,7 @@ func (suite *StatusBoostableTestSuite) TestOwnUnlockedBoostable() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(boostable) @@ -55,7 +55,7 @@ func (suite *StatusBoostableTestSuite) TestOwnMutualsOnlyNonInteractiveBoostable testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(boostable) @@ -66,7 +66,7 @@ func (suite *StatusBoostableTestSuite) TestOwnMutualsOnlyBoostable() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(boostable) @@ -77,7 +77,7 @@ func (suite *StatusBoostableTestSuite) TestOwnFollowersOnlyBoostable() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(boostable) @@ -88,7 +88,7 @@ func (suite *StatusBoostableTestSuite) TestOwnDirectNotBoostable() { testAccount := suite.testAccounts["local_account_2"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.False(boostable) @@ -99,7 +99,7 @@ func (suite *StatusBoostableTestSuite) TestOtherPublicBoostable() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(boostable) @@ -110,7 +110,7 @@ func (suite *StatusBoostableTestSuite) TestOtherUnlistedBoostable() { testAccount := suite.testAccounts["local_account_2"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(boostable) @@ -121,7 +121,7 @@ func (suite *StatusBoostableTestSuite) TestOtherFollowersOnlyNotBoostable() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.False(boostable) @@ -132,19 +132,19 @@ func (suite *StatusBoostableTestSuite) TestOtherDirectNotBoostable() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) suite.NoError(err) suite.False(boostable) } -func (suite *StatusBoostableTestSuite) TestRemoteFollowersOnlyNotVisibleError() { +func (suite *StatusBoostableTestSuite) TestRemoteFollowersOnlyNotVisible() { testStatus := suite.testStatuses["local_account_1_status_5"] testAccount := suite.testAccounts["remote_account_1"] ctx := context.Background() - boostable, err := suite.filter.StatusBoostable(ctx, testStatus, testAccount) - suite.Assert().Error(err) + boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) + suite.NoError(err) suite.False(boostable) } diff --git a/internal/visibility/filter.go b/internal/visibility/filter.go index caa622d09..c9f007ccf 100644 --- a/internal/visibility/filter.go +++ b/internal/visibility/filter.go @@ -18,46 +18,20 @@ package visibility import ( - "context" - - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" ) -// Filter packages up a bunch of logic for checking whether given statuses or accounts are visible to a requester. -type Filter interface { - // StatusVisible returns true if targetStatus is visible to requestingAccount, based on the - // privacy settings of the status, and any blocks/mutes that might exist between the two accounts - // or account domains, and other relevant accounts mentioned in or replied to by the status. - StatusVisible(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error) - - // StatusesVisible calls StatusVisible for each status in the statuses slice, and returns a slice of only - // statuses which are visible to the requestingAccount. - StatusesVisible(ctx context.Context, statuses []*gtsmodel.Status, requestingAccount *gtsmodel.Account) ([]*gtsmodel.Status, error) - - // StatusHometimelineable returns true if targetStatus should be in the home timeline of the requesting account. - // - // This function will call StatusVisible internally, so it's not necessary to call it beforehand. - StatusHometimelineable(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error) - - // StatusPublictimelineable returns true if targetStatus should be in the public timeline of the requesting account. - // - // This function will call StatusVisible internally, so it's not necessary to call it beforehand. - StatusPublictimelineable(ctx context.Context, targetStatus *gtsmodel.Status, timelineOwnerAccount *gtsmodel.Account) (bool, error) - - // StatusBoostable returns true if targetStatus can be boosted by the requesting account. - // - // this function will call StatusVisible internally so it's not necessary to call it beforehand. - StatusBoostable(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error) -} +// noauth is a placeholder ID used in cache lookups +// when there is no authorized account ID to use. +const noauth = "noauth" -type filter struct { - db db.DB +// Filter packages up a bunch of logic for checking whether +// given statuses or accounts are visible to a requester. +type Filter struct { + state *state.State } // NewFilter returns a new Filter interface that will use the provided database. -func NewFilter(db db.DB) Filter { - return &filter{ - db: db, - } +func NewFilter(state *state.State) *Filter { + return &Filter{state: state} } diff --git a/internal/visibility/filter_test.go b/internal/visibility/filter_test.go index 500f46239..41f06079a 100644 --- a/internal/visibility/filter_test.go +++ b/internal/visibility/filter_test.go @@ -29,7 +29,8 @@ import ( type FilterStandardTestSuite struct { // standard suite interfaces suite.Suite - db db.DB + db db.DB + state state.State // standard suite models testTokens map[string]*gtsmodel.Token @@ -43,7 +44,7 @@ type FilterStandardTestSuite struct { testMentions map[string]*gtsmodel.Mention testFollows map[string]*gtsmodel.Follow - filter visibility.Filter + filter *visibility.Filter } func (suite *FilterStandardTestSuite) SetupSuite() { @@ -60,14 +61,13 @@ func (suite *FilterStandardTestSuite) SetupSuite() { } func (suite *FilterStandardTestSuite) SetupTest() { - var state state.State - state.Caches.Init() + suite.state.Caches.Init() testrig.InitTestConfig() testrig.InitTestLog() - suite.db = testrig.NewTestDB(&state) - suite.filter = visibility.NewFilter(suite.db) + suite.db = testrig.NewTestDB(&suite.state) + suite.filter = visibility.NewFilter(&suite.state) testrig.StandardDBSetup(suite.db, nil) } diff --git a/internal/visibility/home_timeline.go b/internal/visibility/home_timeline.go new file mode 100644 index 000000000..3f0f1f16a --- /dev/null +++ b/internal/visibility/home_timeline.go @@ -0,0 +1,165 @@ +// 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 visibility + +import ( + "context" + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// StatusHomeTimelineable checks if given status should be included on owner's home timeline. Primarily relying on status visibility to owner and the AP visibility setting, but also taking into account thread replies etc. +func (f *Filter) StatusHomeTimelineable(ctx context.Context, owner *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + // By default we assume no auth. + requesterID := noauth + + if owner != nil { + // Use provided account ID. + requesterID = owner.ID + } + + visibility, err := f.state.Caches.Visibility.Load("Type.RequesterID.ItemID", func() (*cache.CachedVisibility, error) { + // Visibility not yet cached, perform timeline visibility lookup. + visible, err := f.isStatusHomeTimelineable(ctx, owner, status) + if err != nil { + return nil, err + } + + // Return visibility value. + return &cache.CachedVisibility{ + ItemID: status.ID, + RequesterID: requesterID, + Type: cache.VisibilityTypeHome, + Value: visible, + }, nil + }, "home", requesterID, status.ID) + if err != nil { + if err == cache.SentinelError { + // Filter-out our temporary + // race-condition error. + return false, nil + } + + return false, err + } + + return visibility.Value, nil +} + +func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + if status.CreatedAt.After(time.Now().Add(24 * time.Hour)) { + // Statuses made over 1 day in the future we don't show... + log.Warnf(ctx, "status >24hrs in the future: %+v", status) + return false, nil + } + + // Check whether status is visible to timeline owner. + visible, err := f.StatusVisible(ctx, owner, status) + if err != nil { + return false, err + } + + if !visible { + log.Trace(ctx, "status not visible to timeline owner") + return false, nil + } + + if status.AccountID == owner.ID { + // Author can always see their status. + return true, nil + } + + if status.MentionsAccount(owner.ID) { + // Can always see when you are mentioned. + return true, nil + } + + var ( + parent *gtsmodel.Status + included bool + oneAuthor bool + ) + + for parent = status; parent.InReplyToURI != ""; { + // Fetch next parent to lookup. + parentID := parent.InReplyToID + if parentID == "" { + log.Warnf(ctx, "status not yet deref'd: %s", parent.InReplyToURI) + return false, cache.SentinelError + } + + // Get the next parent in the chain from DB. + parent, err = f.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + parentID, + ) + if err != nil { + return false, fmt.Errorf("isStatusHomeTimelineable: error getting status parent %s: %w", parentID, err) + } + + if (parent.AccountID == owner.ID) || + parent.MentionsAccount(owner.ID) { + // Owner is in / mentioned in + // this status thread. + included = true + break + } + + if oneAuthor { + // Check if this is a single-author status thread. + oneAuthor = (parent.AccountID == status.AccountID) + } + } + + if parent != status && !included && !oneAuthor { + log.Trace(ctx, "ignoring visible reply to conversation thread excluding owner") + return false, nil + } + + // At this point status is either a top-level status, a reply in a single + // author thread (e.g. "this is my weird-ass take and here is why 1/10 🧵"), + // or a thread mentioning / including timeline owner. + + if status.Visibility == gtsmodel.VisibilityFollowersOnly || + status.Visibility == gtsmodel.VisibilityMutualsOnly { + // Followers/mutuals only post that already passed the status + // visibility check, (i.e. we follow / mutuals with author). + return true, nil + } + + // Ensure owner follows author of public/unlocked status. + follow, err := f.state.DB.IsFollowing(ctx, + owner.ID, + status.AccountID, + ) + if err != nil { + return false, fmt.Errorf("isStatusHomeTimelineable: error checking follow %s->%s: %w", owner.ID, status.AccountID, err) + } + + if !follow { + log.Trace(ctx, "ignoring visible status from unfollowed author") + return false, nil + } + + return true, nil +} diff --git a/internal/visibility/statushometimelineable_test.go b/internal/visibility/home_timeline_test.go index 8f7e51362..b81f8fe4c 100644 --- a/internal/visibility/statushometimelineable_test.go +++ b/internal/visibility/home_timeline_test.go @@ -25,86 +25,77 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/testrig" ) -type StatusStatusHometimelineableTestSuite struct { +type StatusStatusHomeTimelineableTestSuite struct { FilterStandardTestSuite } -func (suite *StatusStatusHometimelineableTestSuite) TestOwnStatusHometimelineable() { +func (suite *StatusStatusHomeTimelineableTestSuite) TestOwnStatusHomeTimelineable() { testStatus := suite.testStatuses["local_account_1_status_1"] testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - timelineable, err := suite.filter.StatusHometimelineable(ctx, testStatus, testAccount) + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(timelineable) } -func (suite *StatusStatusHometimelineableTestSuite) TestFollowingStatusHometimelineable() { +func (suite *StatusStatusHomeTimelineableTestSuite) TestFollowingStatusHomeTimelineable() { testStatus := suite.testStatuses["local_account_2_status_1"] testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - timelineable, err := suite.filter.StatusHometimelineable(ctx, testStatus, testAccount) + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(timelineable) } -func (suite *StatusStatusHometimelineableTestSuite) TestNotFollowingStatusHometimelineable() { +func (suite *StatusStatusHomeTimelineableTestSuite) TestNotFollowingStatusHomeTimelineable() { testStatus := suite.testStatuses["remote_account_1_status_1"] testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - timelineable, err := suite.filter.StatusHometimelineable(ctx, testStatus, testAccount) + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) suite.NoError(err) suite.False(timelineable) } -func (suite *StatusStatusHometimelineableTestSuite) TestStatusTooNewNotTimelineable() { +func (suite *StatusStatusHomeTimelineableTestSuite) TestStatusTooNewNotTimelineable() { testStatus := >smodel.Status{} *testStatus = *suite.testStatuses["local_account_1_status_1"] - var err error - testStatus.ID, err = id.NewULIDFromTime(time.Now().Add(10 * time.Minute)) - if err != nil { - suite.FailNow(err.Error()) - } + testStatus.CreatedAt = time.Now().Add(25 * time.Hour) testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - timelineable, err := suite.filter.StatusHometimelineable(ctx, testStatus, testAccount) + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) suite.NoError(err) suite.False(timelineable) } -func (suite *StatusStatusHometimelineableTestSuite) TestStatusNotTooNewTimelineable() { +func (suite *StatusStatusHomeTimelineableTestSuite) TestStatusNotTooNewTimelineable() { testStatus := >smodel.Status{} *testStatus = *suite.testStatuses["local_account_1_status_1"] - var err error - testStatus.ID, err = id.NewULIDFromTime(time.Now().Add(4 * time.Minute)) - if err != nil { - suite.FailNow(err.Error()) - } + testStatus.CreatedAt = time.Now().Add(23 * time.Hour) testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - timelineable, err := suite.filter.StatusHometimelineable(ctx, testStatus, testAccount) + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) suite.NoError(err) suite.True(timelineable) } -func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyFollowersOnly() { +func (suite *StatusStatusHomeTimelineableTestSuite) TestChainReplyFollowersOnly() { ctx := context.Background() // This scenario makes sure that we don't timeline a status which is a followers-only @@ -112,9 +103,8 @@ func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyFollowersOnly( // timeline owner account doesn't follow. // // In other words, remote_account_1 posts a followers-only status, which local_account_1 replies to; - // THEN, local_account_1 replies to their own reply. We don't want this last status to appear - // in the timeline of local_account_2, even though they follow local_account_1, because they - // *don't* follow remote_account_1. + // THEN, local_account_1 replies to their own reply. None of these statuses should appear to + // local_account_2 since they don't follow the original parent. // // See: https://github.com/superseriousbusiness/gotosocial/issues/501 @@ -152,7 +142,7 @@ func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyFollowersOnly( suite.FailNow(err.Error()) } // this status should not be hometimelineable for local_account_2 - originalStatusTimelineable, err := suite.filter.StatusHometimelineable(ctx, originalStatus, timelineOwnerAccount) + originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) suite.NoError(err) suite.False(originalStatusTimelineable) @@ -185,8 +175,8 @@ func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyFollowersOnly( if err := suite.db.PutStatus(ctx, firstReplyStatus); err != nil { suite.FailNow(err.Error()) } - // this status should not be hometimelineable for local_account_2 - firstReplyStatusTimelineable, err := suite.filter.StatusHometimelineable(ctx, firstReplyStatus, timelineOwnerAccount) + // this status should be hometimelineable for local_account_2 + firstReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, firstReplyStatus) suite.NoError(err) suite.False(firstReplyStatusTimelineable) @@ -221,12 +211,12 @@ func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyFollowersOnly( } // this status should ALSO not be hometimelineable for local_account_2 - secondReplyStatusTimelineable, err := suite.filter.StatusHometimelineable(ctx, secondReplyStatus, timelineOwnerAccount) + secondReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, secondReplyStatus) suite.NoError(err) suite.False(secondReplyStatusTimelineable) } -func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyPublicAndUnlocked() { +func (suite *StatusStatusHomeTimelineableTestSuite) TestChainReplyPublicAndUnlocked() { ctx := context.Background() // This scenario is exactly the same as the above test, but for a mix of unlocked + public posts @@ -265,7 +255,7 @@ func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyPublicAndUnloc suite.FailNow(err.Error()) } // this status should not be hometimelineable for local_account_2 - originalStatusTimelineable, err := suite.filter.StatusHometimelineable(ctx, originalStatus, timelineOwnerAccount) + originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) suite.NoError(err) suite.False(originalStatusTimelineable) @@ -299,7 +289,7 @@ func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyPublicAndUnloc suite.FailNow(err.Error()) } // this status should not be hometimelineable for local_account_2 - firstReplyStatusTimelineable, err := suite.filter.StatusHometimelineable(ctx, firstReplyStatus, timelineOwnerAccount) + firstReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, firstReplyStatus) suite.NoError(err) suite.False(firstReplyStatusTimelineable) @@ -334,11 +324,11 @@ func (suite *StatusStatusHometimelineableTestSuite) TestChainReplyPublicAndUnloc } // this status should ALSO not be hometimelineable for local_account_2 - secondReplyStatusTimelineable, err := suite.filter.StatusHometimelineable(ctx, secondReplyStatus, timelineOwnerAccount) + secondReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, secondReplyStatus) suite.NoError(err) suite.False(secondReplyStatusTimelineable) } -func TestStatusHometimelineableTestSuite(t *testing.T) { - suite.Run(t, new(StatusStatusHometimelineableTestSuite)) +func TestStatusHomeTimelineableTestSuite(t *testing.T) { + suite.Run(t, new(StatusStatusHomeTimelineableTestSuite)) } diff --git a/internal/visibility/public_timeline.go b/internal/visibility/public_timeline.go new file mode 100644 index 000000000..13ac07831 --- /dev/null +++ b/internal/visibility/public_timeline.go @@ -0,0 +1,121 @@ +// 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 visibility + +import ( + "context" + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// StatusHomeTimelineable checks if given status should be included on requester's public timeline. Primarily relying on status visibility to requester and the AP visibility setting, and ignoring conversation threads. +func (f *Filter) StatusPublicTimelineable(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + // By default we assume no auth. + requesterID := noauth + + if requester != nil { + // Use provided account ID. + requesterID = requester.ID + } + + visibility, err := f.state.Caches.Visibility.Load("Type.RequesterID.ItemID", func() (*cache.CachedVisibility, error) { + // Visibility not yet cached, perform timeline visibility lookup. + visible, err := f.isStatusPublicTimelineable(ctx, requester, status) + if err != nil { + return nil, err + } + + // Return visibility value. + return &cache.CachedVisibility{ + ItemID: status.ID, + RequesterID: requesterID, + Type: cache.VisibilityTypePublic, + Value: visible, + }, nil + }, "public", requesterID, status.ID) + if err != nil { + if err == cache.SentinelError { + // Filter-out our temporary + // race-condition error. + return false, nil + } + + return false, err + } + + return visibility.Value, nil +} + +func (f *Filter) isStatusPublicTimelineable(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + if status.CreatedAt.After(time.Now().Add(24 * time.Hour)) { + // Statuses made over 1 day in the future we don't show... + log.Warnf(ctx, "status >24hrs in the future: %+v", status) + return false, nil + } + + // Don't show boosts on timeline. + if status.BoostOfID != "" { + return false, nil + } + + // Check whether status is visible to requesting account. + visible, err := f.StatusVisible(ctx, requester, status) + if err != nil { + return false, err + } + + if !visible { + log.Trace(ctx, "status not visible to timeline requester") + return false, nil + } + + for parent := status; parent.InReplyToURI != ""; { + // Fetch next parent to lookup. + parentID := parent.InReplyToID + if parentID == "" { + log.Warnf(ctx, "status not yet deref'd: %s", parent.InReplyToURI) + return false, cache.SentinelError + } + + // Get the next parent in the chain from DB. + parent, err = f.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + parentID, + ) + if err != nil { + return false, fmt.Errorf("isStatusHomeTimelineable: error getting status parent %s: %w", parentID, err) + } + + if parent.AccountID != status.AccountID { + // This is not a single author reply-chain-thread, + // instead is an actualy conversation. Don't timeline. + log.Trace(ctx, "ignoring multi-author reply-chain") + return false, nil + } + } + + // This is either a visible status in a + // single-author thread, or a visible top + // level status. Show on public timeline. + return true, nil +} diff --git a/internal/visibility/relevantaccounts.go b/internal/visibility/relevantaccounts.go deleted file mode 100644 index 2389b8544..000000000 --- a/internal/visibility/relevantaccounts.go +++ /dev/null @@ -1,230 +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 visibility - -import ( - "context" - "errors" - "fmt" - - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -// relevantAccounts denotes accounts that are replied to, boosted by, or mentioned in a status. -type relevantAccounts struct { - // Who wrote the status - Account *gtsmodel.Account - // Who is the status replying to - InReplyToAccount *gtsmodel.Account - // Which accounts are mentioned (tagged) in the status - MentionedAccounts []*gtsmodel.Account - // Who authed the boosted status - BoostedAccount *gtsmodel.Account - // If the boosted status replies to another account, who does it reply to? - BoostedInReplyToAccount *gtsmodel.Account - // Who is mentioned (tagged) in the boosted status - BoostedMentionedAccounts []*gtsmodel.Account -} - -func (f *filter) relevantAccounts(ctx context.Context, status *gtsmodel.Status, getBoosted bool) (*relevantAccounts, error) { - relAccts := &relevantAccounts{ - MentionedAccounts: []*gtsmodel.Account{}, - BoostedMentionedAccounts: []*gtsmodel.Account{}, - } - - /* - Here's what we need to try and extract from the status: - - // 1. Who wrote the status - Account *gtsmodel.Account - - // 2. Who is the status replying to - InReplyToAccount *gtsmodel.Account - - // 3. Which accounts are mentioned (tagged) in the status - MentionedAccounts []*gtsmodel.Account - - if getBoosted: - // 4. Who wrote the boosted status - BoostedAccount *gtsmodel.Account - - // 5. If the boosted status replies to another account, who does it reply to? - BoostedInReplyToAccount *gtsmodel.Account - - // 6. Who is mentioned (tagged) in the boosted status - BoostedMentionedAccounts []*gtsmodel.Account - */ - - // 1. Account. - // Account might be set on the status already - if status.Account != nil { - // it was set - relAccts.Account = status.Account - } else { - // it wasn't set, so get it from the db - account, err := f.db.GetAccountByID(ctx, status.AccountID) - if err != nil { - return nil, fmt.Errorf("relevantAccounts: error getting account with id %s: %s", status.AccountID, err) - } - // set it on the status in case we need it further along - status.Account = account - // set it on relevant accounts - relAccts.Account = account - } - - // 2. InReplyToAccount - // only get this if InReplyToAccountID is set - if status.InReplyToAccountID != "" { - // InReplyToAccount might be set on the status already - if status.InReplyToAccount != nil { - // it was set - relAccts.InReplyToAccount = status.InReplyToAccount - } else { - // it wasn't set, so get it from the db - inReplyToAccount, err := f.db.GetAccountByID(ctx, status.InReplyToAccountID) - if err != nil { - return nil, fmt.Errorf("relevantAccounts: error getting inReplyToAccount with id %s: %s", status.InReplyToAccountID, err) - } - // set it on the status in case we need it further along - status.InReplyToAccount = inReplyToAccount - // set it on relevant accounts - relAccts.InReplyToAccount = inReplyToAccount - } - } - - // 3. MentionedAccounts - // First check if status.Mentions is populated with all mentions that correspond to status.MentionIDs - for _, mID := range status.MentionIDs { - if mID == "" { - continue - } - if !idIn(mID, status.Mentions) { - // mention with ID isn't in status.Mentions - mention, err := f.db.GetMention(ctx, mID) - if err != nil { - return nil, fmt.Errorf("relevantAccounts: error getting mention with id %s: %s", mID, err) - } - if mention == nil { - return nil, fmt.Errorf("relevantAccounts: mention with id %s was nil", mID) - } - status.Mentions = append(status.Mentions, mention) - } - } - // now filter mentions to make sure we only have mentions with a corresponding ID - nm := []*gtsmodel.Mention{} - for _, m := range status.Mentions { - if m == nil { - continue - } - if mentionIn(m, status.MentionIDs) { - nm = append(nm, m) - relAccts.MentionedAccounts = append(relAccts.MentionedAccounts, m.TargetAccount) - } - } - status.Mentions = nm - - if len(status.Mentions) != len(status.MentionIDs) { - return nil, errors.New("relevantAccounts: mentions length did not correspond with mentionIDs length") - } - - // if getBoosted is set, we should check the same properties on the boosted account as well - if getBoosted { - // 4, 5, 6. Boosted status items - // get the boosted status if it's not set on the status already - if status.BoostOfID != "" && status.BoostOf == nil { - boostedStatus, err := f.db.GetStatusByID(ctx, status.BoostOfID) - if err != nil { - return nil, fmt.Errorf("relevantAccounts: error getting boosted status with id %s: %s", status.BoostOfID, err) - } - status.BoostOf = boostedStatus - } - - if status.BoostOf != nil { - // return relevant accounts for the boosted status - boostedRelAccts, err := f.relevantAccounts(ctx, status.BoostOf, false) // false because we don't want to recurse - if err != nil { - return nil, fmt.Errorf("relevantAccounts: error getting relevant accounts of boosted status %s: %s", status.BoostOf.ID, err) - } - relAccts.BoostedAccount = boostedRelAccts.Account - relAccts.BoostedInReplyToAccount = boostedRelAccts.InReplyToAccount - relAccts.BoostedMentionedAccounts = boostedRelAccts.MentionedAccounts - } - } - - return relAccts, nil -} - -// domainBlockedRelevant checks through all relevant accounts attached to a status -// to make sure none of them are domain blocked by this instance. -func (f *filter) domainBlockedRelevant(ctx context.Context, r *relevantAccounts) (bool, error) { - domains := []string{} - - if r.Account != nil { - domains = append(domains, r.Account.Domain) - } - - if r.InReplyToAccount != nil { - domains = append(domains, r.InReplyToAccount.Domain) - } - - for _, a := range r.MentionedAccounts { - if a != nil { - domains = append(domains, a.Domain) - } - } - - if r.BoostedAccount != nil { - domains = append(domains, r.BoostedAccount.Domain) - } - - if r.BoostedInReplyToAccount != nil { - domains = append(domains, r.BoostedInReplyToAccount.Domain) - } - - for _, a := range r.BoostedMentionedAccounts { - if a != nil { - domains = append(domains, a.Domain) - } - } - - return f.db.AreDomainsBlocked(ctx, domains) -} - -func idIn(id string, mentions []*gtsmodel.Mention) bool { - for _, m := range mentions { - if m == nil { - continue - } - if m.ID == id { - return true - } - } - return false -} - -func mentionIn(mention *gtsmodel.Mention, ids []string) bool { - if mention == nil { - return false - } - for _, i := range ids { - if mention.ID == i { - return true - } - } - return false -} diff --git a/internal/visibility/status.go b/internal/visibility/status.go new file mode 100644 index 000000000..dc8261624 --- /dev/null +++ b/internal/visibility/status.go @@ -0,0 +1,217 @@ +// 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 visibility + +import ( + "context" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// StatusesVisible calls StatusVisible for each status in the statuses slice, and returns a slice of only statuses which are visible to the requester. +func (f *Filter) StatusesVisible(ctx context.Context, requester *gtsmodel.Account, statuses []*gtsmodel.Status) ([]*gtsmodel.Status, error) { + // Preallocate slice of maximum possible length. + filtered := make([]*gtsmodel.Status, 0, len(statuses)) + + for _, status := range statuses { + // Check whether status is visible to requester. + visible, err := f.StatusVisible(ctx, requester, status) + if err != nil { + return nil, err + } + + if visible { + // Add filtered status to ret slice. + filtered = append(filtered, status) + } + } + + return filtered, nil +} + +// StatusVisible will check if given status is visible to requester, accounting for requester with no auth (i.e is nil), suspensions, disabled local users, account blocks and status privacy. +func (f *Filter) StatusVisible(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + // By default we assume no auth. + requesterID := noauth + + if requester != nil { + // Use provided account ID. + requesterID = requester.ID + } + + visibility, err := f.state.Caches.Visibility.Load("Type.RequesterID.ItemID", func() (*cache.CachedVisibility, error) { + // Visibility not yet cached, perform visibility lookup. + visible, err := f.isStatusVisible(ctx, requester, status) + if err != nil { + return nil, err + } + + // Return visibility value. + return &cache.CachedVisibility{ + ItemID: status.ID, + RequesterID: requesterID, + Type: cache.VisibilityTypeStatus, + Value: visible, + }, nil + }, "status", requesterID, status.ID) + if err != nil { + return false, err + } + + return visibility.Value, nil +} + +// isStatusVisible will check if status is visible to requester. It is the "meat" of the logic to Filter{}.StatusVisible() which is called within cache loader callback. +func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + // Ensure that status is fully populated for further processing. + if err := f.state.DB.PopulateStatus(ctx, status); err != nil { + return false, err + } + + // Check whether status accounts are visible to the requester. + visible, err := f.areStatusAccountsVisible(ctx, requester, status) + if err != nil { + return false, fmt.Errorf("isStatusVisible: error checking status %s account visibility: %w", status.ID, err) + } else if !visible { + return false, nil + } + + if status.Visibility == gtsmodel.VisibilityPublic { + // This status will be visible to all. + return true, nil + } + + if requester == nil { + // This request is WITHOUT auth, and status is NOT public. + log.Trace(ctx, "unauthorized request to non-public status") + return false, nil + } + + if status.Visibility == gtsmodel.VisibilityUnlocked { + // This status is visible to all auth'd accounts. + return true, nil + } + + if requester.ID == status.AccountID { + // Author can always see their own status. + return true, nil + } + + if status.MentionsAccount(requester.ID) { + // Status mentions the requesting account. + return true, nil + } + + if status.BoostOf != nil { + if !status.BoostOf.MentionsPopulated() { + // Boosted status needs its mentions populating, fetch these from database. + status.BoostOf.Mentions, err = f.state.DB.GetMentions(ctx, status.BoostOf.MentionIDs) + if err != nil { + return false, fmt.Errorf("isStatusVisible: error populating boosted status %s mentions: %w", status.BoostOfID, err) + } + } + + if status.BoostOf.MentionsAccount(requester.ID) { + // Boosted status mentions the requesting account. + return true, nil + } + } + + switch status.Visibility { + case gtsmodel.VisibilityFollowersOnly: + // Check requester follows status author. + follows, err := f.state.DB.IsFollowing(ctx, + requester.ID, + status.AccountID, + ) + if err != nil { + return false, fmt.Errorf("isStatusVisible: error checking follow %s->%s: %w", requester.ID, status.AccountID, err) + } + + if !follows { + log.Trace(ctx, "follow-only status not visible to requester") + return false, nil + } + + return true, nil + + case gtsmodel.VisibilityMutualsOnly: + // Check mutual following between requester and author. + mutuals, err := f.state.DB.IsMutualFollowing(ctx, + requester.ID, + status.AccountID, + ) + if err != nil { + return false, fmt.Errorf("isStatusVisible: error checking mutual follow %s<->%s: %w", requester.ID, status.AccountID, err) + } + + if !mutuals { + log.Trace(ctx, "mutual-only status not visible to requester") + return false, nil + } + + return true, nil + + case gtsmodel.VisibilityDirect: + log.Trace(ctx, "direct status not visible to requester") + return false, nil + + default: + log.Warnf(ctx, "unexpected status visibility %s for %s", status.Visibility, status.URI) + return false, nil + } +} + +// areStatusAccountsVisible calls Filter{}.AccountVisible() on status author and the status boost-of (if set) author, returning visibility of status (and boost-of) to requester. +func (f *Filter) areStatusAccountsVisible(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + // Check whether status author's account is visible to requester. + visible, err := f.AccountVisible(ctx, requester, status.Account) + if err != nil { + return false, err + } + + if !visible { + log.Trace(ctx, "status author not visible to requester") + return false, nil + } + + if status.BoostOfID != "" { + // This is a boosted status. + + if status.AccountID == status.BoostOfAccountID { + // Some clout-chaser boosted their own status, tch. + return true, nil + } + + // Check whether boosted status author's account is visible to requester. + visible, err := f.AccountVisible(ctx, requester, status.BoostOfAccount) + if err != nil { + return false, err + } + + if !visible { + log.Trace(ctx, "boosted status author not visible to requester") + return false, nil + } + } + + return true, nil +} diff --git a/internal/visibility/statusvisible_test.go b/internal/visibility/status_test.go index bd799d7ca..ad6bc66df 100644 --- a/internal/visibility/statusvisible_test.go +++ b/internal/visibility/status_test.go @@ -34,7 +34,7 @@ func (suite *StatusVisibleTestSuite) TestOwnStatusVisible() { testAccount := suite.testAccounts["local_account_1"] ctx := context.Background() - visible, err := suite.filter.StatusVisible(ctx, testStatus, testAccount) + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) suite.NoError(err) suite.True(visible) @@ -48,7 +48,7 @@ func (suite *StatusVisibleTestSuite) TestOwnDMVisible() { suite.NoError(err) testAccount := suite.testAccounts["local_account_2"] - visible, err := suite.filter.StatusVisible(ctx, testStatus, testAccount) + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) suite.NoError(err) suite.True(visible) @@ -62,7 +62,7 @@ func (suite *StatusVisibleTestSuite) TestDMVisibleToTarget() { suite.NoError(err) testAccount := suite.testAccounts["local_account_1"] - visible, err := suite.filter.StatusVisible(ctx, testStatus, testAccount) + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) suite.NoError(err) suite.True(visible) @@ -76,7 +76,7 @@ func (suite *StatusVisibleTestSuite) TestDMNotVisibleIfNotMentioned() { suite.NoError(err) testAccount := suite.testAccounts["admin_account"] - visible, err := suite.filter.StatusVisible(ctx, testStatus, testAccount) + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) suite.NoError(err) suite.False(visible) @@ -92,7 +92,7 @@ func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotMutuals() { suite.NoError(err) testAccount := suite.testAccounts["local_account_2"] - visible, err := suite.filter.StatusVisible(ctx, testStatus, testAccount) + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) suite.NoError(err) suite.False(visible) @@ -108,12 +108,54 @@ func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotFollowing() { suite.NoError(err) testAccount := suite.testAccounts["admin_account"] - visible, err := suite.filter.StatusVisible(ctx, testStatus, testAccount) + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) suite.NoError(err) suite.False(visible) } +func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotMutualsCached() { + ctx := context.Background() + testStatusID := suite.testStatuses["local_account_1_status_4"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["local_account_2"] + + // Perform a status visibility check while mutuals, this shsould be true. + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.True(visible) + + err = suite.db.DeleteFollowByID(ctx, suite.testFollows["local_account_2_local_account_1"].ID) + suite.NoError(err) + + // Perform a status visibility check after unfollow, this should be false. + visible, err = suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.False(visible) +} + +func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotFollowingCached() { + ctx := context.Background() + testStatusID := suite.testStatuses["local_account_1_status_5"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["admin_account"] + + // Perform a status visibility check while following, this shsould be true. + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.True(visible) + + err = suite.db.DeleteFollowByID(ctx, suite.testFollows["admin_account_local_account_1"].ID) + suite.NoError(err) + + // Perform a status visibility check after unfollow, this should be false. + visible, err = suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.False(visible) +} + func TestStatusVisibleTestSuite(t *testing.T) { suite.Run(t, new(StatusVisibleTestSuite)) } diff --git a/internal/visibility/statusboostable.go b/internal/visibility/statusboostable.go deleted file mode 100644 index e008008c2..000000000 --- a/internal/visibility/statusboostable.go +++ /dev/null @@ -1,60 +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 visibility - -import ( - "context" - "errors" - "fmt" - - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -func (f *filter) StatusBoostable(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error) { - // if the status isn't visible, it certainly isn't boostable - visible, err := f.StatusVisible(ctx, targetStatus, requestingAccount) - if err != nil { - return false, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - } - if !visible { - return false, errors.New("status is not visible") - } - - // direct messages are never boostable, even if they're visible - if targetStatus.Visibility == gtsmodel.VisibilityDirect { - log.Trace(ctx, "status is not boostable because it is a DM") - return false, nil - } - - // the original account should always be able to boost its own non-DM statuses - if requestingAccount.ID == targetStatus.Account.ID { - log.Trace(ctx, "status is boostable because author is booster") - return true, nil - } - - // if status is followers-only and not the author's, it is not boostable - if targetStatus.Visibility == gtsmodel.VisibilityFollowersOnly { - log.Trace(ctx, "status not boostable because it is followers-only") - return false, nil - } - - // otherwise, status is as boostable as it says it is - log.Trace(ctx, "defaulting to status.boostable value") - return *targetStatus.Boostable, nil -} diff --git a/internal/visibility/statushometimelineable.go b/internal/visibility/statushometimelineable.go deleted file mode 100644 index b5d5b836e..000000000 --- a/internal/visibility/statushometimelineable.go +++ /dev/null @@ -1,126 +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 visibility - -import ( - "context" - "fmt" - "time" - - "codeberg.org/gruf/go-kv" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -func (f *filter) StatusHometimelineable(ctx context.Context, targetStatus *gtsmodel.Status, timelineOwnerAccount *gtsmodel.Account) (bool, error) { - l := log.WithContext(ctx). - WithFields(kv.Fields{{"statusID", targetStatus.ID}}...) - - // don't timeline statuses more than 5 min in the future - maxID, err := id.NewULIDFromTime(time.Now().Add(5 * time.Minute)) - if err != nil { - return false, err - } - - if targetStatus.ID > maxID { - l.Debug("status not hometimelineable because it's from more than 5 minutes in the future") - return false, nil - } - - // status owner should always be able to see their own status in their timeline so we can return early if this is the case - if targetStatus.AccountID == timelineOwnerAccount.ID { - return true, nil - } - - v, err := f.StatusVisible(ctx, targetStatus, timelineOwnerAccount) - if err != nil { - return false, fmt.Errorf("StatusHometimelineable: error checking visibility of status with id %s: %s", targetStatus.ID, err) - } - - if !v { - l.Debug("status is not hometimelineable because it's not visible to the requester") - return false, nil - } - - for _, m := range targetStatus.Mentions { - if m.TargetAccountID == timelineOwnerAccount.ID { - // if we're mentioned we should be able to see the post - return true, nil - } - } - - // check we follow the originator of the status - if targetStatus.Account == nil { - tsa, err := f.db.GetAccountByID(ctx, targetStatus.AccountID) - if err != nil { - return false, fmt.Errorf("StatusHometimelineable: error getting status author account with id %s: %s", targetStatus.AccountID, err) - } - targetStatus.Account = tsa - } - following, err := f.db.IsFollowing(ctx, timelineOwnerAccount, targetStatus.Account) - if err != nil { - return false, fmt.Errorf("StatusHometimelineable: error checking if %s follows %s: %s", timelineOwnerAccount.ID, targetStatus.AccountID, err) - } - if !following { - return false, nil - } - - // Don't timeline a status whose parent hasn't been dereferenced yet or can't be dereferenced. - // If we have the reply to URI but don't have an ID for the replied-to account or the replied-to status in our database, we haven't dereferenced it yet. - if targetStatus.InReplyToURI != "" && (targetStatus.InReplyToID == "" || targetStatus.InReplyToAccountID == "") { - return false, nil - } - - // if a status replies to an ID we know in the database, we need to check that parent status too - if targetStatus.InReplyToID != "" { - // pin the reply to status on to this status if it hasn't been done already - if targetStatus.InReplyTo == nil { - rs, err := f.db.GetStatusByID(ctx, targetStatus.InReplyToID) - if err != nil { - return false, fmt.Errorf("StatusHometimelineable: error getting replied to status with id %s: %s", targetStatus.InReplyToID, err) - } - targetStatus.InReplyTo = rs - } - - // pin the reply to account on to this status if it hasn't been done already - if targetStatus.InReplyToAccount == nil { - ra, err := f.db.GetAccountByID(ctx, targetStatus.InReplyToAccountID) - if err != nil { - return false, fmt.Errorf("StatusHometimelineable: error getting replied to account with id %s: %s", targetStatus.InReplyToAccountID, err) - } - targetStatus.InReplyToAccount = ra - } - - // if it's a reply to the timelineOwnerAccount, we don't need to check if the timelineOwnerAccount follows itself, just return true, they can see it - if targetStatus.InReplyToAccountID == timelineOwnerAccount.ID { - return true, nil - } - - // make sure the parent status is also home timelineable, otherwise we shouldn't timeline this one either - parentStatusTimelineable, err := f.StatusHometimelineable(ctx, targetStatus.InReplyTo, timelineOwnerAccount) - if err != nil { - return false, fmt.Errorf("StatusHometimelineable: error checking timelineability of parent status %s of status %s: %s", targetStatus.InReplyToID, targetStatus.ID, err) - } - if !parentStatusTimelineable { - return false, nil - } - } - - return true, nil -} diff --git a/internal/visibility/statuspublictimelineable.go b/internal/visibility/statuspublictimelineable.go deleted file mode 100644 index ae2e95f06..000000000 --- a/internal/visibility/statuspublictimelineable.go +++ /dev/null @@ -1,72 +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 visibility - -import ( - "context" - "fmt" - "time" - - "codeberg.org/gruf/go-kv" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -func (f *filter) StatusPublictimelineable(ctx context.Context, targetStatus *gtsmodel.Status, timelineOwnerAccount *gtsmodel.Account) (bool, error) { - l := log.WithContext(ctx). - WithFields(kv.Fields{{"statusID", targetStatus.ID}}...) - - // don't timeline statuses more than 5 min in the future - maxID, err := id.NewULIDFromTime(time.Now().Add(5 * time.Minute)) - if err != nil { - return false, err - } - - if targetStatus.ID > maxID { - l.Debug("status not hometimelineable because it's from more than 5 minutes in the future") - return false, nil - } - - // Don't timeline boosted statuses - if targetStatus.BoostOfID != "" { - return false, nil - } - - // Don't timeline a reply - if targetStatus.InReplyToURI != "" || targetStatus.InReplyToID != "" || targetStatus.InReplyToAccountID != "" { - return false, nil - } - - // status owner should always be able to see their own status in their timeline so we can return early if this is the case - if timelineOwnerAccount != nil && targetStatus.AccountID == timelineOwnerAccount.ID { - return true, nil - } - - v, err := f.StatusVisible(ctx, targetStatus, timelineOwnerAccount) - if err != nil { - return false, fmt.Errorf("StatusPublictimelineable: error checking visibility of status with id %s: %s", targetStatus.ID, err) - } - - if !v { - l.Debug("status is not publicTimelineable because it's not visible to the requester") - return false, nil - } - - return true, nil -} diff --git a/internal/visibility/statusvisible.go b/internal/visibility/statusvisible.go deleted file mode 100644 index 91a1f6221..000000000 --- a/internal/visibility/statusvisible.go +++ /dev/null @@ -1,252 +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 visibility - -import ( - "context" - "fmt" - - "codeberg.org/gruf/go-kv" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -func (f *filter) StatusVisible(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error) { - l := log.WithContext(ctx). - WithFields(kv.Fields{{"statusID", targetStatus.ID}}...) - - // Fetch any relevant accounts for the target status - const getBoosted = true - relevantAccounts, err := f.relevantAccounts(ctx, targetStatus, getBoosted) - if err != nil { - l.Debugf("error pulling relevant accounts for status %s: %s", targetStatus.ID, err) - return false, fmt.Errorf("StatusVisible: error pulling relevant accounts for status %s: %s", targetStatus.ID, err) - } - - // Check we have determined a target account - targetAccount := relevantAccounts.Account - if targetAccount == nil { - l.Trace("target account is not set") - return false, nil - } - - // Check for domain blocks among relevant accounts - domainBlocked, err := f.domainBlockedRelevant(ctx, relevantAccounts) - if err != nil { - l.Debugf("error checking domain block: %s", err) - return false, fmt.Errorf("error checking domain block: %s", err) - } else if domainBlocked { - return false, nil - } - - // if target account is suspended then don't show the status - if !targetAccount.SuspendedAt.IsZero() { - l.Trace("target account suspended at is not zero") - return false, nil - } - - // if the target user doesn't exist (anymore) then the status also shouldn't be visible - // note: we only do this for local users - if targetAccount.Domain == "" { - targetUser, err := f.db.GetUserByAccountID(ctx, targetAccount.ID) - if err != nil { - l.Debug("target user could not be selected") - if err == db.ErrNoEntries { - return false, nil - } - return false, fmt.Errorf("StatusVisible: db error selecting user for local target account %s: %s", targetAccount.ID, err) - } - - // if target user is disabled, not yet approved, or not confirmed then don't show the status - // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!) - if *targetUser.Disabled || !*targetUser.Approved || targetUser.ConfirmedAt.IsZero() { - l.Trace("target user is disabled, not approved, or not confirmed") - return false, nil - } - } - - // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed. - // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't. - if requestingAccount == nil { - if targetStatus.Visibility == gtsmodel.VisibilityPublic { - return true, nil - } - l.Trace("requesting account is nil but the target status isn't public") - return false, nil - } - - // if the requesting user doesn't exist (anymore) then the status also shouldn't be visible - // note: we only do this for local users - if requestingAccount.Domain == "" { - requestingUser, err := f.db.GetUserByAccountID(ctx, requestingAccount.ID) - if err != nil { - // if the requesting account is local but doesn't have a corresponding user in the db this is a problem - l.Debug("requesting user could not be selected") - if err == db.ErrNoEntries { - return false, nil - } - return false, fmt.Errorf("StatusVisible: db error selecting user for local requesting account %s: %s", requestingAccount.ID, err) - } - // okay, user exists, so make sure it has full privileges/is confirmed/approved - if *requestingUser.Disabled || !*requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() { - l.Trace("requesting account is local but corresponding user is either disabled, not approved, or not confirmed") - return false, nil - } - } - - // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten - // this far (ie., been authed) in the first place: this is just for safety. - if !requestingAccount.SuspendedAt.IsZero() { - l.Trace("requesting account is suspended") - return false, nil - } - - // if the target status belongs to the requesting account, they should always be able to view it at this point - if targetStatus.AccountID == requestingAccount.ID { - return true, nil - } - - // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou - // First check if a block exists directly between the target account (which authored the status) and the requesting account. - if blocked, err := f.db.IsBlocked(ctx, targetAccount.ID, requestingAccount.ID, true); err != nil { - l.Debugf("something went wrong figuring out if the accounts have a block: %s", err) - return false, err - } else if blocked { - // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please - l.Trace("a block exists between requesting account and target account") - return false, nil - } - - // If not in reply to the requesting account, check if inReplyToAccount is blocked - if relevantAccounts.InReplyToAccount != nil && relevantAccounts.InReplyToAccount.ID != requestingAccount.ID { - if blocked, err := f.db.IsBlocked(ctx, relevantAccounts.InReplyToAccount.ID, requestingAccount.ID, true); err != nil { - return false, err - } else if blocked { - l.Trace("a block exists between requesting account and reply to account") - return false, nil - } - } - - // status boosts accounts id - if relevantAccounts.BoostedAccount != nil { - if blocked, err := f.db.IsBlocked(ctx, relevantAccounts.BoostedAccount.ID, requestingAccount.ID, true); err != nil { - return false, err - } else if blocked { - l.Trace("a block exists between requesting account and boosted account") - return false, nil - } - } - - // status boosts a reply to account id - if relevantAccounts.BoostedInReplyToAccount != nil { - if blocked, err := f.db.IsBlocked(ctx, relevantAccounts.BoostedInReplyToAccount.ID, requestingAccount.ID, true); err != nil { - return false, err - } else if blocked { - l.Trace("a block exists between requesting account and boosted reply to account") - return false, nil - } - } - - // boost mentions accounts - for _, a := range relevantAccounts.BoostedMentionedAccounts { - if a == nil { - continue - } - if blocked, err := f.db.IsBlocked(ctx, a.ID, requestingAccount.ID, true); err != nil { - return false, err - } else if blocked { - l.Trace("a block exists between requesting account and a boosted mentioned account") - return false, nil - } - } - - // Iterate mentions to check for blocks or requester mentions - isMentioned, blockAmongMentions := false, false - for _, a := range relevantAccounts.MentionedAccounts { - if a == nil { - continue - } - - if blocked, err := f.db.IsBlocked(ctx, a.ID, requestingAccount.ID, true); err != nil { - return false, err - } else if blocked { - blockAmongMentions = true - break - } - - if a.ID == requestingAccount.ID { - isMentioned = true - } - } - - if blockAmongMentions { - l.Trace("a block exists between requesting account and a mentioned account") - return false, nil - } else if isMentioned { - // Requester mentioned, should always be visible - return true, nil - } - - // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status - // that means it's now just a matter of checking the visibility settings of the status itself - switch targetStatus.Visibility { - case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked: - // no problem here - case gtsmodel.VisibilityFollowersOnly: - // Followers-only post, check for a one-way follow to target - follows, err := f.db.IsFollowing(ctx, requestingAccount, targetAccount) - if err != nil { - return false, err - } - if !follows { - l.Trace("requested status is followers only but requesting account is not a follower") - return false, nil - } - case gtsmodel.VisibilityMutualsOnly: - // Mutuals-only post, check for a mutual follow - mutuals, err := f.db.IsMutualFollowing(ctx, requestingAccount, targetAccount) - if err != nil { - return false, err - } - if !mutuals { - l.Trace("requested status is mutuals only but accounts aren't mufos") - return false, nil - } - case gtsmodel.VisibilityDirect: - l.Trace("requesting account requests a direct status it's not mentioned in") - return false, nil // it's not mentioned -_- - } - - // If we reached here, all is okay - return true, nil -} - -func (f *filter) StatusesVisible(ctx context.Context, statuses []*gtsmodel.Status, requestingAccount *gtsmodel.Account) ([]*gtsmodel.Status, error) { - filtered := []*gtsmodel.Status{} - for _, s := range statuses { - visible, err := f.StatusVisible(ctx, s, requestingAccount) - if err != nil { - return nil, err - } - if visible { - filtered = append(filtered, s) - } - } - return filtered, nil -} |