From 9cadc764b389df970c767608e7a061f3bd777dfa Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Tue, 27 Feb 2024 13:22:05 +0100 Subject: [feature] Add experimental `instance-federation-spam-filter` option (#2685) * [chore] Move `visibility` to `filter/visibility` * [feature] Add experimental instance-federation-spam-filter option --- internal/ap/extract.go | 17 +- internal/api/activitypub/emoji/emojiget_test.go | 2 +- internal/api/activitypub/users/user_test.go | 2 +- internal/api/client/accounts/account_test.go | 2 +- internal/api/client/admin/admin_test.go | 2 +- internal/api/client/bookmarks/bookmarks_test.go | 2 +- internal/api/client/favourites/favourites_test.go | 2 +- .../client/followrequests/followrequest_test.go | 2 +- internal/api/client/instance/instance_test.go | 2 +- internal/api/client/lists/lists_test.go | 2 +- internal/api/client/media/mediacreate_test.go | 2 +- internal/api/client/media/mediaupdate_test.go | 2 +- internal/api/client/polls/polls_test.go | 2 +- internal/api/client/reports/reports_test.go | 2 +- internal/api/client/search/search_test.go | 2 +- internal/api/client/statuses/status_test.go | 2 +- internal/api/client/streaming/streaming_test.go | 2 +- internal/api/client/user/user_test.go | 2 +- internal/api/fileserver/fileserver_test.go | 2 +- internal/api/wellknown/webfinger/webfinger_test.go | 2 +- internal/cleaner/media_test.go | 2 +- internal/config/config.go | 1 + internal/config/defaults.go | 1 + internal/config/flags.go | 1 + internal/config/helpers.gen.go | 25 + internal/db/bundb/bundb_test.go | 2 +- .../federation/dereferencing/dereferencer_test.go | 2 +- internal/federation/federatingdb/create.go | 94 +-- internal/federation/federatingdb/db.go | 34 +- .../federation/federatingdb/federatingdb_test.go | 2 +- internal/federation/federator_test.go | 2 +- internal/filter/spam/spam.go | 32 + internal/filter/spam/spam_test.go | 59 ++ internal/filter/spam/statusable.go | 472 +++++++++++++ internal/filter/spam/statusable_test.go | 780 +++++++++++++++++++++ internal/filter/visibility/account.go | 154 ++++ internal/filter/visibility/boostable.go | 62 ++ internal/filter/visibility/boostable_test.go | 154 ++++ internal/filter/visibility/filter.go | 37 + internal/filter/visibility/filter_test.go | 77 ++ internal/filter/visibility/home_timeline.go | 279 ++++++++ internal/filter/visibility/home_timeline_test.go | 415 +++++++++++ internal/filter/visibility/public_timeline.go | 123 ++++ internal/filter/visibility/status.go | 213 ++++++ internal/filter/visibility/status_test.go | 161 +++++ internal/filter/visibility/tag_timeline.go | 60 ++ internal/gtserror/error.go | 29 + internal/gtsmodel/tag.go | 1 + internal/media/media_test.go | 2 +- internal/processing/account/account.go | 2 +- internal/processing/account/account_test.go | 2 +- internal/processing/admin/admin_test.go | 2 +- internal/processing/common/common.go | 2 +- internal/processing/fedi/fedi.go | 2 +- internal/processing/polls/poll_test.go | 2 +- internal/processing/processor.go | 2 +- internal/processing/processor_test.go | 2 +- internal/processing/search/search.go | 2 +- internal/processing/status/status.go | 2 +- internal/processing/status/status_test.go | 2 +- internal/processing/timeline/home.go | 2 +- internal/processing/timeline/list.go | 2 +- internal/processing/timeline/timeline.go | 2 +- internal/processing/workers/surface.go | 2 +- internal/processing/workers/workers.go | 2 +- internal/processing/workers/workers_test.go | 2 +- internal/timeline/timeline_test.go | 2 +- internal/transport/transport_test.go | 2 +- internal/typeutils/converter_test.go | 2 +- internal/typeutils/internaltofrontend_test.go | 10 +- internal/visibility/account.go | 154 ---- internal/visibility/boostable.go | 62 -- internal/visibility/boostable_test.go | 154 ---- internal/visibility/filter.go | 37 - internal/visibility/filter_test.go | 77 -- internal/visibility/home_timeline.go | 279 -------- internal/visibility/home_timeline_test.go | 415 ----------- internal/visibility/public_timeline.go | 123 ---- internal/visibility/status.go | 213 ------ internal/visibility/status_test.go | 161 ----- internal/visibility/tag_timeline.go | 60 -- 81 files changed, 3254 insertions(+), 1862 deletions(-) create mode 100644 internal/filter/spam/spam.go create mode 100644 internal/filter/spam/spam_test.go create mode 100644 internal/filter/spam/statusable.go create mode 100644 internal/filter/spam/statusable_test.go create mode 100644 internal/filter/visibility/account.go create mode 100644 internal/filter/visibility/boostable.go create mode 100644 internal/filter/visibility/boostable_test.go create mode 100644 internal/filter/visibility/filter.go create mode 100644 internal/filter/visibility/filter_test.go create mode 100644 internal/filter/visibility/home_timeline.go create mode 100644 internal/filter/visibility/home_timeline_test.go create mode 100644 internal/filter/visibility/public_timeline.go create mode 100644 internal/filter/visibility/status.go create mode 100644 internal/filter/visibility/status_test.go create mode 100644 internal/filter/visibility/tag_timeline.go delete mode 100644 internal/visibility/account.go delete mode 100644 internal/visibility/boostable.go delete mode 100644 internal/visibility/boostable_test.go delete mode 100644 internal/visibility/filter.go delete mode 100644 internal/visibility/filter_test.go delete mode 100644 internal/visibility/home_timeline.go delete mode 100644 internal/visibility/home_timeline_test.go delete mode 100644 internal/visibility/public_timeline.go delete mode 100644 internal/visibility/status.go delete mode 100644 internal/visibility/status_test.go delete mode 100644 internal/visibility/tag_timeline.go (limited to 'internal') diff --git a/internal/ap/extract.go b/internal/ap/extract.go index 2b49c6477..d9288c162 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -33,6 +33,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/text" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // ExtractObjects will extract object vocab.Types from given implementing interface. @@ -776,11 +777,21 @@ func extractHashtag(i Hashtaggable) (*gtsmodel.Tag, error) { } tagName := strings.TrimPrefix(name, "#") - yeah := func() *bool { t := true; return &t } + // Extract href for the tag, if set. + // + // Fine if not, it's only used for spam + // checking anyway so not critical. + var href string + hrefProp := i.GetActivityStreamsHref() + if hrefProp != nil && hrefProp.IsIRI() { + href = hrefProp.GetIRI().String() + } + return >smodel.Tag{ Name: tagName, - Useable: yeah(), // Assume true by default. - Listable: yeah(), // Assume true by default. + Useable: util.Ptr(true), // Assume true by default. + Listable: util.Ptr(true), // Assume true by default. + Href: href, }, nil } diff --git a/internal/api/activitypub/emoji/emojiget_test.go b/internal/api/activitypub/emoji/emojiget_test.go index 57c412928..4d687a049 100644 --- a/internal/api/activitypub/emoji/emojiget_test.go +++ b/internal/api/activitypub/emoji/emojiget_test.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/middleware" @@ -36,7 +37,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/activitypub/users/user_test.go b/internal/api/activitypub/users/user_test.go index a52c351ac..4d55aad3d 100644 --- a/internal/api/activitypub/users/user_test.go +++ b/internal/api/activitypub/users/user_test.go @@ -24,6 +24,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/middleware" @@ -31,7 +32,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/accounts/account_test.go b/internal/api/client/accounts/account_test.go index e247ba21d..2f8664756 100644 --- a/internal/api/client/accounts/account_test.go +++ b/internal/api/client/accounts/account_test.go @@ -30,6 +30,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -37,7 +38,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/admin/admin_test.go b/internal/api/client/admin/admin_test.go index 13c6d13e9..4a429e57a 100644 --- a/internal/api/client/admin/admin_test.go +++ b/internal/api/client/admin/admin_test.go @@ -30,6 +30,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -37,7 +38,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/bookmarks/bookmarks_test.go b/internal/api/client/bookmarks/bookmarks_test.go index b689ca24c..cb796e9e8 100644 --- a/internal/api/client/bookmarks/bookmarks_test.go +++ b/internal/api/client/bookmarks/bookmarks_test.go @@ -35,6 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -42,7 +43,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/favourites/favourites_test.go b/internal/api/client/favourites/favourites_test.go index 2826b2c4e..bd0ebce2e 100644 --- a/internal/api/client/favourites/favourites_test.go +++ b/internal/api/client/favourites/favourites_test.go @@ -23,13 +23,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/followrequests/followrequest_test.go b/internal/api/client/followrequests/followrequest_test.go index 3ef8951b9..fc9843b4a 100644 --- a/internal/api/client/followrequests/followrequest_test.go +++ b/internal/api/client/followrequests/followrequest_test.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -36,7 +37,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/instance/instance_test.go b/internal/api/client/instance/instance_test.go index 42bd9eeb3..8bfe444e5 100644 --- a/internal/api/client/instance/instance_test.go +++ b/internal/api/client/instance/instance_test.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -36,7 +37,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/lists/lists_test.go b/internal/api/client/lists/lists_test.go index 8504f7ee2..844d54cbb 100644 --- a/internal/api/client/lists/lists_test.go +++ b/internal/api/client/lists/lists_test.go @@ -23,13 +23,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index a2cf9417a..41a1fc16f 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -37,6 +37,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -45,7 +46,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go index abc26247c..bb4e0f4ad 100644 --- a/internal/api/client/media/mediaupdate_test.go +++ b/internal/api/client/media/mediaupdate_test.go @@ -34,6 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -42,7 +43,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/polls/polls_test.go b/internal/api/client/polls/polls_test.go index acf972e2a..5a3c83580 100644 --- a/internal/api/client/polls/polls_test.go +++ b/internal/api/client/polls/polls_test.go @@ -23,13 +23,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/reports/reports_test.go b/internal/api/client/reports/reports_test.go index 3e21ef1ba..b36017d69 100644 --- a/internal/api/client/reports/reports_test.go +++ b/internal/api/client/reports/reports_test.go @@ -23,13 +23,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/search/search_test.go b/internal/api/client/search/search_test.go index 653858a1c..5ba198062 100644 --- a/internal/api/client/search/search_test.go +++ b/internal/api/client/search/search_test.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -36,7 +37,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go index 921afb3b5..6d0710ef0 100644 --- a/internal/api/client/statuses/status_test.go +++ b/internal/api/client/statuses/status_test.go @@ -23,13 +23,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/streaming/streaming_test.go b/internal/api/client/streaming/streaming_test.go index df4009890..1d94a87ec 100644 --- a/internal/api/client/streaming/streaming_test.go +++ b/internal/api/client/streaming/streaming_test.go @@ -35,6 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -42,7 +43,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go index 7cad3975d..efff89b13 100644 --- a/internal/api/client/user/user_test.go +++ b/internal/api/client/user/user_test.go @@ -23,13 +23,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/fileserver/fileserver_test.go b/internal/api/fileserver/fileserver_test.go index 120ebacb5..b58433b9f 100644 --- a/internal/api/fileserver/fileserver_test.go +++ b/internal/api/fileserver/fileserver_test.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -31,7 +32,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/api/wellknown/webfinger/webfinger_test.go b/internal/api/wellknown/webfinger/webfinger_test.go index 4c4aba205..67ac5a64e 100644 --- a/internal/api/wellknown/webfinger/webfinger_test.go +++ b/internal/api/wellknown/webfinger/webfinger_test.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -30,7 +31,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/cleaner/media_test.go b/internal/cleaner/media_test.go index a2fdb0858..c27890f55 100644 --- a/internal/cleaner/media_test.go +++ b/internal/cleaner/media_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/cleaner" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -35,7 +36,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/config/config.go b/internal/config/config.go index a4cd83227..292b6b7ee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -78,6 +78,7 @@ type Configuration struct { WebAssetBaseDir string `name:"web-asset-base-dir" usage:"Directory to serve static assets from, accessible at example.org/assets/"` InstanceFederationMode string `name:"instance-federation-mode" usage:"Set instance federation mode."` + InstanceFederationSpamFilter bool `name:"instance-federation-spam-filter" usage:"Enable basic spam filter heuristics for messages coming from other instances, and drop messages identified as spam"` InstanceExposePeers bool `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"` InstanceExposeSuspended bool `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"` InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index a8343400b..78474539f 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -59,6 +59,7 @@ var Defaults = Configuration{ WebAssetBaseDir: "./web/assets/", InstanceFederationMode: InstanceFederationModeDefault, + InstanceFederationSpamFilter: false, InstanceExposePeers: false, InstanceExposeSuspended: false, InstanceExposeSuspendedWeb: false, diff --git a/internal/config/flags.go b/internal/config/flags.go index ae8a3ba36..516ba0101 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -84,6 +84,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) { // Instance cmd.Flags().String(InstanceFederationModeFlag(), cfg.InstanceFederationMode, fieldtag("InstanceFederationMode", "usage")) + cmd.Flags().Bool(InstanceFederationSpamFilterFlag(), cfg.InstanceFederationSpamFilter, fieldtag("InstanceFederationSpamFilter", "usage")) cmd.Flags().Bool(InstanceExposePeersFlag(), cfg.InstanceExposePeers, fieldtag("InstanceExposePeers", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedFlag(), cfg.InstanceExposeSuspended, fieldtag("InstanceExposeSuspended", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedWebFlag(), cfg.InstanceExposeSuspendedWeb, fieldtag("InstanceExposeSuspendedWeb", "usage")) diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 0379a541b..9549d67c1 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -775,6 +775,31 @@ func GetInstanceFederationMode() string { return global.GetInstanceFederationMod // SetInstanceFederationMode safely sets the value for global configuration 'InstanceFederationMode' field func SetInstanceFederationMode(v string) { global.SetInstanceFederationMode(v) } +// GetInstanceFederationSpamFilter safely fetches the Configuration value for state's 'InstanceFederationSpamFilter' field +func (st *ConfigState) GetInstanceFederationSpamFilter() (v bool) { + st.mutex.RLock() + v = st.config.InstanceFederationSpamFilter + st.mutex.RUnlock() + return +} + +// SetInstanceFederationSpamFilter safely sets the Configuration value for state's 'InstanceFederationSpamFilter' field +func (st *ConfigState) SetInstanceFederationSpamFilter(v bool) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.InstanceFederationSpamFilter = v + st.reloadToViper() +} + +// InstanceFederationSpamFilterFlag returns the flag name for the 'InstanceFederationSpamFilter' field +func InstanceFederationSpamFilterFlag() string { return "instance-federation-spam-filter" } + +// GetInstanceFederationSpamFilter safely fetches the value for global configuration 'InstanceFederationSpamFilter' field +func GetInstanceFederationSpamFilter() bool { return global.GetInstanceFederationSpamFilter() } + +// SetInstanceFederationSpamFilter safely sets the value for global configuration 'InstanceFederationSpamFilter' field +func SetInstanceFederationSpamFilter(v bool) { global.SetInstanceFederationSpamFilter(v) } + // GetInstanceExposePeers safely fetches the Configuration value for state's 'InstanceExposePeers' field func (st *ConfigState) GetInstanceExposePeers() (v bool) { st.mutex.RLock() diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index 037727090..adfd605a5 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -20,10 +20,10 @@ package bundb_test import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/federation/dereferencing/dereferencer_test.go b/internal/federation/dereferencing/dereferencer_test.go index c726467de..7f79e8ac0 100644 --- a/internal/federation/dereferencing/dereferencer_test.go +++ b/internal/federation/dereferencing/dereferencer_test.go @@ -22,11 +22,11 @@ import ( "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go index e2540b739..cfb0f319b 100644 --- a/internal/federation/federatingdb/create.go +++ b/internal/federation/federatingdb/create.go @@ -21,13 +21,11 @@ import ( "context" "errors" "fmt" - "strings" "codeberg.org/gruf/go-logger/v2/level" "github.com/miekg/dns" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" @@ -35,7 +33,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/util" ) // Create adds a new entry to the database which must be able to be @@ -321,26 +318,45 @@ func (f *federatingDB) createStatusable( statusable ap.Statusable, forwarded bool, ) error { - - // Check whether we should accept this new status, - // we do this BEFORE even handling forwards to us. - accept, err := f.shouldAcceptStatusable(ctx, + // Check whether this status is both + // relevant, and doesn't look like spam. + err := f.spamFilter.StatusableOK(ctx, receiver, requester, statusable, ) - if err != nil { - return gtserror.Newf("error checking status acceptibility: %w", err) - } - if !accept { - // This is a status sent with no relation to receiver, i.e. - // - receiving account does not follow requesting account - // - received status does not mention receiving account + switch { + case err == nil: + // No problem! + + case gtserror.IsNotRelevant(err): + // This case is quite common if a remote (Mastodon) + // instance forwards a message to us which is a reply + // from someone else to a status we've also replied to. // - // We just pretend that all is fine (dog with cuppa, flames everywhere) - log.Trace(ctx, "status failed acceptability check") + // It does this to try to ensure thread completion, but + // we have our own thread fetching mechanism anyway. + log.Debugf(ctx, + "status %s is not relevant to receiver (%v); dropping it", + ap.GetJSONLDId(statusable), err, + ) return nil + + case gtserror.IsSpam(err): + // Log this at a higher level so admins can + // gauge how much spam is being sent to them. + // + // TODO: add Prometheus metrics for this. + log.Infof(ctx, + "status %s looked like spam (%v); dropping it", + ap.GetJSONLDId(statusable), err, + ) + return nil + + default: + // A real error has occurred. + return gtserror.Newf("error checking relevancy/spam: %w", err) } // If we do have a forward, we should ignore the content @@ -378,52 +394,6 @@ func (f *federatingDB) createStatusable( return nil } -func (f *federatingDB) shouldAcceptStatusable(ctx context.Context, receiver *gtsmodel.Account, requester *gtsmodel.Account, statusable ap.Statusable) (bool, error) { - host := config.GetHost() - accountDomain := config.GetAccountDomain() - - // Check whether status mentions the receiver, - // this is the quickest check so perform it first. - mentions, _ := ap.ExtractMentions(statusable) - for _, mention := range mentions { - - // Extract placeholder mention vars. - accURI := mention.TargetAccountURI - name := mention.NameString - - switch { - case accURI != "" && - accURI == receiver.URI || accURI == receiver.URL: - // Mention target is receiver, - // they are mentioned in status. - return true, nil - - case accURI == "" && name != "": - // Only a name was provided, extract the user@domain parts. - user, domain, err := util.ExtractNamestringParts(name) - if err != nil { - return false, gtserror.Newf("error extracting mention name parts: %w", err) - } - - // Check if the name points to our receiving local user. - isLocal := (domain == host || domain == accountDomain) - if isLocal && strings.EqualFold(user, receiver.Username) { - return true, nil - } - } - } - - // Check whether receiving account follows the requesting account. - follows, err := f.state.DB.IsFollowing(ctx, receiver.ID, requester.ID) - if err != nil { - return false, gtserror.Newf("error checking follow status: %w", err) - } - - // Status will only be acceptable - // if receiver follows requester. - return follows, nil -} - /* FOLLOW HANDLERS */ diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go index 75ef3a2a7..2174a8003 100644 --- a/internal/federation/federatingdb/db.go +++ b/internal/federation/federatingdb/db.go @@ -22,12 +22,14 @@ import ( "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/filter/spam" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) -// DB wraps the pub.Database interface with a couple of custom functions for GoToSocial. +// DB wraps the pub.Database interface with +// a couple of custom functions for GoToSocial. type DB interface { pub.Database Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error @@ -36,20 +38,28 @@ type DB interface { Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error } -// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface. -// It doesn't care what the underlying implementation of the DB interface is, as long as it works. +// FederatingDB uses the given state interface +// to implement the go-fed pub.Database interface. type federatingDB struct { - state *state.State - converter *typeutils.Converter - filter *visibility.Filter + state *state.State + converter *typeutils.Converter + visFilter *visibility.Filter + spamFilter *spam.Filter } -// New returns a DB interface using the given database and config -func New(state *state.State, converter *typeutils.Converter, filter *visibility.Filter) DB { +// New returns a DB that satisfies the pub.Database +// interface, using the given state and filters. +func New( + state *state.State, + converter *typeutils.Converter, + visFilter *visibility.Filter, + spamFilter *spam.Filter, +) DB { fdb := federatingDB{ - state: state, - converter: converter, - filter: filter, + state: state, + converter: converter, + visFilter: visFilter, + spamFilter: spamFilter, } return &fdb } diff --git a/internal/federation/federatingdb/federatingdb_test.go b/internal/federation/federatingdb/federatingdb_test.go index 54c724057..0f227164d 100644 --- a/internal/federation/federatingdb/federatingdb_test.go +++ b/internal/federation/federatingdb/federatingdb_test.go @@ -23,12 +23,12 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go index 172675bd2..e10c7576c 100644 --- a/internal/federation/federator_test.go +++ b/internal/federation/federator_test.go @@ -23,12 +23,12 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/filter/spam/spam.go b/internal/filter/spam/spam.go new file mode 100644 index 000000000..386c2719b --- /dev/null +++ b/internal/filter/spam/spam.go @@ -0,0 +1,32 @@ +// 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 . + +package spam + +import "github.com/superseriousbusiness/gotosocial/internal/state" + +// Filter packages logic for checking whether +// given statuses should be considered spam. +type Filter struct { + state *state.State +} + +// NewFilter returns a new spam Filter +// that will use the provided state. +func NewFilter(state *state.State) *Filter { + return &Filter{state: state} +} diff --git a/internal/filter/spam/spam_test.go b/internal/filter/spam/spam_test.go new file mode 100644 index 000000000..d9d867a07 --- /dev/null +++ b/internal/filter/spam/spam_test.go @@ -0,0 +1,59 @@ +// 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 . + +package spam_test + +import ( + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/spam" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FilterStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + state state.State + + // standard suite models + testAccounts map[string]*gtsmodel.Account + + filter *spam.Filter +} + +func (suite *FilterStandardTestSuite) SetupSuite() { + suite.testAccounts = testrig.NewTestAccounts() +} + +func (suite *FilterStandardTestSuite) SetupTest() { + suite.state.Caches.Init() + + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.filter = spam.NewFilter(&suite.state) + + testrig.StandardDBSetup(suite.db, nil) +} + +func (suite *FilterStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} diff --git a/internal/filter/spam/statusable.go b/internal/filter/spam/statusable.go new file mode 100644 index 000000000..60598f920 --- /dev/null +++ b/internal/filter/spam/statusable.go @@ -0,0 +1,472 @@ +// 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 . + +package spam + +import ( + "context" + "errors" + "net/url" + "slices" + "strings" + + "github.com/miekg/dns" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/regexes" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// preppedMention represents a partially-parsed +// mention, prepared for spam checking purposes. +type preppedMention struct { + *gtsmodel.Mention + uri *url.URL + domain string + user string + local bool +} + +// StatusableOK returns no error if the given statusable looks OK, +// ie., relevant to the receiver, and not spam. +// +// This should only be used for Creates of statusables, NOT Announces! +// +// If the statusable does not pass relevancy or spam checks, either +// a Spam or NotRelevant error will be returned. Callers should use +// gtserror.IsSpam() and gtserror.IsNotRelevant() to check for this. +// +// If the returned error is not nil, but neither Spam or NotRelevant, +// then it's an actual database error. +// +// The decision is made based on the following heuristics, in order: +// +// 1. Receiver follow requester. Return nil. +// 2. Statusable doesn't mention receiver. Return NotRelevant. +// +// If instance-federation-spam-filter = false, then return nil now. +// Otherwise check: +// +// 3. Receiver is locked and is followed by requester. Return nil. +// 4. Five or more people are mentioned. Return Spam. +// 5. Receiver follow (requests) a mentioned account. Return nil. +// 6. Statusable has a media attachment. Return Spam. +// 7. Statusable contains non-mention, non-hashtag links. Return Spam. +func (f *Filter) StatusableOK( + ctx context.Context, + receiver *gtsmodel.Account, + requester *gtsmodel.Account, + statusable ap.Statusable, +) error { + // HEURISTIC 1: Check whether receiving account follows the requesting account. + // If so, we know it's OK and don't need to do any other checks. + follows, err := f.state.DB.IsFollowing(ctx, receiver.ID, requester.ID) + if err != nil { + return gtserror.Newf("db error checking follow status: %w", err) + } + + if follows { + // Looks fine. + return nil + } + + // HEURISTIC 2: Check whether statusable mentions the + // receiver. If not, we don't want to process this message. + rawMentions, _ := ap.ExtractMentions(statusable) + mentions := prepMentions(ctx, rawMentions) + mentioned := f.isMentioned(ctx, receiver, mentions) + if !mentioned { + // This is a random message fired + // into our inbox, just drop it. + err := errors.New("receiver does not follow requester, and is not mentioned") + return gtserror.SetNotRelevant(err) + } + + // Receiver is mentioned, but not by someone + // they follow. Check if we need to do more + // granular spam filtering. + if !config.GetInstanceFederationSpamFilter() { + // Filter is not enabled, allow it + // through without further checks. + return nil + } + + // More granular spam filtering time! + // + // HEURISTIC 3: Does requester follow locked receiver? + followedBy, err := f.lockedFollowedBy(ctx, receiver, requester) + if err != nil { + return gtserror.Newf("db error checking follow status: %w", err) + } + + // If receiver is locked, and is followed + // by requester, this likely means they're + // interested in the message. Allow it. + if followedBy { + return nil + } + + // HEURISTIC 4: How many people are mentioned? + // If it's 5 or more we can assume this is spam. + mentionsLen := len(mentions) + if mentionsLen >= 5 { + err := errors.New("status mentions 5 or more people") + return gtserror.SetSpam(err) + } + + // HEURISTIC 5: Four or fewer people are mentioned, + // do we follow (request) at least one of them? + // If so, we're probably interested in the message. + knowsOne := f.knowsOneMentioned(ctx, receiver, mentions) + if knowsOne { + return nil + } + + // HEURISTIC 6: Are there any media attachments? + attachments, _ := ap.ExtractAttachments(statusable) + hasAttachments := len(attachments) != 0 + if hasAttachments { + err := errors.New("status has attachment(s)") + return gtserror.SetSpam(err) + } + + // HEURISTIC 7: Are there any links in the post + // aside from mentions and hashtags? Include the + // summary/content warning when checking. + hashtags, _ := ap.ExtractHashtags(statusable) + hasErrantLinks := f.errantLinks(ctx, statusable, mentions, hashtags) + if hasErrantLinks { + err := errors.New("status has one or more non-mention, non-hashtag links") + return gtserror.SetSpam(err) + } + + // Looks OK. + return nil +} + +// prepMentions prepares a slice of mentions +// for spam checking by parsing out the namestring +// and targetAccountURI values, if present. +func prepMentions( + ctx context.Context, + mentions []*gtsmodel.Mention, +) []preppedMention { + var ( + host = config.GetHost() + accountDomain = config.GetAccountDomain() + ) + + parsedMentions := make([]preppedMention, 0, len(mentions)) + for _, mention := range mentions { + // Start by just embedding + // the original mention. + parsedMention := preppedMention{ + Mention: mention, + } + + // Try to parse namestring if present. + if mention.NameString != "" { + user, domain, err := util.ExtractNamestringParts(mention.NameString) + if err != nil { + // Malformed mention, + // just log + ignore. + log.Debugf(ctx, + "malformed mention namestring: %v", + err, + ) + continue + } + + parsedMention.domain = domain + parsedMention.user = user + } + + // Try to parse URI if present. + if mention.TargetAccountURI != "" { + targetURI, err := url.Parse(mention.TargetAccountURI) + if err != nil { + // Malformed mention, + // just log + ignore. + log.Debugf(ctx, + "malformed mention uri: %v", + err, + ) + continue + } + + parsedMention.uri = targetURI + + // Set host from targetURI if + // it wasn't set by namestring. + if parsedMention.domain == "" { + parsedMention.domain = targetURI.Host + } + } + + // It's a mention of a local account if the target host is us. + parsedMention.local = parsedMention.domain == host || parsedMention.domain == accountDomain + + // Done with this one. + parsedMentions = append(parsedMentions, parsedMention) + } + + return parsedMentions +} + +// isMentioned returns true if the +// receiver is targeted by at least +// one of the given mentions. +func (f *Filter) isMentioned( + ctx context.Context, + receiver *gtsmodel.Account, + mentions []preppedMention, +) bool { + return slices.ContainsFunc( + mentions, + func(mention preppedMention) bool { + // Check if receiver mentioned by URI. + if accURI := mention.TargetAccountURI; accURI != "" && + (accURI == receiver.URI || accURI == receiver.URL) { + return true + } + + // Check if receiver mentioned by namestring. + if mention.local && strings.EqualFold(mention.user, receiver.Username) { + return true + } + + // Mention doesn't + // target receiver. + return false + }, + ) +} + +// lockedFollowedBy returns true +// if receiver account is locked, +// and requester follows receiver. +func (f *Filter) lockedFollowedBy( + ctx context.Context, + receiver *gtsmodel.Account, + requester *gtsmodel.Account, +) (bool, error) { + // If receiver is not locked, + // return early to avoid a db call. + if !*receiver.Locked { + return false, nil + } + + return f.state.DB.IsFollowing(ctx, requester.ID, receiver.ID) +} + +// knowsOneMentioned returns true if the +// receiver follows or has follow requested +// at least one of the mentioned accounts. +func (f *Filter) knowsOneMentioned( + ctx context.Context, + receiver *gtsmodel.Account, + mentions []preppedMention, +) bool { + return slices.ContainsFunc( + mentions, + func(mention preppedMention) bool { + var ( + acc *gtsmodel.Account + err error + ) + + // Try to get target account without + // dereffing. After all, if they're not + // in our db we definitely don't know them. + if mention.TargetAccountURI != "" { + acc, err = f.state.DB.GetAccountByURI( + gtscontext.SetBarebones(ctx), + mention.TargetAccountURI, + ) + } else if mention.user != "" { + acc, err = f.state.DB.GetAccountByUsernameDomain( + gtscontext.SetBarebones(ctx), + mention.user, + mention.domain, + ) + } + + if err != nil && !errors.Is(err, db.ErrNoEntries) { + // Proper error. + log.Errorf(ctx, "db error getting mentioned account: %v", err) + return false + } + + if acc == nil { + // We don't know this nerd! + return false + } + + if acc.ID == receiver.ID { + // This is us, doesn't count. + return false + } + + follows, err := f.state.DB.IsFollowing(ctx, receiver.ID, acc.ID) + if err != nil { + // Proper error. + log.Errorf(ctx, "db error checking follow status: %v", err) + return false + } + + if follows { + // We follow this nerd. + return true + } + + // We don't follow this nerd, but + // have we requested to follow them? + followRequested, err := f.state.DB.IsFollowRequested(ctx, receiver.ID, acc.ID) + if err != nil { + // Proper error. + log.Errorf(ctx, "db error checking follow req status: %v", err) + return false + } + + return followRequested + }, + ) +} + +// errantLinks returns true if any http/https +// link discovered in the statusable content + cw +// is not either a mention link, or a hashtag link. +func (f *Filter) errantLinks( + ctx context.Context, + statusable ap.Statusable, + mentions []preppedMention, + hashtags []*gtsmodel.Tag, +) bool { + // Concatenate the cw with the + // content to check for links in both. + cw := ap.ExtractSummary(statusable) + content := ap.ExtractContent(statusable) + concat := cw + " " + content.Content + + // Store link string alongside link + // URI to avoid stringifying twice. + type preppedLink struct { + *url.URL + str string + } + + // Find + parse every http/https link in the status. + rawLinks := regexes.LinkScheme.FindAllString(concat, -1) + links := make([]preppedLink, 0, len(rawLinks)) + for _, rawLink := range rawLinks { + linkURI, err := url.Parse(rawLink) + if err != nil { + log.Debugf(ctx, + "malformed link in status: %v", + err, + ) + // Ignore bad links + // for spam checking. + continue + } + + links = append(links, preppedLink{ + URL: linkURI, + str: rawLink, + }) + } + + // For each link in the status, try to + // match it to a hashtag or a mention. + // If we can't, we have an errant link. + for _, link := range links { + hashtagLink := slices.ContainsFunc( + hashtags, + func(hashtag *gtsmodel.Tag) bool { + // If a link is to the href + // of a hashtag, it's fine. + return strings.EqualFold( + link.str, + hashtag.Href, + ) + }, + ) + + if hashtagLink { + // This link is accounted for. + // Move to the next one. + continue + } + + mentionLink := slices.ContainsFunc( + mentions, + func(mention preppedMention) bool { + // If link is straight up to the URI + // of a mentioned account, it's fine. + if strings.EqualFold( + link.str, + mention.TargetAccountURI, + ) { + return true + } + + // Link might be to an account URL rather + // than URI. This is a bit trickier because + // we can't predict the format of such URLs, + // and it's difficult to reconstruct them + // while also taking account of different + // host + account-domain values. + // + // So, just check if this link is on the same + // host as the mentioned account, or at least + // shares a host with it. + if link.Host == mention.domain { + // Same host. + return true + } + + // Shares a host if it has at least two + // components from the right in common. + common := dns.CompareDomainName( + link.Host, + mention.domain, + ) + return common >= 2 + }, + ) + + if mentionLink { + // This link is accounted for. + // Move to the next one. + continue + } + + // Not a hashtag link + // or a mention link, + // so it's errant. + return true + } + + // All links OK, or + // no links found. + return false +} diff --git a/internal/filter/spam/statusable_test.go b/internal/filter/spam/statusable_test.go new file mode 100644 index 000000000..db88454ee --- /dev/null +++ b/internal/filter/spam/statusable_test.go @@ -0,0 +1,780 @@ +// 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 . + +package spam_test + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +type StatusableTestSuite struct { + FilterStandardTestSuite +} + +const ( + // Message that mentions 5 people (including receiver), + // and contains a errant link. + spam1 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.org/users/9gol6f8zff", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "https://another.misskey.instance.com/users/9eklgce5yk", + "http://localhost:8080/users/the_mighty_zork" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

https://spammylink.org/

@Nao_ya_ia22
@nityosan
@FIzxive
@mendako
@the_mighty_zork

", + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.org/users/9gol6f8zff", + "name": "@Nao_ya_ia22@example.org" + }, + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "https://another.misskey.instance.com/users/9eklgce5yk", + "name": "@mendako@another.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/the_mighty_zork", + "name": "@the_mighty_zork@localhost:8080" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } +}` + + // Message that mentions 4 people (including receiver), + // and contains a errant link. + spam2 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "https://another.misskey.instance.com/users/9eklgce5yk", + "http://localhost:8080/users/the_mighty_zork" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

https://spammylink.org/

@nityosan
@FIzxive
@mendako
@the_mighty_zork

", + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "https://another.misskey.instance.com/users/9eklgce5yk", + "name": "@mendako@another.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/the_mighty_zork", + "name": "@the_mighty_zork@localhost:8080" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } +}` + + // Message that mentions 4 people (including receiver), + // but contains no errant links. + spam3 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "https://another.misskey.instance.com/users/9eklgce5yk", + "http://localhost:8080/users/the_mighty_zork" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

@nityosan
@FIzxive
@mendako
@the_mighty_zork

", + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "https://another.misskey.instance.com/users/9eklgce5yk", + "name": "@mendako@another.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/the_mighty_zork", + "name": "@the_mighty_zork@localhost:8080" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } +}` + + // Message that mentions 4 people (including receiver), + // contains no errant links, but 1 attachment. + spam4 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "https://another.misskey.instance.com/users/9eklgce5yk", + "http://localhost:8080/users/the_mighty_zork" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

@nityosan
@FIzxive
@mendako
@the_mighty_zork

", + "attachment": [ + { + "blurhash": "LNJRdVM{00Rj%Mayt7j[4nWBofRj", + "mediaType": "image/jpeg", + "name": "", + "type": "Document", + "url": "http://fossbros-anonymous.io/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg" + } + ], + "tag": [ + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "https://another.misskey.instance.com/users/9eklgce5yk", + "name": "@mendako@another.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/the_mighty_zork", + "name": "@the_mighty_zork@localhost:8080" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } +}` + + // Message that mentions 4 people (including receiver), + // and contains a errant link, and receiver follows + // another mentioned account. + spam5 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "http://localhost:8080/users/admin", + "http://localhost:8080/users/the_mighty_zork" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

https://spammylink.org/

@nityosan
@FIzxive
@admin
@the_mighty_zork

", + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/admin", + "name": "@admin@localhost:8080" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/the_mighty_zork", + "name": "@the_mighty_zork@localhost:8080" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } +}` + + // Message that mentions 3 people, contains a + // errant link, and receiver follows another + // mentioned account. However, receiver is not mentioned. + spam6 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "http://localhost:8080/users/admin" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

https://spammylink.org/

@nityosan
@FIzxive
@admin

", + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/admin", + "name": "@admin@localhost:8080" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } +}` + + // Message that mentions 4 people (including receiver), + // and hash a hashtag, but contains no errant links. + spam7 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "https://another.misskey.instance.com/users/9eklgce5yk", + "http://localhost:8080/users/the_mighty_zork" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

#gotosocial smells

@nityosan
@FIzxive
@mendako
@the_mighty_zork

", + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "https://another.misskey.instance.com/users/9eklgce5yk", + "name": "@mendako@another.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/the_mighty_zork", + "name": "@the_mighty_zork@localhost:8080" + }, + { + "type": "Hashtag", + "href": "https://fossbros-anonymous.io/tags/gotosocial", + "name": "#gotosocial" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } + }` + + // Same as spam7, except message doesn't + // have a hashtag in the tags array. + spam8 = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2024-02-24T07:06:14Z", + "url": "http://fossbros-anonymous.io/@foss_satan/111985188827079562", + "attributedTo": "http://fossbros-anonymous.io/users/foss_satan", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://fossbros-anonymous.io/users/foss_satan/followers", + "https://example.net/users/nityosan", + "https://a.misskey.instance.com/users/9c06ylkgsx", + "https://another.misskey.instance.com/users/9eklgce5yk", + "http://localhost:8080/users/the_mighty_zork" + ], + "sensitive": false, + "atomUri": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562", + "inReplyToAtomUri": null, + "content": "

#gotosocial smells

@nityosan
@FIzxive
@mendako
@the_mighty_zork

", + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.net/users/nityosan", + "name": "@nityosan@example.net" + }, + { + "type": "Mention", + "href": "https://a.misskey.instance.com/users/9c06ylkgsx", + "name": "@FIzxive@a.misskey.instance.com" + }, + { + "type": "Mention", + "href": "https://another.misskey.instance.com/users/9eklgce5yk", + "name": "@mendako@another.misskey.instance.com" + }, + { + "type": "Mention", + "href": "http://localhost:8080/users/the_mighty_zork", + "name": "@the_mighty_zork@localhost:8080" + } + ], + "replies": { + "id": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies?only_other_accounts=true&page=true", + "partOf": "http://fossbros-anonymous.io/users/foss_satan/statuses/111985188827079562/replies", + "items": [] + } + } + }` +) + +func (suite *StatusableTestSuite) TestStatusableOK() { + var ( + ctx = context.Background() + receiver = suite.testAccounts["local_account_1"] + requester = suite.testAccounts["remote_account_1"] + ) + + type testStruct struct { + message string + check func(error) + } + + for _, test := range []testStruct{ + { + // SPAM: status mentions 5 or more people + message: spam1, + check: func(err error) { + suite.True(gtserror.IsSpam(err), "expected Spam, got %+v", err) + }, + }, + { + // SPAM: receiver doesn't know a mentioned account, and status has attachments or errant links + message: spam2, + check: func(err error) { + suite.True(gtserror.IsSpam(err), "expected Spam, got %+v", err) + }, + }, + { + // NOT SPAM: receiver doesn't know a mentioned account, but status has no attachments or errant links + message: spam3, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + // SPAM: receiver doesn't know a mentioned account, and status has attachments or errant links + message: spam4, + check: func(err error) { + suite.True(gtserror.IsSpam(err), "expected Spam, got %+v", err) + }, + }, + { + // NOT SPAM: receiver knows a mentioned account + message: spam5, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + // SPAM: receiver does not follow requester, and is not mentioned + message: spam6, + check: func(err error) { + suite.True(gtserror.IsNotRelevant(err), "expected NotRelevant, got %+v", err) + }, + }, + { + // NOT SPAM: receiver doesn't know a mentioned account, but status has no attachments or errant links + message: spam7, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + // SPAM: receiver doesn't know a mentioned account, and status has attachments or errant links + message: spam8, + check: func(err error) { + suite.True(gtserror.IsSpam(err), "expected Spam, got %+v", err) + }, + }, + } { + rc := io.NopCloser(bytes.NewReader([]byte(test.message))) + + statusable, err := ap.ResolveStatusable(ctx, rc) + if err != nil { + suite.FailNow(err.Error()) + } + + err = suite.filter.StatusableOK(ctx, receiver, requester, statusable) + test.check(err) + } + + // Put a follow in place from receiver to requester. + fID := id.NewULID() + if err := suite.state.DB.PutFollow(ctx, >smodel.Follow{ + ID: fID, + URI: "http://localhost:8080/users/the_mighty_zork/follows/" + fID, + AccountID: receiver.ID, + TargetAccountID: requester.ID, + }); err != nil { + suite.FailNow(err.Error()) + } + + // Run all the tests again. They should all + // be OK since receiver now follows requester. + for _, test := range []testStruct{ + { + message: spam1, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + message: spam2, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + message: spam3, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + message: spam4, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + message: spam5, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + message: spam6, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + message: spam7, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + { + message: spam8, + check: func(err error) { + suite.NoError(err, "expected not spam, got %+v", err) + }, + }, + } { + rc := io.NopCloser(bytes.NewReader([]byte(test.message))) + + statusable, err := ap.ResolveStatusable(ctx, rc) + if err != nil { + suite.FailNow(err.Error()) + } + + err = suite.filter.StatusableOK(ctx, receiver, requester, statusable) + test.check(err) + } +} + +func TestStatusableTestSuite(t *testing.T) { + suite.Run(t, &StatusableTestSuite{}) +} diff --git a/internal/filter/visibility/account.go b/internal/filter/visibility/account.go new file mode 100644 index 000000000..410daa1ce --- /dev/null +++ b/internal/filter/visibility/account.go @@ -0,0 +1,154 @@ +// 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 . + +package visibility + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "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) { + const vtype = cache.VisibilityTypeAccount + + // By default we assume no auth. + requesterID := noauth + + if requester != nil { + // Use provided account ID. + requesterID = requester.ID + } + + visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, + Value: visible, + }, nil + }, vtype, 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, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("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 { + err := gtserror.Newf("db error getting user for account %s: %w", account.ID, err) + return false, err + } + + // Make sure that user is active (i.e. not disabled, not approved etc). + if *user.Disabled || !*user.Approved || user.ConfirmedAt.IsZero() { + log.Trace(ctx, "local account not active") + return false, nil + } + } else { + // This is a remote account. + + // Check whether remote account's domain is blocked. + blocked, err := f.state.DB.IsDomainBlocked(ctx, account.Domain) + if err != nil { + return false, err + } + + if blocked { + log.Trace(ctx, "remote account domain blocked") + return false, nil + } + } + + if !account.SuspendedAt.IsZero() { + log.Trace(ctx, "account suspended") + return false, nil + } + + return true, nil +} diff --git a/internal/filter/visibility/boostable.go b/internal/filter/visibility/boostable.go new file mode 100644 index 000000000..7c8bda324 --- /dev/null +++ b/internal/filter/visibility/boostable.go @@ -0,0 +1,62 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package visibility + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// StatusBoostable checks if given status is boostable by requester, checking boolean status visibility to requester and ultimately the AP status visibility setting. +func (f *Filter) StatusBoostable(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { + if status.Visibility == gtsmodel.VisibilityDirect { + log.Trace(ctx, "direct statuses are not boostable") + return false, nil + } + + // Check whether status is visible to requesting account. + visible, err := f.StatusVisible(ctx, requester, status) + if err != nil { + return false, err + } + + if !visible { + log.Trace(ctx, "status not visible to requesting account") + return false, nil + } + + if requester.ID == status.AccountID { + // Status author can always boost non-directs. + return true, nil + } + + if status.Visibility == gtsmodel.VisibilityFollowersOnly || + status.Visibility == gtsmodel.VisibilityMutualsOnly { + log.Trace(ctx, "unauthored %s status not boostable", status.Visibility) + return false, nil + } + + if !*status.Boostable { + log.Trace(ctx, "status marked not boostable") + return false, nil + } + + return true, nil +} diff --git a/internal/filter/visibility/boostable_test.go b/internal/filter/visibility/boostable_test.go new file mode 100644 index 000000000..fd29e7305 --- /dev/null +++ b/internal/filter/visibility/boostable_test.go @@ -0,0 +1,154 @@ +// 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 . + +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/filter.go b/internal/filter/visibility/filter.go new file mode 100644 index 000000000..c9f007ccf --- /dev/null +++ b/internal/filter/visibility/filter.go @@ -0,0 +1,37 @@ +// 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 . + +package visibility + +import ( + "github.com/superseriousbusiness/gotosocial/internal/state" +) + +// noauth is a placeholder ID used in cache lookups +// when there is no authorized account ID to use. +const noauth = "noauth" + +// 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(state *state.State) *Filter { + return &Filter{state: state} +} diff --git a/internal/filter/visibility/filter_test.go b/internal/filter/visibility/filter_test.go new file mode 100644 index 000000000..2d83ea759 --- /dev/null +++ b/internal/filter/visibility/filter_test.go @@ -0,0 +1,77 @@ +// 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 . + +package visibility_test + +import ( + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FilterStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + state state.State + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testTags map[string]*gtsmodel.Tag + testMentions map[string]*gtsmodel.Mention + testFollows map[string]*gtsmodel.Follow + + filter *visibility.Filter +} + +func (suite *FilterStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() + suite.testTags = testrig.NewTestTags() + suite.testMentions = testrig.NewTestMentions() + suite.testFollows = testrig.NewTestFollows() +} + +func (suite *FilterStandardTestSuite) SetupTest() { + suite.state.Caches.Init() + + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.filter = visibility.NewFilter(&suite.state) + + testrig.StandardDBSetup(suite.db, nil) +} + +func (suite *FilterStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} diff --git a/internal/filter/visibility/home_timeline.go b/internal/filter/visibility/home_timeline.go new file mode 100644 index 000000000..0a3fbde4e --- /dev/null +++ b/internal/filter/visibility/home_timeline.go @@ -0,0 +1,279 @@ +// 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 . + +package visibility + +import ( + "context" + "errors" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// 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) { + const vtype = cache.VisibilityTypeHome + + // By default we assume no auth. + requesterID := noauth + + if owner != nil { + // Use provided account ID. + requesterID = owner.ID + } + + visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, + Value: visible, + }, nil + }, vtype, 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 ( + // iterated-over + // loop status. + next = status + + // assume one author + // until proven otherwise. + oneAuthor = true + ) + + for { + // Populate account mention objects before account mention checks. + next.Mentions, err = f.state.DB.GetMentions(ctx, next.MentionIDs) + if err != nil { + return false, gtserror.Newf("error populating status %s mentions: %w", next.ID, err) + } + + if (next.AccountID == owner.ID) || + next.MentionsAccount(owner.ID) { + // Owner is in / mentioned in + // this status thread. They can + // see future visible statuses. + visible = true + break + } + + var notVisible bool + + // Check whether status in conversation is explicitly relevant to timeline + // owner (i.e. includes mutals), or is explicitly invisible (i.e. blocked). + visible, notVisible, err = f.isVisibleConversation(ctx, owner, next) + if err != nil { + return false, gtserror.Newf("error checking conversation visibility: %w", err) + } + + if notVisible { + log.Tracef(ctx, "conversation not visible to timeline owner") + return false, nil + } + + if visible { + // Conversation relevant + // to timeline owner! + break + } + + if oneAuthor { + // Check if this continues to be a single-author thread. + oneAuthor = (next.AccountID == status.AccountID) + } + + if next.InReplyToURI == "" { + // Reached the top of the thread. + break + } + + // Check parent is deref'd. + if next.InReplyToID == "" { + log.Debugf(ctx, "status not (yet) deref'd: %s", next.InReplyToURI) + return false, cache.SentinelError + } + + // Fetch next parent in conversation. + next, err = f.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + next.InReplyToID, + ) + if err != nil { + return false, gtserror.Newf("error getting status parent %s: %w", next.InReplyToID, err) + } + } + + if next != status && !oneAuthor && !visible { + log.Trace(ctx, "ignoring visible reply in conversation irrelevant to 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 🧵"), + // a status thread *including* the owner, or a conversation thread between + // accounts the timeline owner follows. + + // Ensure owner follows author. + follow, err := f.state.DB.GetFollow(ctx, + owner.ID, + status.AccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return false, gtserror.Newf("error retrieving follow %s->%s: %w", owner.ID, status.AccountID, err) + } + + if follow == nil { + log.Trace(ctx, "ignoring status from unfollowed author") + return false, nil + } + + if status.BoostOfID != "" && !*follow.ShowReblogs { + // Status is a boost, but the owner of this follow + // doesn't want to see boosts from this account. + return false, nil + } + + return true, nil +} + +func (f *Filter) isVisibleConversation( + ctx context.Context, + owner *gtsmodel.Account, + status *gtsmodel.Status, +) ( + bool, // explicitly IS visible + bool, // explicitly NOT visible + error, // err +) { + // Check if status is visible to the timeline owner. + visible, err := f.StatusVisible(ctx, owner, status) + if err != nil { + return false, false, err + } + + if !visible { + // Explicitly NOT visible + // to the timeline owner. + return false, true, nil + } + + if status.Visibility == gtsmodel.VisibilityUnlocked || + status.Visibility == gtsmodel.VisibilityPublic { + // NOTE: there is no need to check in the case of + // direct / follow-only / mutual-only visibility statuses + // as the above visibility check already handles this. + + // Check owner follows the status author. + follow, err := f.state.DB.IsFollowing(ctx, + owner.ID, + status.AccountID, + ) + if err != nil { + return false, false, gtserror.Newf("error checking follow %s->%s: %w", owner.ID, status.AccountID, err) + } + + if !follow { + // Not explicitly visible + // status to timeline owner. + return false, false, nil + } + } + + var follow bool + + for _, mention := range status.Mentions { + // Check block between timeline owner and mention. + block, err := f.state.DB.IsEitherBlocked(ctx, + owner.ID, + mention.TargetAccountID, + ) + if err != nil { + return false, false, gtserror.Newf("error checking mention block %s<->%s: %w", owner.ID, mention.TargetAccountID, err) + } + + if block { + // Invisible conversation. + return false, true, nil + } + + if !follow { + // See if tl owner follows any of mentions. + follow, err = f.state.DB.IsFollowing(ctx, + owner.ID, + mention.TargetAccountID, + ) + if err != nil { + return false, false, gtserror.Newf("error checking mention follow %s->%s: %w", owner.ID, mention.TargetAccountID, err) + } + } + } + + return follow, false, nil +} diff --git a/internal/filter/visibility/home_timeline_test.go b/internal/filter/visibility/home_timeline_test.go new file mode 100644 index 000000000..d8211c8dd --- /dev/null +++ b/internal/filter/visibility/home_timeline_test.go @@ -0,0 +1,415 @@ +// 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 . + +package visibility_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusStatusHomeTimelineableTestSuite struct { + FilterStandardTestSuite +} + +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, testAccount, testStatus) + suite.NoError(err) + + suite.True(timelineable) +} + +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, testAccount, testStatus) + suite.NoError(err) + + suite.True(timelineable) +} + +func (suite *StatusStatusHomeTimelineableTestSuite) TestFollowingBoostedStatusHomeTimelineable() { + ctx := context.Background() + + testStatus := suite.testStatuses["admin_account_status_4"] + testAccount := suite.testAccounts["local_account_1"] + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.True(timelineable) +} + +func (suite *StatusStatusHomeTimelineableTestSuite) TestFollowingBoostedStatusHomeTimelineableNoReblogs() { + ctx := context.Background() + + // Update follow to indicate that local_account_1 + // doesn't want to see reblogs by admin_account. + follow := >smodel.Follow{} + *follow = *suite.testFollows["local_account_1_admin_account"] + follow.ShowReblogs = util.Ptr(false) + + if err := suite.db.UpdateFollow(ctx, follow, "show_reblogs"); err != nil { + suite.FailNow(err.Error()) + } + + testStatus := suite.testStatuses["admin_account_status_4"] + testAccount := suite.testAccounts["local_account_1"] + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.False(timelineable) +} + +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, testAccount, testStatus) + suite.NoError(err) + + suite.False(timelineable) +} + +func (suite *StatusStatusHomeTimelineableTestSuite) TestStatusTooNewNotTimelineable() { + testStatus := >smodel.Status{} + *testStatus = *suite.testStatuses["local_account_1_status_1"] + + testStatus.CreatedAt = time.Now().Add(25 * time.Hour) + + testAccount := suite.testAccounts["local_account_1"] + ctx := context.Background() + + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.False(timelineable) +} + +func (suite *StatusStatusHomeTimelineableTestSuite) TestStatusNotTooNewTimelineable() { + testStatus := >smodel.Status{} + *testStatus = *suite.testStatuses["local_account_1_status_1"] + + testStatus.CreatedAt = time.Now().Add(23 * time.Hour) + + testAccount := suite.testAccounts["local_account_1"] + ctx := context.Background() + + timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.True(timelineable) +} + +func (suite *StatusStatusHomeTimelineableTestSuite) TestThread() { + ctx := context.Background() + + threadParentAccount := suite.testAccounts["local_account_1"] + timelineOwnerAccount := suite.testAccounts["local_account_2"] + originalStatus := suite.testStatuses["local_account_1_status_1"] + + // this status should be hometimelineable for local_account_2 + originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) + suite.NoError(err) + suite.True(originalStatusTimelineable) + + // now a reply from the original status author to their own status + firstReplyStatus := >smodel.Status{ + ID: "01G395ESAYPK9161QSQEZKATJN", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", + URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", + Content: "nbnbdy expects dog", + CreatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), + UpdatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), + Local: util.Ptr(false), + AccountURI: "http://localhost:8080/users/the_mighty_zork", + AccountID: threadParentAccount.ID, + InReplyToID: originalStatus.ID, + InReplyToAccountID: threadParentAccount.ID, + InReplyToURI: originalStatus.URI, + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityFollowersOnly, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "", + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + if err := suite.db.PutStatus(ctx, firstReplyStatus); err != nil { + suite.FailNow(err.Error()) + } + + // this status should also be hometimelineable for local_account_2 + firstReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, firstReplyStatus) + suite.NoError(err) + suite.True(firstReplyStatusTimelineable) +} + +func (suite *StatusStatusHomeTimelineableTestSuite) TestChainReplyFollowersOnly() { + ctx := context.Background() + + // This scenario makes sure that we don't timeline a status which is a followers-only + // reply to a followers-only status TO A FOLLOWERS-ONLY STATUS owned by someone the + // 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. 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 + + originalStatusParent := suite.testAccounts["remote_account_1"] + replyingAccount := suite.testAccounts["local_account_1"] + timelineOwnerAccount := suite.testAccounts["local_account_2"] + + // put a followers-only status by remote_account_1 in the db + originalStatus := >smodel.Status{ + ID: "01G3957TS7XE2CMDKFG3MZPWAF", + URI: "http://fossbros-anonymous.io/users/foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", + URL: "http://fossbros-anonymous.io/@foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", + Content: "didn't expect dog", + CreatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), + UpdatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), + Local: util.Ptr(false), + AccountURI: "http://fossbros-anonymous.io/users/foss_satan", + AccountID: originalStatusParent.ID, + InReplyToID: "", + InReplyToAccountID: "", + InReplyToURI: "", + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityFollowersOnly, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "", + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + if err := suite.db.PutStatus(ctx, originalStatus); err != nil { + suite.FailNow(err.Error()) + } + // this status should not be hometimelineable for local_account_2 + originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) + suite.NoError(err) + suite.False(originalStatusTimelineable) + + // now a followers-only reply from zork + firstReplyStatus := >smodel.Status{ + ID: "01G395ESAYPK9161QSQEZKATJN", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", + URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", + Content: "nbnbdy expects dog", + CreatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), + UpdatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), + Local: util.Ptr(false), + AccountURI: "http://localhost:8080/users/the_mighty_zork", + AccountID: replyingAccount.ID, + InReplyToID: originalStatus.ID, + InReplyToAccountID: originalStatusParent.ID, + InReplyToURI: originalStatus.URI, + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityFollowersOnly, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "", + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + if err := suite.db.PutStatus(ctx, firstReplyStatus); err != nil { + suite.FailNow(err.Error()) + } + // this status should be hometimelineable for local_account_2 + firstReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, firstReplyStatus) + suite.NoError(err) + suite.False(firstReplyStatusTimelineable) + + // now a followers-only reply from zork to the status they just replied to + secondReplyStatus := >smodel.Status{ + ID: "01G395NZQZGJYRBAES57KYZ7XP", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", + URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", + Content: "*nobody", + CreatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), + UpdatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), + Local: util.Ptr(false), + AccountURI: "http://localhost:8080/users/the_mighty_zork", + AccountID: replyingAccount.ID, + InReplyToID: firstReplyStatus.ID, + InReplyToAccountID: replyingAccount.ID, + InReplyToURI: firstReplyStatus.URI, + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityFollowersOnly, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "", + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + if err := suite.db.PutStatus(ctx, secondReplyStatus); err != nil { + suite.FailNow(err.Error()) + } + + // this status should ALSO not be hometimelineable for local_account_2 + secondReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, secondReplyStatus) + suite.NoError(err) + suite.False(secondReplyStatusTimelineable) +} + +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 + + originalStatusParent := suite.testAccounts["remote_account_1"] + replyingAccount := suite.testAccounts["local_account_1"] + timelineOwnerAccount := suite.testAccounts["local_account_2"] + + // put an unlocked status by remote_account_1 in the db + originalStatus := >smodel.Status{ + ID: "01G3957TS7XE2CMDKFG3MZPWAF", + URI: "http://fossbros-anonymous.io/users/foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", + URL: "http://fossbros-anonymous.io/@foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", + Content: "didn't expect dog", + CreatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), + UpdatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), + Local: util.Ptr(false), + AccountURI: "http://fossbros-anonymous.io/users/foss_satan", + AccountID: originalStatusParent.ID, + InReplyToID: "", + InReplyToAccountID: "", + InReplyToURI: "", + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityUnlocked, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "", + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + if err := suite.db.PutStatus(ctx, originalStatus); err != nil { + suite.FailNow(err.Error()) + } + // this status should not be hometimelineable for local_account_2 + originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) + suite.NoError(err) + suite.False(originalStatusTimelineable) + + // now a public reply from zork + firstReplyStatus := >smodel.Status{ + ID: "01G395ESAYPK9161QSQEZKATJN", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", + URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", + Content: "nbnbdy expects dog", + CreatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), + UpdatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), + Local: util.Ptr(false), + AccountURI: "http://localhost:8080/users/the_mighty_zork", + AccountID: replyingAccount.ID, + InReplyToID: originalStatus.ID, + InReplyToAccountID: originalStatusParent.ID, + InReplyToURI: originalStatus.URI, + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityPublic, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "", + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + 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, timelineOwnerAccount, firstReplyStatus) + suite.NoError(err) + suite.False(firstReplyStatusTimelineable) + + // now an unlocked reply from zork to the status they just replied to + secondReplyStatus := >smodel.Status{ + ID: "01G395NZQZGJYRBAES57KYZ7XP", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", + URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", + Content: "*nobody", + CreatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), + UpdatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), + Local: util.Ptr(false), + AccountURI: "http://localhost:8080/users/the_mighty_zork", + AccountID: replyingAccount.ID, + InReplyToID: firstReplyStatus.ID, + InReplyToAccountID: replyingAccount.ID, + InReplyToURI: firstReplyStatus.URI, + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityUnlocked, + Sensitive: util.Ptr(false), + Language: "en", + CreatedWithApplicationID: "", + Federated: util.Ptr(true), + Boostable: util.Ptr(true), + Replyable: util.Ptr(true), + Likeable: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + if err := suite.db.PutStatus(ctx, secondReplyStatus); err != nil { + suite.FailNow(err.Error()) + } + + // this status should ALSO not be hometimelineable for local_account_2 + secondReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, secondReplyStatus) + suite.NoError(err) + suite.False(secondReplyStatusTimelineable) +} + +func TestStatusHomeTimelineableTestSuite(t *testing.T) { + suite.Run(t, new(StatusStatusHomeTimelineableTestSuite)) +} diff --git a/internal/filter/visibility/public_timeline.go b/internal/filter/visibility/public_timeline.go new file mode 100644 index 000000000..bad7cf991 --- /dev/null +++ b/internal/filter/visibility/public_timeline.go @@ -0,0 +1,123 @@ +// 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 . + +package visibility + +import ( + "context" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "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) { + const vtype = cache.VisibilityTypePublic + + // By default we assume no auth. + requesterID := noauth + + if requester != nil { + // Use provided account ID. + requesterID = requester.ID + } + + visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, + Value: visible, + }, nil + }, vtype, 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.Debugf(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, gtserror.Newf("error getting status parent %s: %w", parentID, err) + } + + if parent.AccountID != status.AccountID { + // This is not a single author reply-chain-thread, + // instead is an actualy conversation. Don't timeline. + log.Trace(ctx, "ignoring multi-author reply-chain") + return false, nil + } + } + + // This is either a visible status in a + // single-author thread, or a visible top + // level status. Show on public timeline. + return true, nil +} diff --git a/internal/filter/visibility/status.go b/internal/filter/visibility/status.go new file mode 100644 index 000000000..5e2052ae4 --- /dev/null +++ b/internal/filter/visibility/status.go @@ -0,0 +1,213 @@ +// 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 . + +package visibility + +import ( + "context" + "slices" + + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "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) { + var errs gtserror.MultiError + filtered := slices.DeleteFunc(statuses, func(status *gtsmodel.Status) bool { + visible, err := f.StatusVisible(ctx, requester, status) + if err != nil { + errs.Append(err) + return true + } + return !visible + }) + 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) { + const vtype = cache.VisibilityTypeStatus + + // By default we assume no auth. + requesterID := noauth + + if requester != nil { + // Use provided account ID. + requesterID = requester.ID + } + + visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, + Value: visible, + }, nil + }, vtype, 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, gtserror.Newf("error populating status %s: %w", status.ID, err) + } + + // Check whether status accounts are visible to the requester. + visible, err := f.areStatusAccountsVisible(ctx, requester, status) + if err != nil { + return false, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("error checking status author visibility: %w", 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, gtserror.Newf("error checking boosted author visibility: %w", err) + } + + if !visible { + log.Trace(ctx, "boosted status author not visible to requester") + return false, nil + } + } + + return true, nil +} diff --git a/internal/filter/visibility/status_test.go b/internal/filter/visibility/status_test.go new file mode 100644 index 000000000..ad6bc66df --- /dev/null +++ b/internal/filter/visibility/status_test.go @@ -0,0 +1,161 @@ +// 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 . + +package visibility_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusVisibleTestSuite struct { + FilterStandardTestSuite +} + +func (suite *StatusVisibleTestSuite) TestOwnStatusVisible() { + testStatus := suite.testStatuses["local_account_1_status_1"] + testAccount := suite.testAccounts["local_account_1"] + ctx := context.Background() + + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.True(visible) +} + +func (suite *StatusVisibleTestSuite) TestOwnDMVisible() { + ctx := context.Background() + + testStatusID := suite.testStatuses["local_account_2_status_6"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["local_account_2"] + + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.True(visible) +} + +func (suite *StatusVisibleTestSuite) TestDMVisibleToTarget() { + ctx := context.Background() + + testStatusID := suite.testStatuses["local_account_2_status_6"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["local_account_1"] + + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.True(visible) +} + +func (suite *StatusVisibleTestSuite) TestDMNotVisibleIfNotMentioned() { + ctx := context.Background() + + testStatusID := suite.testStatuses["local_account_2_status_6"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["admin_account"] + + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.False(visible) +} + +func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotMutuals() { + ctx := context.Background() + + suite.db.DeleteByID(ctx, suite.testFollows["local_account_2_local_account_1"].ID, >smodel.Follow{}) + + 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"] + + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.False(visible) +} + +func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotFollowing() { + ctx := context.Background() + + suite.db.DeleteByID(ctx, suite.testFollows["admin_account_local_account_1"].ID, >smodel.Follow{}) + + testStatusID := suite.testStatuses["local_account_1_status_5"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["admin_account"] + + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + + suite.False(visible) +} + +func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotMutualsCached() { + ctx := context.Background() + testStatusID := suite.testStatuses["local_account_1_status_4"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["local_account_2"] + + // Perform a status visibility check while mutuals, this shsould be true. + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.True(visible) + + err = suite.db.DeleteFollowByID(ctx, suite.testFollows["local_account_2_local_account_1"].ID) + suite.NoError(err) + + // Perform a status visibility check after unfollow, this should be false. + visible, err = suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.False(visible) +} + +func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotFollowingCached() { + ctx := context.Background() + testStatusID := suite.testStatuses["local_account_1_status_5"].ID + testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) + suite.NoError(err) + testAccount := suite.testAccounts["admin_account"] + + // Perform a status visibility check while following, this shsould be true. + visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.True(visible) + + err = suite.db.DeleteFollowByID(ctx, suite.testFollows["admin_account_local_account_1"].ID) + suite.NoError(err) + + // Perform a status visibility check after unfollow, this should be false. + visible, err = suite.filter.StatusVisible(ctx, testAccount, testStatus) + suite.NoError(err) + suite.False(visible) +} + +func TestStatusVisibleTestSuite(t *testing.T) { + suite.Run(t, new(StatusVisibleTestSuite)) +} diff --git a/internal/filter/visibility/tag_timeline.go b/internal/filter/visibility/tag_timeline.go new file mode 100644 index 000000000..b2c9dbf29 --- /dev/null +++ b/internal/filter/visibility/tag_timeline.go @@ -0,0 +1,60 @@ +// 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 . + +package visibility + +import ( + "context" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// StatusHomeTimelineable checks if given status should be included +// on requester's tag timeline, primarily relying on status visibility +// to requester and the AP visibility setting. +func (f *Filter) StatusTagTimelineable( + 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 tag 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 + } + + // Looks good! + return true, nil +} diff --git a/internal/gtserror/error.go b/internal/gtserror/error.go index 9fd9812dc..dc4c5a504 100644 --- a/internal/gtserror/error.go +++ b/internal/gtserror/error.go @@ -37,6 +37,8 @@ const ( wrongTypeKey smtpKey malformedKey + notRelevantKey + spamKey ) // IsUnretrievable indicates that a call to retrieve a resource @@ -127,3 +129,30 @@ func IsMalformed(err error) bool { func SetMalformed(err error) error { return errors.WithValue(err, malformedKey, struct{}{}) } + +// IsNotRelevant checks error for a stored "notRelevant" flag. +// This error is used when determining whether or not to store +// + process an incoming AP message. +func IsNotRelevant(err error) bool { + _, ok := errors.Value(err, notRelevantKey).(struct{}) + return ok +} + +// SetNotRelevant will wrap the given error to store a "notRelevant" flag, +// returning wrapped error. See IsNotRelevant() for example use-cases. +func SetNotRelevant(err error) error { + return errors.WithValue(err, notRelevantKey, struct{}{}) +} + +// IsSpam checks error for a stored "spam" flag. This error is used when +// determining whether or not to store + process an incoming AP message. +func IsSpam(err error) bool { + _, ok := errors.Value(err, spamKey).(struct{}) + return ok +} + +// SetSpam will wrap the given error to store a "spam" flag, +// returning wrapped error. See IsSpam() for example use-cases. +func SetSpam(err error) error { + return errors.WithValue(err, spamKey, struct{}{}) +} diff --git a/internal/gtsmodel/tag.go b/internal/gtsmodel/tag.go index 514389f23..470bee094 100644 --- a/internal/gtsmodel/tag.go +++ b/internal/gtsmodel/tag.go @@ -27,4 +27,5 @@ type Tag struct { Name string `bun:",unique,nullzero,notnull"` // (lowercase) name of the tag without the hash prefix Useable *bool `bun:",nullzero,notnull,default:true"` // Tag is useable on this instance. Listable *bool `bun:",nullzero,notnull,default:true"` // Tagged statuses can be listed on this instance. + Href string `bun:"-"` // Href of the hashtag. Will only be set on freshly-extracted hashtags from remote AP messages. Not stored in the database. } diff --git a/internal/media/media_test.go b/internal/media/media_test.go index a719116d1..0980bf295 100644 --- a/internal/media/media_test.go +++ b/internal/media/media_test.go @@ -20,13 +20,13 @@ package media_test import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index ea6882271..79f6ecfc1 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -19,6 +19,7 @@ package account import ( "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -26,7 +27,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) // Processor wraps functionality for updating, creating, and deleting accounts in response to API requests. diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 2eea5f438..244fc89d6 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -24,6 +24,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/messages" @@ -35,7 +36,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/processing/admin/admin_test.go b/internal/processing/admin/admin_test.go index 1bd355b3d..97b055158 100644 --- a/internal/processing/admin/admin_test.go +++ b/internal/processing/admin/admin_test.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/messages" @@ -33,7 +34,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/processing/common/common.go b/internal/processing/common/common.go index 5abf50674..e4a49cc45 100644 --- a/internal/processing/common/common.go +++ b/internal/processing/common/common.go @@ -19,9 +19,9 @@ package common import ( "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) // Processor provides a processor with logic diff --git a/internal/processing/fedi/fedi.go b/internal/processing/fedi/fedi.go index eeef94113..b08f0eefd 100644 --- a/internal/processing/fedi/fedi.go +++ b/internal/processing/fedi/fedi.go @@ -19,10 +19,10 @@ package fedi import ( "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) type Processor struct { diff --git a/internal/processing/polls/poll_test.go b/internal/processing/polls/poll_test.go index 59e9aeb60..847612503 100644 --- a/internal/processing/polls/poll_test.go +++ b/internal/processing/polls/poll_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/suite" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -32,7 +33,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/polls" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/processing/processor.go b/internal/processing/processor.go index d4ef41bea..bb46d31a9 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -21,6 +21,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/cleaner" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" mm "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -42,7 +43,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) // Processor groups together processing functions and diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index 148ec42ed..2a1d47e46 100644 --- a/internal/processing/processor_test.go +++ b/internal/processing/processor_test.go @@ -25,6 +25,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -34,7 +35,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/processing/search/search.go b/internal/processing/search/search.go index 5321cb89d..6c0ab2457 100644 --- a/internal/processing/search/search.go +++ b/internal/processing/search/search.go @@ -19,9 +19,9 @@ package search import ( "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) type Processor struct { diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go index eaeb12b39..18f8e741a 100644 --- a/internal/processing/status/status.go +++ b/internal/processing/status/status.go @@ -19,13 +19,13 @@ package status import ( "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/processing/polls" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) type Processor struct { diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index 37c7d6147..171e4b488 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" @@ -31,7 +32,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go index 126c9f668..d12dd98c4 100644 --- a/internal/processing/timeline/home.go +++ b/internal/processing/timeline/home.go @@ -23,6 +23,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -30,7 +31,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) // HomeTimelineGrab returns a function that satisfies GrabFunction for home timelines. diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go index 64de288db..7356d1978 100644 --- a/internal/processing/timeline/list.go +++ b/internal/processing/timeline/list.go @@ -23,6 +23,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -30,7 +31,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) // ListTimelineGrab returns a function that satisfies GrabFunction for list timelines. diff --git a/internal/processing/timeline/timeline.go b/internal/processing/timeline/timeline.go index bf9864398..b791791ee 100644 --- a/internal/processing/timeline/timeline.go +++ b/internal/processing/timeline/timeline.go @@ -18,9 +18,9 @@ package timeline import ( + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) type Processor struct { diff --git a/internal/processing/workers/surface.go b/internal/processing/workers/surface.go index 4012dcacc..09162b131 100644 --- a/internal/processing/workers/surface.go +++ b/internal/processing/workers/surface.go @@ -19,10 +19,10 @@ package workers import ( "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/processing/stream" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" ) // surface wraps functions for 'surfacing' the result diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go index 02e2f3f5b..c0612de27 100644 --- a/internal/processing/workers/workers.go +++ b/internal/processing/workers/workers.go @@ -20,12 +20,12 @@ package workers import ( "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/processing/stream" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/internal/workers" ) diff --git a/internal/processing/workers/workers_test.go b/internal/processing/workers/workers_test.go index cbdd10f9a..3ab19d38d 100644 --- a/internal/processing/workers/workers_test.go +++ b/internal/processing/workers/workers_test.go @@ -25,6 +25,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -34,7 +35,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/timeline/timeline_test.go b/internal/timeline/timeline_test.go index d142ed1ff..ffc6d6e53 100644 --- a/internal/timeline/timeline_test.go +++ b/internal/timeline/timeline_test.go @@ -22,10 +22,10 @@ import ( "sort" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go index b68f9695a..3a884d53f 100644 --- a/internal/transport/transport_test.go +++ b/internal/transport/transport_test.go @@ -24,6 +24,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/processing" @@ -31,7 +32,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go index 38376c87a..716a39c29 100644 --- a/internal/typeutils/converter_test.go +++ b/internal/typeutils/converter_test.go @@ -21,11 +21,11 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/testrig" ) diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 9003dcca3..519888e21 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -2000,8 +2000,8 @@ func (suite *InternalToFrontendTestSuite) TestRelationshipFollowRequested() { b, err := json.MarshalIndent(relationship, "", " ") if err != nil { - suite.FailNow(err.Error()) - } + suite.FailNow(err.Error()) + } suite.Equal(`{ "id": "01F8MH5NBDF2MV7CTC4Q5128HF", @@ -2034,10 +2034,10 @@ func (suite *InternalToFrontendTestSuite) TestRelationshipFollowRequested() { b, err = json.MarshalIndent(relationship, "", " ") if err != nil { - suite.FailNow(err.Error()) - } + suite.FailNow(err.Error()) + } - suite.Equal(`{ + suite.Equal(`{ "id": "01F8MH17FWEB39HZJ76B6VXSKF", "following": false, "showing_reblogs": false, diff --git a/internal/visibility/account.go b/internal/visibility/account.go deleted file mode 100644 index 410daa1ce..000000000 --- a/internal/visibility/account.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 . - -package visibility - -import ( - "context" - - "github.com/superseriousbusiness/gotosocial/internal/cache" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "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) { - const vtype = cache.VisibilityTypeAccount - - // By default we assume no auth. - requesterID := noauth - - if requester != nil { - // Use provided account ID. - requesterID = requester.ID - } - - visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, - Value: visible, - }, nil - }, vtype, 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, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("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 { - err := gtserror.Newf("db error getting user for account %s: %w", account.ID, err) - return false, err - } - - // Make sure that user is active (i.e. not disabled, not approved etc). - if *user.Disabled || !*user.Approved || user.ConfirmedAt.IsZero() { - log.Trace(ctx, "local account not active") - return false, nil - } - } else { - // This is a remote account. - - // Check whether remote account's domain is blocked. - blocked, err := f.state.DB.IsDomainBlocked(ctx, account.Domain) - if err != nil { - return false, err - } - - if blocked { - log.Trace(ctx, "remote account domain blocked") - return false, nil - } - } - - if !account.SuspendedAt.IsZero() { - log.Trace(ctx, "account suspended") - return false, nil - } - - return true, nil -} diff --git a/internal/visibility/boostable.go b/internal/visibility/boostable.go deleted file mode 100644 index 7c8bda324..000000000 --- a/internal/visibility/boostable.go +++ /dev/null @@ -1,62 +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 . - -package visibility - -import ( - "context" - - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -// StatusBoostable checks if given status is boostable by requester, checking boolean status visibility to requester and ultimately the AP status visibility setting. -func (f *Filter) StatusBoostable(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { - if status.Visibility == gtsmodel.VisibilityDirect { - log.Trace(ctx, "direct statuses are not boostable") - return false, nil - } - - // Check whether status is visible to requesting account. - visible, err := f.StatusVisible(ctx, requester, status) - if err != nil { - return false, err - } - - if !visible { - log.Trace(ctx, "status not visible to requesting account") - return false, nil - } - - if requester.ID == status.AccountID { - // Status author can always boost non-directs. - return true, nil - } - - if status.Visibility == gtsmodel.VisibilityFollowersOnly || - status.Visibility == gtsmodel.VisibilityMutualsOnly { - log.Trace(ctx, "unauthored %s status not boostable", status.Visibility) - return false, nil - } - - if !*status.Boostable { - log.Trace(ctx, "status marked not boostable") - return false, nil - } - - return true, nil -} diff --git a/internal/visibility/boostable_test.go b/internal/visibility/boostable_test.go deleted file mode 100644 index fd29e7305..000000000 --- a/internal/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 . - -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/visibility/filter.go b/internal/visibility/filter.go deleted file mode 100644 index c9f007ccf..000000000 --- a/internal/visibility/filter.go +++ /dev/null @@ -1,37 +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 . - -package visibility - -import ( - "github.com/superseriousbusiness/gotosocial/internal/state" -) - -// noauth is a placeholder ID used in cache lookups -// when there is no authorized account ID to use. -const noauth = "noauth" - -// 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(state *state.State) *Filter { - return &Filter{state: state} -} diff --git a/internal/visibility/filter_test.go b/internal/visibility/filter_test.go deleted file mode 100644 index 41f06079a..000000000 --- a/internal/visibility/filter_test.go +++ /dev/null @@ -1,77 +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 . - -package visibility_test - -import ( - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/visibility" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type FilterStandardTestSuite struct { - // standard suite interfaces - suite.Suite - db db.DB - state state.State - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - testTags map[string]*gtsmodel.Tag - testMentions map[string]*gtsmodel.Mention - testFollows map[string]*gtsmodel.Follow - - filter *visibility.Filter -} - -func (suite *FilterStandardTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() - suite.testTags = testrig.NewTestTags() - suite.testMentions = testrig.NewTestMentions() - suite.testFollows = testrig.NewTestFollows() -} - -func (suite *FilterStandardTestSuite) SetupTest() { - suite.state.Caches.Init() - - testrig.InitTestConfig() - testrig.InitTestLog() - - suite.db = testrig.NewTestDB(&suite.state) - suite.filter = visibility.NewFilter(&suite.state) - - testrig.StandardDBSetup(suite.db, nil) -} - -func (suite *FilterStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) -} diff --git a/internal/visibility/home_timeline.go b/internal/visibility/home_timeline.go deleted file mode 100644 index 0a3fbde4e..000000000 --- a/internal/visibility/home_timeline.go +++ /dev/null @@ -1,279 +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 . - -package visibility - -import ( - "context" - "errors" - "time" - - "github.com/superseriousbusiness/gotosocial/internal/cache" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -// 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) { - const vtype = cache.VisibilityTypeHome - - // By default we assume no auth. - requesterID := noauth - - if owner != nil { - // Use provided account ID. - requesterID = owner.ID - } - - visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, - Value: visible, - }, nil - }, vtype, 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 ( - // iterated-over - // loop status. - next = status - - // assume one author - // until proven otherwise. - oneAuthor = true - ) - - for { - // Populate account mention objects before account mention checks. - next.Mentions, err = f.state.DB.GetMentions(ctx, next.MentionIDs) - if err != nil { - return false, gtserror.Newf("error populating status %s mentions: %w", next.ID, err) - } - - if (next.AccountID == owner.ID) || - next.MentionsAccount(owner.ID) { - // Owner is in / mentioned in - // this status thread. They can - // see future visible statuses. - visible = true - break - } - - var notVisible bool - - // Check whether status in conversation is explicitly relevant to timeline - // owner (i.e. includes mutals), or is explicitly invisible (i.e. blocked). - visible, notVisible, err = f.isVisibleConversation(ctx, owner, next) - if err != nil { - return false, gtserror.Newf("error checking conversation visibility: %w", err) - } - - if notVisible { - log.Tracef(ctx, "conversation not visible to timeline owner") - return false, nil - } - - if visible { - // Conversation relevant - // to timeline owner! - break - } - - if oneAuthor { - // Check if this continues to be a single-author thread. - oneAuthor = (next.AccountID == status.AccountID) - } - - if next.InReplyToURI == "" { - // Reached the top of the thread. - break - } - - // Check parent is deref'd. - if next.InReplyToID == "" { - log.Debugf(ctx, "status not (yet) deref'd: %s", next.InReplyToURI) - return false, cache.SentinelError - } - - // Fetch next parent in conversation. - next, err = f.state.DB.GetStatusByID( - gtscontext.SetBarebones(ctx), - next.InReplyToID, - ) - if err != nil { - return false, gtserror.Newf("error getting status parent %s: %w", next.InReplyToID, err) - } - } - - if next != status && !oneAuthor && !visible { - log.Trace(ctx, "ignoring visible reply in conversation irrelevant to 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 🧵"), - // a status thread *including* the owner, or a conversation thread between - // accounts the timeline owner follows. - - // Ensure owner follows author. - follow, err := f.state.DB.GetFollow(ctx, - owner.ID, - status.AccountID, - ) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - return false, gtserror.Newf("error retrieving follow %s->%s: %w", owner.ID, status.AccountID, err) - } - - if follow == nil { - log.Trace(ctx, "ignoring status from unfollowed author") - return false, nil - } - - if status.BoostOfID != "" && !*follow.ShowReblogs { - // Status is a boost, but the owner of this follow - // doesn't want to see boosts from this account. - return false, nil - } - - return true, nil -} - -func (f *Filter) isVisibleConversation( - ctx context.Context, - owner *gtsmodel.Account, - status *gtsmodel.Status, -) ( - bool, // explicitly IS visible - bool, // explicitly NOT visible - error, // err -) { - // Check if status is visible to the timeline owner. - visible, err := f.StatusVisible(ctx, owner, status) - if err != nil { - return false, false, err - } - - if !visible { - // Explicitly NOT visible - // to the timeline owner. - return false, true, nil - } - - if status.Visibility == gtsmodel.VisibilityUnlocked || - status.Visibility == gtsmodel.VisibilityPublic { - // NOTE: there is no need to check in the case of - // direct / follow-only / mutual-only visibility statuses - // as the above visibility check already handles this. - - // Check owner follows the status author. - follow, err := f.state.DB.IsFollowing(ctx, - owner.ID, - status.AccountID, - ) - if err != nil { - return false, false, gtserror.Newf("error checking follow %s->%s: %w", owner.ID, status.AccountID, err) - } - - if !follow { - // Not explicitly visible - // status to timeline owner. - return false, false, nil - } - } - - var follow bool - - for _, mention := range status.Mentions { - // Check block between timeline owner and mention. - block, err := f.state.DB.IsEitherBlocked(ctx, - owner.ID, - mention.TargetAccountID, - ) - if err != nil { - return false, false, gtserror.Newf("error checking mention block %s<->%s: %w", owner.ID, mention.TargetAccountID, err) - } - - if block { - // Invisible conversation. - return false, true, nil - } - - if !follow { - // See if tl owner follows any of mentions. - follow, err = f.state.DB.IsFollowing(ctx, - owner.ID, - mention.TargetAccountID, - ) - if err != nil { - return false, false, gtserror.Newf("error checking mention follow %s->%s: %w", owner.ID, mention.TargetAccountID, err) - } - } - } - - return follow, false, nil -} diff --git a/internal/visibility/home_timeline_test.go b/internal/visibility/home_timeline_test.go deleted file mode 100644 index d8211c8dd..000000000 --- a/internal/visibility/home_timeline_test.go +++ /dev/null @@ -1,415 +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 . - -package visibility_test - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusStatusHomeTimelineableTestSuite struct { - FilterStandardTestSuite -} - -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, testAccount, testStatus) - suite.NoError(err) - - suite.True(timelineable) -} - -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, testAccount, testStatus) - suite.NoError(err) - - suite.True(timelineable) -} - -func (suite *StatusStatusHomeTimelineableTestSuite) TestFollowingBoostedStatusHomeTimelineable() { - ctx := context.Background() - - testStatus := suite.testStatuses["admin_account_status_4"] - testAccount := suite.testAccounts["local_account_1"] - timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(timelineable) -} - -func (suite *StatusStatusHomeTimelineableTestSuite) TestFollowingBoostedStatusHomeTimelineableNoReblogs() { - ctx := context.Background() - - // Update follow to indicate that local_account_1 - // doesn't want to see reblogs by admin_account. - follow := >smodel.Follow{} - *follow = *suite.testFollows["local_account_1_admin_account"] - follow.ShowReblogs = util.Ptr(false) - - if err := suite.db.UpdateFollow(ctx, follow, "show_reblogs"); err != nil { - suite.FailNow(err.Error()) - } - - testStatus := suite.testStatuses["admin_account_status_4"] - testAccount := suite.testAccounts["local_account_1"] - timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(timelineable) -} - -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, testAccount, testStatus) - suite.NoError(err) - - suite.False(timelineable) -} - -func (suite *StatusStatusHomeTimelineableTestSuite) TestStatusTooNewNotTimelineable() { - testStatus := >smodel.Status{} - *testStatus = *suite.testStatuses["local_account_1_status_1"] - - testStatus.CreatedAt = time.Now().Add(25 * time.Hour) - - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(timelineable) -} - -func (suite *StatusStatusHomeTimelineableTestSuite) TestStatusNotTooNewTimelineable() { - testStatus := >smodel.Status{} - *testStatus = *suite.testStatuses["local_account_1_status_1"] - - testStatus.CreatedAt = time.Now().Add(23 * time.Hour) - - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - timelineable, err := suite.filter.StatusHomeTimelineable(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(timelineable) -} - -func (suite *StatusStatusHomeTimelineableTestSuite) TestThread() { - ctx := context.Background() - - threadParentAccount := suite.testAccounts["local_account_1"] - timelineOwnerAccount := suite.testAccounts["local_account_2"] - originalStatus := suite.testStatuses["local_account_1_status_1"] - - // this status should be hometimelineable for local_account_2 - originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) - suite.NoError(err) - suite.True(originalStatusTimelineable) - - // now a reply from the original status author to their own status - firstReplyStatus := >smodel.Status{ - ID: "01G395ESAYPK9161QSQEZKATJN", - URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", - URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", - Content: "nbnbdy expects dog", - CreatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), - UpdatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), - Local: util.Ptr(false), - AccountURI: "http://localhost:8080/users/the_mighty_zork", - AccountID: threadParentAccount.ID, - InReplyToID: originalStatus.ID, - InReplyToAccountID: threadParentAccount.ID, - InReplyToURI: originalStatus.URI, - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityFollowersOnly, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "", - Federated: util.Ptr(true), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - if err := suite.db.PutStatus(ctx, firstReplyStatus); err != nil { - suite.FailNow(err.Error()) - } - - // this status should also be hometimelineable for local_account_2 - firstReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, firstReplyStatus) - suite.NoError(err) - suite.True(firstReplyStatusTimelineable) -} - -func (suite *StatusStatusHomeTimelineableTestSuite) TestChainReplyFollowersOnly() { - ctx := context.Background() - - // This scenario makes sure that we don't timeline a status which is a followers-only - // reply to a followers-only status TO A FOLLOWERS-ONLY STATUS owned by someone the - // 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. 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 - - originalStatusParent := suite.testAccounts["remote_account_1"] - replyingAccount := suite.testAccounts["local_account_1"] - timelineOwnerAccount := suite.testAccounts["local_account_2"] - - // put a followers-only status by remote_account_1 in the db - originalStatus := >smodel.Status{ - ID: "01G3957TS7XE2CMDKFG3MZPWAF", - URI: "http://fossbros-anonymous.io/users/foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", - URL: "http://fossbros-anonymous.io/@foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", - Content: "didn't expect dog", - CreatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), - UpdatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), - Local: util.Ptr(false), - AccountURI: "http://fossbros-anonymous.io/users/foss_satan", - AccountID: originalStatusParent.ID, - InReplyToID: "", - InReplyToAccountID: "", - InReplyToURI: "", - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityFollowersOnly, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "", - Federated: util.Ptr(true), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - if err := suite.db.PutStatus(ctx, originalStatus); err != nil { - suite.FailNow(err.Error()) - } - // this status should not be hometimelineable for local_account_2 - originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) - suite.NoError(err) - suite.False(originalStatusTimelineable) - - // now a followers-only reply from zork - firstReplyStatus := >smodel.Status{ - ID: "01G395ESAYPK9161QSQEZKATJN", - URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", - URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", - Content: "nbnbdy expects dog", - CreatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), - UpdatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), - Local: util.Ptr(false), - AccountURI: "http://localhost:8080/users/the_mighty_zork", - AccountID: replyingAccount.ID, - InReplyToID: originalStatus.ID, - InReplyToAccountID: originalStatusParent.ID, - InReplyToURI: originalStatus.URI, - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityFollowersOnly, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "", - Federated: util.Ptr(true), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - if err := suite.db.PutStatus(ctx, firstReplyStatus); err != nil { - suite.FailNow(err.Error()) - } - // this status should be hometimelineable for local_account_2 - firstReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, firstReplyStatus) - suite.NoError(err) - suite.False(firstReplyStatusTimelineable) - - // now a followers-only reply from zork to the status they just replied to - secondReplyStatus := >smodel.Status{ - ID: "01G395NZQZGJYRBAES57KYZ7XP", - URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", - URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", - Content: "*nobody", - CreatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), - UpdatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), - Local: util.Ptr(false), - AccountURI: "http://localhost:8080/users/the_mighty_zork", - AccountID: replyingAccount.ID, - InReplyToID: firstReplyStatus.ID, - InReplyToAccountID: replyingAccount.ID, - InReplyToURI: firstReplyStatus.URI, - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityFollowersOnly, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "", - Federated: util.Ptr(true), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - if err := suite.db.PutStatus(ctx, secondReplyStatus); err != nil { - suite.FailNow(err.Error()) - } - - // this status should ALSO not be hometimelineable for local_account_2 - secondReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, secondReplyStatus) - suite.NoError(err) - suite.False(secondReplyStatusTimelineable) -} - -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 - - originalStatusParent := suite.testAccounts["remote_account_1"] - replyingAccount := suite.testAccounts["local_account_1"] - timelineOwnerAccount := suite.testAccounts["local_account_2"] - - // put an unlocked status by remote_account_1 in the db - originalStatus := >smodel.Status{ - ID: "01G3957TS7XE2CMDKFG3MZPWAF", - URI: "http://fossbros-anonymous.io/users/foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", - URL: "http://fossbros-anonymous.io/@foss_satan/statuses/01G3957TS7XE2CMDKFG3MZPWAF", - Content: "didn't expect dog", - CreatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), - UpdatedAt: testrig.TimeMustParse("2021-09-20T12:40:37+02:00"), - Local: util.Ptr(false), - AccountURI: "http://fossbros-anonymous.io/users/foss_satan", - AccountID: originalStatusParent.ID, - InReplyToID: "", - InReplyToAccountID: "", - InReplyToURI: "", - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityUnlocked, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "", - Federated: util.Ptr(true), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - if err := suite.db.PutStatus(ctx, originalStatus); err != nil { - suite.FailNow(err.Error()) - } - // this status should not be hometimelineable for local_account_2 - originalStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, originalStatus) - suite.NoError(err) - suite.False(originalStatusTimelineable) - - // now a public reply from zork - firstReplyStatus := >smodel.Status{ - ID: "01G395ESAYPK9161QSQEZKATJN", - URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", - URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395ESAYPK9161QSQEZKATJN", - Content: "nbnbdy expects dog", - CreatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), - UpdatedAt: testrig.TimeMustParse("2021-09-20T12:41:37+02:00"), - Local: util.Ptr(false), - AccountURI: "http://localhost:8080/users/the_mighty_zork", - AccountID: replyingAccount.ID, - InReplyToID: originalStatus.ID, - InReplyToAccountID: originalStatusParent.ID, - InReplyToURI: originalStatus.URI, - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityPublic, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "", - Federated: util.Ptr(true), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - 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, timelineOwnerAccount, firstReplyStatus) - suite.NoError(err) - suite.False(firstReplyStatusTimelineable) - - // now an unlocked reply from zork to the status they just replied to - secondReplyStatus := >smodel.Status{ - ID: "01G395NZQZGJYRBAES57KYZ7XP", - URI: "http://localhost:8080/users/the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", - URL: "http://localhost:8080/@the_mighty_zork/statuses/01G395NZQZGJYRBAES57KYZ7XP", - Content: "*nobody", - CreatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), - UpdatedAt: testrig.TimeMustParse("2021-09-20T12:42:37+02:00"), - Local: util.Ptr(false), - AccountURI: "http://localhost:8080/users/the_mighty_zork", - AccountID: replyingAccount.ID, - InReplyToID: firstReplyStatus.ID, - InReplyToAccountID: replyingAccount.ID, - InReplyToURI: firstReplyStatus.URI, - BoostOfID: "", - ContentWarning: "", - Visibility: gtsmodel.VisibilityUnlocked, - Sensitive: util.Ptr(false), - Language: "en", - CreatedWithApplicationID: "", - Federated: util.Ptr(true), - Boostable: util.Ptr(true), - Replyable: util.Ptr(true), - Likeable: util.Ptr(true), - ActivityStreamsType: ap.ObjectNote, - } - if err := suite.db.PutStatus(ctx, secondReplyStatus); err != nil { - suite.FailNow(err.Error()) - } - - // this status should ALSO not be hometimelineable for local_account_2 - secondReplyStatusTimelineable, err := suite.filter.StatusHomeTimelineable(ctx, timelineOwnerAccount, secondReplyStatus) - suite.NoError(err) - suite.False(secondReplyStatusTimelineable) -} - -func TestStatusHomeTimelineableTestSuite(t *testing.T) { - suite.Run(t, new(StatusStatusHomeTimelineableTestSuite)) -} diff --git a/internal/visibility/public_timeline.go b/internal/visibility/public_timeline.go deleted file mode 100644 index bad7cf991..000000000 --- a/internal/visibility/public_timeline.go +++ /dev/null @@ -1,123 +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 . - -package visibility - -import ( - "context" - "time" - - "github.com/superseriousbusiness/gotosocial/internal/cache" - "github.com/superseriousbusiness/gotosocial/internal/gtscontext" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "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) { - const vtype = cache.VisibilityTypePublic - - // By default we assume no auth. - requesterID := noauth - - if requester != nil { - // Use provided account ID. - requesterID = requester.ID - } - - visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, - Value: visible, - }, nil - }, vtype, 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.Debugf(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, gtserror.Newf("error getting status parent %s: %w", parentID, err) - } - - if parent.AccountID != status.AccountID { - // This is not a single author reply-chain-thread, - // instead is an actualy conversation. Don't timeline. - log.Trace(ctx, "ignoring multi-author reply-chain") - return false, nil - } - } - - // This is either a visible status in a - // single-author thread, or a visible top - // level status. Show on public timeline. - return true, nil -} diff --git a/internal/visibility/status.go b/internal/visibility/status.go deleted file mode 100644 index 5e2052ae4..000000000 --- a/internal/visibility/status.go +++ /dev/null @@ -1,213 +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 . - -package visibility - -import ( - "context" - "slices" - - "github.com/superseriousbusiness/gotosocial/internal/cache" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "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) { - var errs gtserror.MultiError - filtered := slices.DeleteFunc(statuses, func(status *gtsmodel.Status) bool { - visible, err := f.StatusVisible(ctx, requester, status) - if err != nil { - errs.Append(err) - return true - } - return !visible - }) - 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) { - const vtype = cache.VisibilityTypeStatus - - // By default we assume no auth. - requesterID := noauth - - if requester != nil { - // Use provided account ID. - requesterID = requester.ID - } - - visibility, err := f.state.Caches.Visibility.LoadOne("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: vtype, - Value: visible, - }, nil - }, vtype, 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, gtserror.Newf("error populating status %s: %w", status.ID, err) - } - - // Check whether status accounts are visible to the requester. - visible, err := f.areStatusAccountsVisible(ctx, requester, status) - if err != nil { - return false, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("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, gtserror.Newf("error checking status author visibility: %w", 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, gtserror.Newf("error checking boosted author visibility: %w", err) - } - - if !visible { - log.Trace(ctx, "boosted status author not visible to requester") - return false, nil - } - } - - return true, nil -} diff --git a/internal/visibility/status_test.go b/internal/visibility/status_test.go deleted file mode 100644 index ad6bc66df..000000000 --- a/internal/visibility/status_test.go +++ /dev/null @@ -1,161 +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 . - -package visibility_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type StatusVisibleTestSuite struct { - FilterStandardTestSuite -} - -func (suite *StatusVisibleTestSuite) TestOwnStatusVisible() { - testStatus := suite.testStatuses["local_account_1_status_1"] - testAccount := suite.testAccounts["local_account_1"] - ctx := context.Background() - - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(visible) -} - -func (suite *StatusVisibleTestSuite) TestOwnDMVisible() { - ctx := context.Background() - - testStatusID := suite.testStatuses["local_account_2_status_6"].ID - testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) - suite.NoError(err) - testAccount := suite.testAccounts["local_account_2"] - - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(visible) -} - -func (suite *StatusVisibleTestSuite) TestDMVisibleToTarget() { - ctx := context.Background() - - testStatusID := suite.testStatuses["local_account_2_status_6"].ID - testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) - suite.NoError(err) - testAccount := suite.testAccounts["local_account_1"] - - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.True(visible) -} - -func (suite *StatusVisibleTestSuite) TestDMNotVisibleIfNotMentioned() { - ctx := context.Background() - - testStatusID := suite.testStatuses["local_account_2_status_6"].ID - testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) - suite.NoError(err) - testAccount := suite.testAccounts["admin_account"] - - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(visible) -} - -func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotMutuals() { - ctx := context.Background() - - suite.db.DeleteByID(ctx, suite.testFollows["local_account_2_local_account_1"].ID, >smodel.Follow{}) - - 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"] - - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(visible) -} - -func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotFollowing() { - ctx := context.Background() - - suite.db.DeleteByID(ctx, suite.testFollows["admin_account_local_account_1"].ID, >smodel.Follow{}) - - testStatusID := suite.testStatuses["local_account_1_status_5"].ID - testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) - suite.NoError(err) - testAccount := suite.testAccounts["admin_account"] - - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - - suite.False(visible) -} - -func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotMutualsCached() { - ctx := context.Background() - testStatusID := suite.testStatuses["local_account_1_status_4"].ID - testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) - suite.NoError(err) - testAccount := suite.testAccounts["local_account_2"] - - // Perform a status visibility check while mutuals, this shsould be true. - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - suite.True(visible) - - err = suite.db.DeleteFollowByID(ctx, suite.testFollows["local_account_2_local_account_1"].ID) - suite.NoError(err) - - // Perform a status visibility check after unfollow, this should be false. - visible, err = suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - suite.False(visible) -} - -func (suite *StatusVisibleTestSuite) TestStatusNotVisibleIfNotFollowingCached() { - ctx := context.Background() - testStatusID := suite.testStatuses["local_account_1_status_5"].ID - testStatus, err := suite.db.GetStatusByID(ctx, testStatusID) - suite.NoError(err) - testAccount := suite.testAccounts["admin_account"] - - // Perform a status visibility check while following, this shsould be true. - visible, err := suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - suite.True(visible) - - err = suite.db.DeleteFollowByID(ctx, suite.testFollows["admin_account_local_account_1"].ID) - suite.NoError(err) - - // Perform a status visibility check after unfollow, this should be false. - visible, err = suite.filter.StatusVisible(ctx, testAccount, testStatus) - suite.NoError(err) - suite.False(visible) -} - -func TestStatusVisibleTestSuite(t *testing.T) { - suite.Run(t, new(StatusVisibleTestSuite)) -} diff --git a/internal/visibility/tag_timeline.go b/internal/visibility/tag_timeline.go deleted file mode 100644 index b2c9dbf29..000000000 --- a/internal/visibility/tag_timeline.go +++ /dev/null @@ -1,60 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package visibility - -import ( - "context" - "time" - - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -// StatusHomeTimelineable checks if given status should be included -// on requester's tag timeline, primarily relying on status visibility -// to requester and the AP visibility setting. -func (f *Filter) StatusTagTimelineable( - 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 tag 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 - } - - // Looks good! - return true, nil -} -- cgit v1.2.3