summaryrefslogtreecommitdiff
path: root/internal/filter
diff options
context:
space:
mode:
Diffstat (limited to 'internal/filter')
-rw-r--r--internal/filter/interaction/filter.go34
-rw-r--r--internal/filter/interaction/interactable.go561
-rw-r--r--internal/filter/visibility/boostable.go57
-rw-r--r--internal/filter/visibility/boostable_test.go154
-rw-r--r--internal/filter/visibility/status.go65
-rw-r--r--internal/filter/visibility/status_test.go44
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 &gtsmodel.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 &gtsmodel.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 &gtsmodel.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 &gtsmodel.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 &gtsmodel.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 &gtsmodel.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 &gtsmodel.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 &gtsmodel.PolicyCheckResult{
+ Permission: gtsmodel.PolicyPermissionForbidden,
+ }, nil
+ }
+
+ if requester.ID == status.AccountID {
+ // Status author themself can
+ // always boost non-directs,
+ // no need for further checks.
+ return &gtsmodel.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 &gtsmodel.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 &gtsmodel.PolicyCheckResult{
+ Permission: gtsmodel.PolicyPermissionPermitted,
+ PermittedMatchedOn: &matchAlwaysValue,
+ }, nil
+
+ case matchWithApproval == explicit:
+ return &gtsmodel.PolicyCheckResult{
+ Permission: gtsmodel.PolicyPermissionWithApproval,
+ }, nil
+
+ // Then try implicit match,
+ // prioritizing "always".
+ case matchAlways == implicit:
+ return &gtsmodel.PolicyCheckResult{
+ Permission: gtsmodel.PolicyPermissionPermitted,
+ PermittedMatchedOn: &matchAlwaysValue,
+ }, nil
+
+ case matchWithApproval == implicit:
+ return &gtsmodel.PolicyCheckResult{
+ Permission: gtsmodel.PolicyPermissionWithApproval,
+ }, nil
+ }
+
+ // No match.
+ return &gtsmodel.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))
}