[performance] refactoring + add fave / follow / request / visibility caching (#1607)

* refactor visibility checking, add caching for visibility

* invalidate visibility cache items on account / status deletes

* fix requester ID passed to visibility cache nil ptr

* de-interface caches, fix home / public timeline caching + visibility

* finish adding code comments for visibility filter

* fix angry goconst linter warnings

* actually finish adding filter visibility code comments for timeline functions

* move home timeline status author check to after visibility

* remove now-unused code

* add more code comments

* add TODO code comment, update printed cache start names

* update printed cache names on stop

* start adding separate follow(request) delete db functions, add specific visibility cache tests

* add relationship type caching

* fix getting local account follows / followed-bys, other small codebase improvements

* simplify invalidation using cache hooks, add more GetAccountBy___() functions

* fix boosting to return 404 if not boostable but no error (to not leak status ID)

* remove dead code

* improved placement of cache invalidation

* update license headers

* add example follow, follow-request config entries

* add example visibility cache configuration to config file

* use specific PutFollowRequest() instead of just Put()

* add tests for all GetAccountBy()

* add GetBlockBy() tests

* update block to check primitive fields

* update and finish adding Get{Account,Block,Follow,FollowRequest}By() tests

* fix copy-pasted code

* update envparsing test

* whitespace

* fix bun struct tag

* add license header to gtscontext

* fix old license header

* improved error creation to not use fmt.Errorf() when not needed

* fix various rebase conflicts, fix account test

* remove commented-out code, fix-up mention caching

* fix mention select bun statement

* ensure mention target account populated, pass in context to customrenderer logging

* remove more uncommented code, fix typeutil test

* add statusfave database model caching

* add status fave cache configuration

* add status fave cache example config

* woops, catch missed error. nice catch linter!

* add back testrig panic on nil db

* update example configuration to match defaults, slight tweak to cache configuration defaults

* update envparsing test with new defaults

* fetch followingget to use the follow target account

* use accounnt.IsLocal() instead of empty domain check

* use constants for the cache visibility type check

* use bun.In() for notification type restriction in db query

* include replies when fetching PublicTimeline() (to account for single-author threads in Visibility{}.StatusPublicTimelineable())

* use bun query building for nested select statements to ensure working with postgres

* update public timeline future status checks to match visibility filter

* same as previous, for home timeline

* update public timeline tests to dynamically check for appropriate statuses

* migrate accounts to allow unique constraint on public_key

* provide minimal account with publicKey

---------

Signed-off-by: kim <grufwub@gmail.com>
Co-authored-by: tsmethurst <tobi.smethurst@protonmail.com>
This commit is contained in:
kim 2023-03-28 14:03:14 +01:00 committed by GitHub
commit de6e3e5f2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
100 changed files with 4423 additions and 2367 deletions

View 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 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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
// noauth is a placeholder ID used in cache lookups
// when there is no authorized account ID to use.
const noauth = "noauth"
// 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)
}
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}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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 := &gtsmodel.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 := &gtsmodel.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))
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}