From 2d921d9d7c8411a802df2ea7767f345bc93efd17 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 23 Jul 2024 12:51:07 -0700 Subject: [PATCH] Explicitly propagate filter results from statuses to their boosts in API responses (#3130) Related to #3128 --- internal/typeutils/internaltofrontend.go | 4 +- internal/typeutils/internaltofrontend_test.go | 367 +++++++++++++++++- 2 files changed, 355 insertions(+), 16 deletions(-) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index a13304bd8..c194360c1 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -841,6 +841,7 @@ func (c *Converter) statusToAPIFilterResults( if mutes.Matches(s.AccountID, filterContext, now) { return nil, statusfilter.ErrHideStatus } + // If this status is part of a multi-account discussion, // and all of the accounts replied to or mentioned are invisible to the requesting account // (due to blocks, domain blocks, moderation, etc.), @@ -1185,13 +1186,14 @@ func (c *Converter) statusToFrontend( return nil, gtserror.Newf("error converting boosted status: %w", err) } - // Set boosted status and set interactions from original. + // Set boosted status and set interactions and filter results from original. apiStatus.Reblog = &apimodel.StatusReblogged{reblog} apiStatus.Favourited = apiStatus.Reblog.Favourited apiStatus.Bookmarked = apiStatus.Reblog.Bookmarked apiStatus.Muted = apiStatus.Reblog.Muted apiStatus.Reblogged = apiStatus.Reblog.Reblogged apiStatus.Pinned = apiStatus.Reblog.Pinned + apiStatus.Filtered = apiStatus.Reblog.Filtered } return apiStatus, nil diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index faae22e65..fda4bc005 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -23,6 +23,7 @@ import ( "testing" "github.com/stretchr/testify/suite" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" @@ -570,19 +571,35 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { }`, string(b)) } -// Test that a status which is filtered with a warn filter by the requesting user has `filtered` set correctly. -func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() { +// Modify a fixture status into a status that should be filtered, +// and then filter it, returning the API status or any error from converting it. +func (suite *InternalToFrontendTestSuite) filteredStatusToFrontend(action gtsmodel.FilterAction, boost bool) (*apimodel.Status, error) { testStatus := suite.testStatuses["admin_account_status_1"] testStatus.Content += " fnord" testStatus.Text += " fnord" + + if boost { + // Modify a fixture boost into a boost of the above status. + boostStatus := suite.testStatuses["admin_account_status_4"] + boostStatus.BoostOf = testStatus + boostStatus.BoostOfID = testStatus.ID + testStatus = boostStatus + } + requestingAccount := suite.testAccounts["local_account_1"] + expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"] + expectedMatchingFilter.Action = action + expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"] suite.NoError(expectedMatchingFilterKeyword.Compile()) expectedMatchingFilterKeyword.Filter = expectedMatchingFilter + expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword} + requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter} - apiStatus, err := suite.typeconverter.StatusToAPIStatus( + + return suite.typeconverter.StatusToAPIStatus( context.Background(), testStatus, requestingAccount, @@ -590,6 +607,11 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() { requestingAccountFilters, nil, ) +} + +// Test that a status which is filtered with a warn filter by the requesting user has `filtered` set correctly. +func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() { + apiStatus, err := suite.filteredStatusToFrontend(gtsmodel.FilterActionWarn, false) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -745,28 +767,343 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() { }`, string(b)) } +// Test that a status which is filtered with a warn filter by the requesting user has `filtered` set correctly when boosted. +func (suite *InternalToFrontendTestSuite) TestWarnFilteredBoostToFrontend() { + apiStatus, err := suite.filteredStatusToFrontend(gtsmodel.FilterActionWarn, true) + suite.NoError(err) + + b, err := json.MarshalIndent(apiStatus, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "id": "01G36SF3V6Y6V5BF9P4R7PQG7G", + "created_at": "2021-10-20T10:41:37.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": true, + "spoiler_text": "introduction post", + "visibility": "public", + "language": "en", + "uri": "http://localhost:8080/users/admin/statuses/01G36SF3V6Y6V5BF9P4R7PQG7G", + "url": "http://localhost:8080/@admin/statuses/01G36SF3V6Y6V5BF9P4R7PQG7G", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": true, + "reblogged": false, + "muted": false, + "bookmarked": true, + "pinned": false, + "content": "hello everyone!", + "reblog": { + "id": "01F8MH75CBF9JFX4ZAD54N0W0R", + "created_at": "2021-10-20T11:36:45.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 1, + "favourited": true, + "reblogged": false, + "muted": false, + "bookmarked": true, + "pinned": false, + "content": "hello world! #welcome ! first post on the instance :rainbow: ! fnord", + "reblog": null, + "application": { + "name": "superseriousbusiness", + "website": "https://superserious.business" + }, + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", + "avatar_description": "a green goblin looking nasty", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "followers_count": 2, + "following_count": 2, + "statuses_count": 8, + "last_status_at": "2024-01-10T09:24:00.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "user" + } + }, + "media_attachments": [ + { + "id": "01F8MH6NEM8D7527KZAECTCR76", + "type": "image", + "url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", + "text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", + "preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp", + "remote_url": null, + "preview_remote_url": null, + "meta": { + "original": { + "width": 1200, + "height": 630, + "size": "1200x630", + "aspect": 1.9047619 + }, + "small": { + "width": 512, + "height": 268, + "size": "512x268", + "aspect": 1.9104477 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Black and white image of some 50's style text saying: Welcome On Board", + "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj" + } + ], + "mentions": [], + "tags": [ + { + "name": "welcome", + "url": "http://localhost:8080/tags/welcome" + } + ], + "emojis": [ + { + "shortcode": "rainbow", + "url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png", + "static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png", + "visible_in_picker": true, + "category": "reactions" + } + ], + "card": null, + "poll": null, + "text": "hello world! #welcome ! first post on the instance :rainbow: ! fnord", + "filtered": [ + { + "filter": { + "id": "01HN26VM6KZTW1ANNRVSBMA461", + "title": "fnord", + "context": [ + "home", + "public" + ], + "expires_at": null, + "filter_action": "warn", + "keywords": [ + { + "id": "01HN272TAVWAXX72ZX4M8JZ0PS", + "keyword": "fnord", + "whole_word": true + } + ], + "statuses": [] + }, + "keyword_matches": [ + "fnord" + ], + "status_matches": [] + } + ], + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } + }, + "application": { + "name": "superseriousbusiness", + "website": "https://superserious.business" + }, + "account": { + "id": "01F8MH17FWEB39HZJ76B6VXSKF", + "username": "admin", + "acct": "admin", + "display_name": "", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-17T13:10:59.000Z", + "note": "", + "url": "http://localhost:8080/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.webp", + "header_static": "http://localhost:8080/assets/default_header.webp", + "followers_count": 1, + "following_count": 1, + "statuses_count": 4, + "last_status_at": "2021-10-20T10:41:37.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "admin" + } + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "text": "hello everyone!", + "filtered": [ + { + "filter": { + "id": "01HN26VM6KZTW1ANNRVSBMA461", + "title": "fnord", + "context": [ + "home", + "public" + ], + "expires_at": null, + "filter_action": "warn", + "keywords": [ + { + "id": "01HN272TAVWAXX72ZX4M8JZ0PS", + "keyword": "fnord", + "whole_word": true + } + ], + "statuses": [] + }, + "keyword_matches": [ + "fnord" + ], + "status_matches": [] + } + ], + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } +}`, string(b)) +} + // Test that a status which is filtered with a hide filter by the requesting user results in the ErrHideStatus error. func (suite *InternalToFrontendTestSuite) TestHideFilteredStatusToFrontend() { + _, err := suite.filteredStatusToFrontend(gtsmodel.FilterActionHide, false) + suite.ErrorIs(err, statusfilter.ErrHideStatus) +} + +// Test that a status which is filtered with a hide filter by the requesting user results in the ErrHideStatus error for a boost of that status. +func (suite *InternalToFrontendTestSuite) TestHideFilteredBoostToFrontend() { + _, err := suite.filteredStatusToFrontend(gtsmodel.FilterActionHide, true) + suite.ErrorIs(err, statusfilter.ErrHideStatus) +} + +// Test that a hashtag filter for a hashtag in Mastodon HTML content works the way most users would expect. +func (suite *InternalToFrontendTestSuite) testHashtagFilteredStatusToFrontend(wholeWord bool, boost bool) { testStatus := suite.testStatuses["admin_account_status_1"] - testStatus.Content += " fnord" - testStatus.Text += " fnord" + testStatus.Content = `

doggo doggin' it

#dogsofmastodon

` + + if boost { + // Modify a fixture boost into a boost of the above status. + boostStatus := suite.testStatuses["admin_account_status_4"] + boostStatus.BoostOf = testStatus + boostStatus.BoostOfID = testStatus.ID + testStatus = boostStatus + } + requestingAccount := suite.testAccounts["local_account_1"] - expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"] - expectedMatchingFilter.Action = gtsmodel.FilterActionHide - expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"] - suite.NoError(expectedMatchingFilterKeyword.Compile()) - expectedMatchingFilterKeyword.Filter = expectedMatchingFilter - expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword} - requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter} - _, err := suite.typeconverter.StatusToAPIStatus( + + filterKeyword := >smodel.FilterKeyword{ + Keyword: "#dogsofmastodon", + WholeWord: &wholeWord, + Regexp: nil, + } + if err := filterKeyword.Compile(); err != nil { + suite.FailNow(err.Error()) + } + + filter := >smodel.Filter{ + Action: gtsmodel.FilterActionWarn, + Keywords: []*gtsmodel.FilterKeyword{filterKeyword}, + ContextHome: util.Ptr(true), + ContextNotifications: util.Ptr(false), + ContextPublic: util.Ptr(false), + ContextThread: util.Ptr(false), + ContextAccount: util.Ptr(false), + } + + apiStatus, err := suite.typeconverter.StatusToAPIStatus( context.Background(), testStatus, requestingAccount, statusfilter.FilterContextHome, - requestingAccountFilters, + []*gtsmodel.Filter{filter}, nil, ) - suite.ErrorIs(err, statusfilter.ErrHideStatus) + if suite.NoError(err) { + suite.NotEmpty(apiStatus.Filtered) + } +} + +func (suite *InternalToFrontendTestSuite) TestHashtagWholeWordFilteredStatusToFrontend() { + suite.testHashtagFilteredStatusToFrontend(true, false) +} + +func (suite *InternalToFrontendTestSuite) TestHashtagWholeWordFilteredBoostToFrontend() { + suite.testHashtagFilteredStatusToFrontend(true, true) +} + +func (suite *InternalToFrontendTestSuite) TestHashtagAnywhereFilteredStatusToFrontend() { + suite.testHashtagFilteredStatusToFrontend(false, false) +} + +func (suite *InternalToFrontendTestSuite) TestHashtagAnywhereFilteredBoostToFrontend() { + suite.testHashtagFilteredStatusToFrontend(false, true) } // Test that a status from a user muted by the requesting user results in the ErrHideStatus error.