diff options
Diffstat (limited to 'internal/filter')
-rw-r--r-- | internal/filter/interaction/filter.go | 34 | ||||
-rw-r--r-- | internal/filter/interaction/interactable.go | 561 | ||||
-rw-r--r-- | internal/filter/visibility/boostable.go | 57 | ||||
-rw-r--r-- | internal/filter/visibility/boostable_test.go | 154 | ||||
-rw-r--r-- | internal/filter/visibility/status.go | 65 | ||||
-rw-r--r-- | internal/filter/visibility/status_test.go | 44 |
6 files changed, 700 insertions, 215 deletions
diff --git a/internal/filter/interaction/filter.go b/internal/filter/interaction/filter.go new file mode 100644 index 000000000..49e0758c1 --- /dev/null +++ b/internal/filter/interaction/filter.go @@ -0,0 +1,34 @@ +// 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 interaction + +import ( + "github.com/superseriousbusiness/gotosocial/internal/state" +) + +// Filter packages up logic for checking whether +// an interaction is permitted within set policies. +type Filter struct { + state *state.State +} + +// NewFilter returns a new Filter +// that will use the provided state. +func NewFilter(state *state.State) *Filter { + return &Filter{state: state} +} diff --git a/internal/filter/interaction/interactable.go b/internal/filter/interaction/interactable.go new file mode 100644 index 000000000..fe31ce8f2 --- /dev/null +++ b/internal/filter/interaction/interactable.go @@ -0,0 +1,561 @@ +// 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 interaction + +import ( + "context" + "fmt" + "slices" + + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +type matchType int + +const ( + none matchType = 0 + implicit matchType = 1 + explicit matchType = 2 +) + +// startedThread returns true if requester started +// the thread that the given status is part of. +// Ie., requester created the first post in the thread. +func (f *Filter) startedThread( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) (bool, error) { + parents, err := f.state.DB.GetStatusParents(ctx, status) + if err != nil { + return false, fmt.Errorf("db error getting parents of %s: %w", status.ID, err) + } + + if len(parents) == 0 { + // No parents available. Just check + // if this status belongs to requester. + return status.AccountID == requester.ID, nil + } + + // Check if OG status owned by requester. + return parents[0].AccountID == requester.ID, nil +} + +// StatusLikeable checks if the given status +// is likeable by the requester account. +// +// Callers to this function should have already +// checked the visibility of status to requester, +// including taking account of blocks, as this +// function does not do visibility checks, only +// interaction policy checks. +func (f *Filter) StatusLikeable( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) (*gtsmodel.PolicyCheckResult, error) { + if requester.ID == status.AccountID { + // Status author themself can + // always like their own status, + // no need for further checks. + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + PermittedMatchedOn: util.Ptr(gtsmodel.PolicyValueAuthor), + }, nil + } + + switch { + // If status has policy set, check against that. + case status.InteractionPolicy != nil: + return f.checkPolicy( + ctx, + requester, + status, + status.InteractionPolicy.CanLike, + ) + + // If status is local and has no policy set, + // check against the default policy for this + // visibility, as we're interaction-policy aware. + case *status.Local: + policy := gtsmodel.DefaultInteractionPolicyFor(status.Visibility) + return f.checkPolicy( + ctx, + requester, + status, + policy.CanLike, + ) + + // Otherwise, assume the status is from an + // instance that does not use / does not care + // about interaction policies, and just return OK. + default: + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + }, nil + } +} + +// StatusReplyable checks if the given status +// is replyable by the requester account. +// +// Callers to this function should have already +// checked the visibility of status to requester, +// including taking account of blocks, as this +// function does not do visibility checks, only +// interaction policy checks. +func (f *Filter) StatusReplyable( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) (*gtsmodel.PolicyCheckResult, error) { + if util.PtrOrValue(status.PendingApproval, false) { + // Target status is pending approval, + // check who started this thread. + startedThread, err := f.startedThread( + ctx, + requester, + status, + ) + if err != nil { + err := gtserror.Newf("error checking thread ownership: %w", err) + return nil, err + } + + if !startedThread { + // If status is itself still pending approval, + // and the requester didn't start this thread, + // then buddy, any status that tries to reply + // to it must be pending approval too. We do + // this to prevent someone replying to a status + // with a policy set that causes that reply to + // require approval, *THEN* replying to their + // own reply (which may not have a policy set) + // and having the reply-to-their-own-reply go + // through as Permitted. None of that! + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionWithApproval, + }, nil + } + } + + if requester.ID == status.AccountID { + // Status author themself can + // always reply to their own status, + // no need for further checks. + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + PermittedMatchedOn: util.Ptr(gtsmodel.PolicyValueAuthor), + }, nil + } + + // If requester is replied to by this status, + // then just return OK, it's functionally equivalent + // to them being mentioned, and easier to check! + if status.InReplyToAccountID == requester.ID { + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + PermittedMatchedOn: util.Ptr(gtsmodel.PolicyValueMentioned), + }, nil + } + + // Check if requester mentioned by this status. + // + // Prefer checking by ID, fall back to URI, URL, + // or NameString for not-yet enriched statuses. + mentioned := slices.ContainsFunc( + status.Mentions, + func(m *gtsmodel.Mention) bool { + switch { + + // Check by ID - most accurate. + case m.TargetAccountID != "": + return m.TargetAccountID == requester.ID + + // Check by URI - also accurate. + case m.TargetAccountURI != "": + return m.TargetAccountURI == requester.URI + + // Check by URL - probably accurate. + case m.TargetAccountURL != "": + return m.TargetAccountURL == requester.URL + + // Fall back to checking by namestring. + case m.NameString != "": + username, host, err := util.ExtractNamestringParts(m.NameString) + if err != nil { + log.Debugf(ctx, "error checking if mentioned: %v", err) + return false + } + + if requester.IsLocal() { + // Local requester has empty string + // domain so check using config. + return username == requester.Username && + (host == config.GetHost() || host == config.GetAccountDomain()) + } + + // Remote requester has domain set. + return username == requester.Username && + host == requester.Domain + + default: + // Not mentioned. + return false + } + }, + ) + + if mentioned { + // A mentioned account can always + // reply, no need for further checks. + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + PermittedMatchedOn: util.Ptr(gtsmodel.PolicyValueMentioned), + }, nil + } + + switch { + // If status has policy set, check against that. + case status.InteractionPolicy != nil: + return f.checkPolicy( + ctx, + requester, + status, + status.InteractionPolicy.CanReply, + ) + + // If status is local and has no policy set, + // check against the default policy for this + // visibility, as we're interaction-policy aware. + case *status.Local: + policy := gtsmodel.DefaultInteractionPolicyFor(status.Visibility) + return f.checkPolicy( + ctx, + requester, + status, + policy.CanReply, + ) + + // Otherwise, assume the status is from an + // instance that does not use / does not care + // about interaction policies, and just return OK. + default: + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + }, nil + } +} + +// StatusBoostable checks if the given status +// is boostable by the requester account. +// +// Callers to this function should have already +// checked the visibility of status to requester, +// including taking account of blocks, as this +// function does not do visibility checks, only +// interaction policy checks. +func (f *Filter) StatusBoostable( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) (*gtsmodel.PolicyCheckResult, error) { + if status.Visibility == gtsmodel.VisibilityDirect { + log.Trace(ctx, "direct statuses are not boostable") + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionForbidden, + }, nil + } + + if requester.ID == status.AccountID { + // Status author themself can + // always boost non-directs, + // no need for further checks. + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + PermittedMatchedOn: util.Ptr(gtsmodel.PolicyValueAuthor), + }, nil + } + + switch { + // If status has policy set, check against that. + case status.InteractionPolicy != nil: + return f.checkPolicy( + ctx, + requester, + status, + status.InteractionPolicy.CanAnnounce, + ) + + // If status is local and has no policy set, + // check against the default policy for this + // visibility, as we're interaction-policy aware. + case *status.Local: + policy := gtsmodel.DefaultInteractionPolicyFor(status.Visibility) + return f.checkPolicy( + ctx, + requester, + status, + policy.CanAnnounce, + ) + + // Otherwise, assume the status is from an + // instance that does not use / does not care + // about interaction policies, and just return OK. + default: + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + }, nil + } +} + +func (f *Filter) checkPolicy( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, + rules gtsmodel.PolicyRules, +) (*gtsmodel.PolicyCheckResult, error) { + + // Wrap context to be able to + // cache some database calls. + fctx := new(filterctx) + fctx.Context = ctx + + // Check if requester matches a PolicyValue + // to be always allowed to do this. + matchAlways, matchAlwaysValue, err := f.matchPolicy(fctx, + requester, + status, + rules.Always, + ) + if err != nil { + return nil, gtserror.Newf("error checking policy match: %w", err) + } + + // Check if requester matches a PolicyValue + // to be allowed to do this pending approval. + matchWithApproval, _, err := f.matchPolicy(fctx, + requester, + status, + rules.WithApproval, + ) + if err != nil { + return nil, gtserror.Newf("error checking policy approval match: %w", err) + } + + switch { + + // Prefer explicit match, + // prioritizing "always". + case matchAlways == explicit: + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + PermittedMatchedOn: &matchAlwaysValue, + }, nil + + case matchWithApproval == explicit: + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionWithApproval, + }, nil + + // Then try implicit match, + // prioritizing "always". + case matchAlways == implicit: + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionPermitted, + PermittedMatchedOn: &matchAlwaysValue, + }, nil + + case matchWithApproval == implicit: + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionWithApproval, + }, nil + } + + // No match. + return >smodel.PolicyCheckResult{ + Permission: gtsmodel.PolicyPermissionForbidden, + }, nil +} + +// matchPolicy returns whether requesting account +// matches any of the policy values for given status, +// returning the policy it matches on and match type. +// uses a *filterctx to cache certain db results. +func (f *Filter) matchPolicy( + ctx *filterctx, + requester *gtsmodel.Account, + status *gtsmodel.Status, + policyValues []gtsmodel.PolicyValue, +) ( + matchType, + gtsmodel.PolicyValue, + error, +) { + var ( + match = none + value gtsmodel.PolicyValue + ) + + for _, p := range policyValues { + switch p { + + // Check if anyone + // can do this. + case gtsmodel.PolicyValuePublic: + match = implicit + value = gtsmodel.PolicyValuePublic + + // Check if follower + // of status owner. + case gtsmodel.PolicyValueFollowers: + inFollowers, err := f.inFollowers(ctx, + requester, + status, + ) + if err != nil { + return 0, "", err + } + if inFollowers { + match = implicit + value = gtsmodel.PolicyValueFollowers + } + + // Check if followed + // by status owner. + case gtsmodel.PolicyValueFollowing: + inFollowing, err := f.inFollowing(ctx, + requester, + status, + ) + if err != nil { + return 0, "", err + } + if inFollowing { + match = implicit + value = gtsmodel.PolicyValueFollowing + } + + // Check if replied-to by or + // mentioned in the status. + case gtsmodel.PolicyValueMentioned: + if (status.InReplyToAccountID == requester.ID) || + status.MentionsAccount(requester.ID) { + // Return early as we've + // found an explicit match. + match = explicit + value = gtsmodel.PolicyValueMentioned + return match, value, nil + } + + // Check if PolicyValue specifies + // requester explicitly. + default: + if string(p) == requester.URI { + // Return early as we've + // found an explicit match. + match = explicit + value = gtsmodel.PolicyValue(requester.URI) + return match, value, nil + } + } + } + + // Return either "" or "implicit", + // and the policy value matched + // against (if set). + return match, value, nil +} + +// inFollowers returns whether requesting account is following +// status author, uses *filterctx type for db result caching. +func (f *Filter) inFollowers( + ctx *filterctx, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) ( + bool, + error, +) { + if ctx.inFollowersOnce == 0 { + var err error + + // Load the 'inFollowers' result from database. + ctx.inFollowers, err = f.state.DB.IsFollowing(ctx, + requester.ID, + status.AccountID, + ) + if err != nil { + return false, gtserror.Newf("error checking follow status: %w", err) + } + + // Mark value as stored. + ctx.inFollowersOnce = 1 + } + + // Return stored value. + return ctx.inFollowers, nil +} + +// inFollowing returns whether status author is following +// requesting account, uses *filterctx for db result caching. +func (f *Filter) inFollowing( + ctx *filterctx, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) ( + bool, + error, +) { + if ctx.inFollowingOnce == 0 { + var err error + + // Load the 'inFollowers' result from database. + ctx.inFollowing, err = f.state.DB.IsFollowing(ctx, + status.AccountID, + requester.ID, + ) + if err != nil { + return false, gtserror.Newf("error checking follow status: %w", err) + } + + // Mark value as stored. + ctx.inFollowingOnce = 1 + } + + // Return stored value. + return ctx.inFollowing, nil +} + +// filterctx wraps a context.Context to also +// store loadable data relevant to a fillter +// operation from the database, such that it +// only needs to be loaded once IF required. +type filterctx struct { + context.Context + + inFollowers bool + inFollowersOnce int32 + + inFollowing bool + inFollowingOnce int32 +} diff --git a/internal/filter/visibility/boostable.go b/internal/filter/visibility/boostable.go deleted file mode 100644 index 7362ad45c..000000000 --- a/internal/filter/visibility/boostable.go +++ /dev/null @@ -1,57 +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" - - "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 - } - - return true, nil -} diff --git a/internal/filter/visibility/boostable_test.go b/internal/filter/visibility/boostable_test.go deleted file mode 100644 index fd29e7305..000000000 --- a/internal/filter/visibility/boostable_test.go +++ /dev/null @@ -1,154 +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_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/suite" -) - -type StatusBoostableTestSuite struct { - FilterStandardTestSuite -} - -func (suite *StatusBoostableTestSuite) TestOwnPublicBoostable() { - testStatus := suite.testStatuses["local_account_1_status_1"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOwnUnlockedBoostable() { - testStatus := suite.testStatuses["local_account_1_status_2"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOwnMutualsOnlyNonInteractiveBoostable() { - testStatus := suite.testStatuses["local_account_1_status_3"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOwnMutualsOnlyBoostable() { - testStatus := suite.testStatuses["local_account_1_status_4"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOwnFollowersOnlyBoostable() { - testStatus := suite.testStatuses["local_account_1_status_5"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOwnDirectNotBoostable() { - testStatus := suite.testStatuses["local_account_2_status_6"] - testAccount := suite.testAccounts["local_account_2"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOtherPublicBoostable() { - testStatus := suite.testStatuses["local_account_2_status_1"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOtherUnlistedBoostable() { - testStatus := suite.testStatuses["local_account_1_status_2"] - testAccount := suite.testAccounts["local_account_2"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOtherFollowersOnlyNotBoostable() { - testStatus := suite.testStatuses["local_account_2_status_7"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(boostable) -} - -func (suite *StatusBoostableTestSuite) TestOtherDirectNotBoostable() { - testStatus := suite.testStatuses["local_account_2_status_6"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - boostable, err := suite.filter.StatusBoostable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(boostable) -} - -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, testAccount, testStatus) - suite.NoError(err) - - suite.False(boostable) -} - -func TestStatusBoostableTestSuite(t *testing.T) { - suite.Run(t, new(StatusBoostableTestSuite)) -} diff --git a/internal/filter/visibility/status.go b/internal/filter/visibility/status.go index 5e2052ae4..be1c6a350 100644 --- a/internal/filter/visibility/status.go +++ b/internal/filter/visibility/status.go @@ -25,6 +25,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // StatusesVisible calls StatusVisible for each status in the statuses slice, and returns a slice of only statuses which are visible to the requester. @@ -41,8 +42,15 @@ func (f *Filter) StatusesVisible(ctx context.Context, requester *gtsmodel.Accoun return filtered, errs.Combine() } -// 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) { +// StatusVisible will check if status is visible to requester, +// accounting for requester with no auth (i.e is nil), suspensions, +// disabled local users, pending approvals, account blocks, +// and status visibility settings. +func (f *Filter) StatusVisible( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) (bool, error) { const vtype = cache.VisibilityTypeStatus // By default we assume no auth. @@ -75,8 +83,14 @@ func (f *Filter) StatusVisible(ctx context.Context, requester *gtsmodel.Account, 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) { +// 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, gtserror.Newf("error populating status %s: %w", status.ID, err) @@ -90,6 +104,14 @@ func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Accoun return false, nil } + if util.PtrOrValue(status.PendingApproval, false) { + // Use a different visibility heuristic + // for pending approval statuses. + return f.isPendingStatusVisible(ctx, + requester, status, + ) + } + if status.Visibility == gtsmodel.VisibilityPublic { // This status will be visible to all. return true, nil @@ -176,6 +198,41 @@ func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Accoun } } +func (f *Filter) isPendingStatusVisible( + _ context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) (bool, error) { + if requester == nil { + // Any old tom, dick, and harry can't + // see pending-approval statuses, + // no matter what their visibility. + return false, nil + } + + if status.AccountID == requester.ID { + // This is requester's status, + // so they can always see it. + return true, nil + } + + if status.InReplyToAccountID == requester.ID { + // This status replies to requester, + // so they can always see it (else + // they can't approve it). + return true, nil + } + + if status.BoostOfAccountID == requester.ID { + // This status boosts requester, + // so they can always see it. + return true, nil + } + + // Nobody else can see this. + 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. diff --git a/internal/filter/visibility/status_test.go b/internal/filter/visibility/status_test.go index ad6bc66df..6f8bb12b4 100644 --- a/internal/filter/visibility/status_test.go +++ b/internal/filter/visibility/status_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" ) type StatusVisibleTestSuite struct { @@ -156,6 +157,49 @@ func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotFollowingCached() suite.False(visible) } +func (suite *StatusVisibleTestSuite) TestVisiblePending() { + ctx := context.Background() + + // Copy the test status and mark + // the copy as pending approval. + // + // This is a status from admin + // that replies to zork. + testStatus := new(gtsmodel.Status) + *testStatus = *suite.testStatuses["admin_account_status_3"] + testStatus.PendingApproval = util.Ptr(true) + + for _, testCase := range []struct { + acct *gtsmodel.Account + visible bool + }{ + { + acct: suite.testAccounts["admin_account"], + visible: true, // Own status, always visible. + }, + { + acct: suite.testAccounts["local_account_1"], + visible: true, // Reply to zork, always visible. + }, + { + acct: suite.testAccounts["local_account_2"], + visible: false, // None of their business. + }, + { + acct: suite.testAccounts["remote_account_1"], + visible: false, // None of their business. + }, + { + acct: nil, // Unauthed request. + visible: false, // None of their business. + }, + } { + visible, err := suite.filter.StatusVisible(ctx, testCase.acct, testStatus) + suite.NoError(err) + suite.Equal(testCase.visible, visible) + } +} + func TestStatusVisibleTestSuite(t *testing.T) { suite.Run(t, new(StatusVisibleTestSuite)) } |