From 45f4afe60e29e147e3adfaa4d7b66ca58e22b1de Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Mon, 6 May 2024 04:49:08 -0700 Subject: [PATCH] feature: filters v2 server-side warning/hiding (#2793) * Remove dead code * Filter statuses when converting to frontend representation * status.filtered is an array * Make matching case-insensitive * Remove TODOs that don't need to be done now * Add missing filter check for notification * lint: rename ErrHideStatus * APIFilterActionToFilterAction not used yet * swaggerino docseroni * Address review comments * Add apimodel.FilterActionNone --------- Co-authored-by: tobi <31960611+tsmethurst@users.noreply.github.com> Co-authored-by: tobi --- docs/api/swagger.yaml | 116 +++++++++ internal/api/model/filterresult.go | 34 +++ internal/api/model/filterv2.go | 106 ++++++++ internal/api/model/status.go | 2 + internal/filter/status/status.go | 45 ++++ internal/processing/account/bookmarks.go | 3 +- internal/processing/account/statuses.go | 9 +- internal/processing/common/status.go | 84 +----- internal/processing/search/util.go | 3 +- internal/processing/status/get.go | 11 +- .../processing/stream/statusupdate_test.go | 3 +- internal/processing/timeline/faved.go | 3 +- internal/processing/timeline/home.go | 9 +- internal/processing/timeline/list.go | 9 +- internal/processing/timeline/notification.go | 16 +- internal/processing/timeline/public.go | 16 +- internal/processing/timeline/tag.go | 12 +- .../processing/workers/fromclientapi_test.go | 5 +- internal/processing/workers/surfacenotify.go | 7 +- .../processing/workers/surfacetimeline.go | 34 ++- internal/timeline/prepare.go | 7 + internal/typeutils/converter_test.go | 26 +- internal/typeutils/internaltofrontend.go | 243 ++++++++++++++++-- internal/typeutils/internaltofrontend_test.go | 182 ++++++++++++- 24 files changed, 855 insertions(+), 130 deletions(-) create mode 100644 internal/api/model/filterresult.go create mode 100644 internal/api/model/filterv2.go create mode 100644 internal/filter/status/status.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index dda090c52..8bd43ae8e 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -1,5 +1,9 @@ basePath: / definitions: + FilterAction: + title: FilterAction is the action to apply to statuses matching a filter. + type: string + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model InstanceConfigurationEmojis: properties: emoji_size_limit: @@ -1037,6 +1041,60 @@ definitions: type: string x-go-name: FilterContext x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + filterKeyword: + properties: + id: + description: The ID of the filter keyword entry in the database. + type: string + x-go-name: ID + keyword: + description: The text to be filtered. + example: fnord + type: string + x-go-name: Keyword + whole_word: + description: Should the filter consider word boundaries? + example: true + type: boolean + x-go-name: WholeWord + title: FilterKeyword represents text to filter within a v2 filter. + type: object + x-go-name: FilterKeyword + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + filterResult: + properties: + filter: + $ref: '#/definitions/filterV2' + keyword_matches: + description: The keywords within the filter that were matched. + items: + type: string + type: array + x-go-name: KeywordMatches + status_matches: + description: The status IDs within the filter that were matched. + items: + type: string + type: array + x-go-name: StatusMatches + title: FilterResult is returned along with a filtered status to explain why it was filtered. + type: object + x-go-name: FilterResult + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + filterStatus: + properties: + id: + description: The ID of the filter status entry in the database. + type: string + x-go-name: ID + phrase: + description: The status ID to be filtered. + type: string + x-go-name: StatusID + title: FilterStatus represents a single status to filter within a v2 filter. + type: object + x-go-name: FilterStatus + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model filterV1: description: |- Note that v1 filters are mapped to v2 filters and v2 filter keywords internally. @@ -1086,6 +1144,52 @@ definitions: type: object x-go-name: FilterV1 x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + filterV2: + description: v2 filters have names and can include multiple phrases and status IDs to filter. + properties: + context: + description: The contexts in which the filter should be applied. + example: + - home + - public + items: + $ref: '#/definitions/filterContext' + minItems: 1 + type: array + uniqueItems: true + x-go-name: Context + expires_at: + description: When the filter should no longer be applied. Null if the filter does not expire. + example: "2024-02-01T02:57:49Z" + type: string + x-go-name: ExpiresAt + filter_action: + $ref: '#/definitions/FilterAction' + id: + description: The ID of the filter in the database. + type: string + x-go-name: ID + keywords: + description: The keywords grouped under this filter. + items: + $ref: '#/definitions/filterKeyword' + type: array + x-go-name: Keywords + statuses: + description: The statuses grouped under this filter. + items: + $ref: '#/definitions/filterStatus' + type: array + x-go-name: Statuses + title: + description: The name of the filter. + example: Linux Words + type: string + x-go-name: Title + title: FilterV2 represents a user-defined filter for determining which statuses should not be shown to the user. + type: object + x-go-name: FilterV2 + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model headerFilter: properties: created_at: @@ -2118,6 +2222,12 @@ definitions: format: int64 type: integer x-go-name: FavouritesCount + filtered: + description: A list of filters that matched this status and why they matched, if there are any such filters. + items: + $ref: '#/definitions/filterResult' + type: array + x-go-name: Filtered id: description: ID of the status. example: 01FBVD42CQ3ZEEVMW180SBX03B @@ -2321,6 +2431,12 @@ definitions: format: int64 type: integer x-go-name: FavouritesCount + filtered: + description: A list of filters that matched this status and why they matched, if there are any such filters. + items: + $ref: '#/definitions/filterResult' + type: array + x-go-name: Filtered id: description: ID of the status. example: 01FBVD42CQ3ZEEVMW180SBX03B diff --git a/internal/api/model/filterresult.go b/internal/api/model/filterresult.go new file mode 100644 index 000000000..942c2124a --- /dev/null +++ b/internal/api/model/filterresult.go @@ -0,0 +1,34 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package model + +// FilterResult is returned along with a filtered status to explain why it was filtered. +// +// swagger:model filterResult +// +// --- +// tags: +// - filters +type FilterResult struct { + // The filter that was matched. + Filter FilterV2 `json:"filter"` + // The keywords within the filter that were matched. + KeywordMatches []string `json:"keyword_matches"` + // The status IDs within the filter that were matched. + StatusMatches []string `json:"status_matches"` +} diff --git a/internal/api/model/filterv2.go b/internal/api/model/filterv2.go new file mode 100644 index 000000000..797c97213 --- /dev/null +++ b/internal/api/model/filterv2.go @@ -0,0 +1,106 @@ +// 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 model + +// FilterV2 represents a user-defined filter for determining which statuses should not be shown to the user. +// v2 filters have names and can include multiple phrases and status IDs to filter. +// +// swagger:model filterV2 +// +// --- +// tags: +// - filters +type FilterV2 struct { + // The ID of the filter in the database. + ID string `json:"id"` + // The name of the filter. + // + // Example: Linux Words + Title string `json:"title"` + // The contexts in which the filter should be applied. + // + // Minimum items: 1 + // Unique: true + // Enum: + // - home + // - notifications + // - public + // - thread + // - account + // Example: ["home", "public"] + Context []FilterContext `json:"context"` + // When the filter should no longer be applied. Null if the filter does not expire. + // + // Example: 2024-02-01T02:57:49Z + ExpiresAt *string `json:"expires_at"` + // The action to be taken when a status matches this filter. + // Enum: + // - warn + // - hide + FilterAction FilterAction `json:"filter_action"` + // The keywords grouped under this filter. + Keywords []FilterKeyword `json:"keywords"` + // The statuses grouped under this filter. + Statuses []FilterStatus `json:"statuses"` +} + +// FilterAction is the action to apply to statuses matching a filter. +type FilterAction string + +const ( + // FilterActionNone filters should not exist, except internally, for partially constructed or invalid filters. + FilterActionNone FilterAction = "" + // FilterActionWarn filters will include this status in API results with a warning. + FilterActionWarn FilterAction = "warn" + // FilterActionHide filters will remove this status from API results. + FilterActionHide FilterAction = "hide" +) + +// FilterKeyword represents text to filter within a v2 filter. +// +// swagger:model filterKeyword +// +// --- +// tags: +// - filters +type FilterKeyword struct { + // The ID of the filter keyword entry in the database. + ID string `json:"id"` + // The text to be filtered. + // + // Example: fnord + Keyword string `json:"keyword"` + // Should the filter consider word boundaries? + // + // Example: true + WholeWord bool `json:"whole_word"` +} + +// FilterStatus represents a single status to filter within a v2 filter. +// +// swagger:model filterStatus +// +// --- +// tags: +// - filters +type FilterStatus struct { + // The ID of the filter status entry in the database. + ID string `json:"id"` + // The status ID to be filtered. + StatusID string `json:"phrase"` +} diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 9543303eb..9098cb59d 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -100,6 +100,8 @@ type Status struct { // so the user may redraft from the source text without the client having to reverse-engineer // the original text from the HTML content. Text string `json:"text,omitempty"` + // A list of filters that matched this status and why they matched, if there are any such filters. + Filtered []FilterResult `json:"filtered,omitempty"` // Additional fields not exposed via JSON // (used only internally for templating etc). diff --git a/internal/filter/status/status.go b/internal/filter/status/status.go new file mode 100644 index 000000000..7cf0a7a1e --- /dev/null +++ b/internal/filter/status/status.go @@ -0,0 +1,45 @@ +// 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 status represents status filters managed by the user through the API. +package status + +import ( + "errors" +) + +// ErrHideStatus indicates that a status has been filtered and should not be returned at all. +var ErrHideStatus = errors.New("hide status") + +// FilterContext determines the filters that apply to a given status or list of statuses. +type FilterContext string + +const ( + // FilterContextNone means no filters should be applied. + // There are no filters with this context; it's for internal use only. + FilterContextNone FilterContext = "" + // FilterContextHome means this status is being filtered as part of a home or list timeline. + FilterContextHome FilterContext = "home" + // FilterContextNotifications means this status is being filtered as part of the notifications timeline. + FilterContextNotifications FilterContext = "notifications" + // FilterContextPublic means this status is being filtered as part of a public or tag timeline. + FilterContextPublic FilterContext = "public" + // FilterContextThread means this status is being filtered as part of a thread's context. + FilterContextThread FilterContext = "thread" + // FilterContextAccount means this status is being filtered as part of an account's statuses. + FilterContextAccount FilterContext = "account" +) diff --git a/internal/processing/account/bookmarks.go b/internal/processing/account/bookmarks.go index 9cbc3db26..5618934ae 100644 --- a/internal/processing/account/bookmarks.go +++ b/internal/processing/account/bookmarks.go @@ -23,6 +23,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -74,7 +75,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode } // Convert the status. - item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount) + item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) if err != nil { log.Errorf(ctx, "error converting bookmarked status to api: %s", err) continue diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 0985bb4ef..8f0548371 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -24,6 +24,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -96,9 +97,15 @@ func (p *Processor) StatusesGet( return nil, gtserror.NewErrorInternalError(err) } + filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + for _, s := range filtered { // Convert filtered statuses to API statuses. - item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount) + item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index 308f5173f..bb46ee38c 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -24,6 +24,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -184,7 +185,7 @@ func (p *Processor) GetAPIStatus( apiStatus *apimodel.Status, errWithCode gtserror.WithCode, ) { - apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil) if err != nil { err = gtserror.Newf("error converting status: %w", err) return nil, gtserror.NewErrorInternalError(err) @@ -192,87 +193,6 @@ func (p *Processor) GetAPIStatus( return apiStatus, nil } -// GetVisibleAPIStatuses converts an array of gtsmodel.Status (inputted by next function) into -// API model statuses, checking first for visibility. Please note that all errors will be -// logged at ERROR level, but will not be returned. Callers are likely to run into show-stopping -// errors in the lead-up to this function, whereas calling this should not be a show-stopper. -func (p *Processor) GetVisibleAPIStatuses( - ctx context.Context, - requester *gtsmodel.Account, - next func(int) *gtsmodel.Status, - length int, -) []*apimodel.Status { - return p.getVisibleAPIStatuses(ctx, 3, requester, next, length) -} - -// GetVisibleAPIStatusesPaged is functionally equivalent to GetVisibleAPIStatuses(), -// except the statuses are returned as a converted slice of statuses as interface{}. -func (p *Processor) GetVisibleAPIStatusesPaged( - ctx context.Context, - requester *gtsmodel.Account, - next func(int) *gtsmodel.Status, - length int, -) []interface{} { - statuses := p.getVisibleAPIStatuses(ctx, 3, requester, next, length) - if len(statuses) == 0 { - return nil - } - items := make([]interface{}, len(statuses)) - for i, status := range statuses { - items[i] = status - } - return items -} - -func (p *Processor) getVisibleAPIStatuses( - ctx context.Context, - calldepth int, // used to skip wrapping func above these's names - requester *gtsmodel.Account, - next func(int) *gtsmodel.Status, - length int, -) []*apimodel.Status { - // Start new log entry with - // the above calling func's name. - l := log. - WithContext(ctx). - WithField("caller", log.Caller(calldepth+1)) - - // Preallocate slice according to expected length. - statuses := make([]*apimodel.Status, 0, length) - - for i := 0; i < length; i++ { - // Get next status. - status := next(i) - if status == nil { - continue - } - - // Check whether this status is visible to requesting account. - visible, err := p.filter.StatusVisible(ctx, requester, status) - if err != nil { - l.Errorf("error checking status visibility: %v", err) - continue - } - - if !visible { - // Not visible to requester. - continue - } - - // Convert the status to an API model representation. - apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requester) - if err != nil { - l.Errorf("error converting status: %v", err) - continue - } - - // Append API model to return slice. - statuses = append(statuses, apiStatus) - } - - return statuses -} - // InvalidateTimelinedStatus is a shortcut function for invalidating the cached // representation one status in the home timeline and all list timelines of the // given accountID. It should only be called in cases where a status update diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go index de91e5d51..196fef5fc 100644 --- a/internal/processing/search/util.go +++ b/internal/processing/search/util.go @@ -21,6 +21,7 @@ import ( "context" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -113,7 +114,7 @@ func (p *Processor) packageStatuses( continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) if err != nil { log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err) continue diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 57fd4005c..c05f3effd 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -23,6 +23,7 @@ import ( "strings" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -280,7 +281,15 @@ func TopoSort(apiStatuses []*apimodel.Status, targetAccountID string) { // ContextGet returns the context (previous and following posts) from the given status ID. func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { - return p.contextGet(ctx, requestingAccount, targetStatusID, p.converter.StatusToAPIStatus) + filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + convert := func(ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) { + return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters) + } + return p.contextGet(ctx, requestingAccount, targetStatusID, convert) } // WebContextGet is like ContextGet, but is explicitly diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go index 8814c966f..12971caa1 100644 --- a/internal/processing/stream/statusupdate_test.go +++ b/internal/processing/stream/statusupdate_test.go @@ -24,6 +24,7 @@ import ( "testing" "github.com/stretchr/testify/suite" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -39,7 +40,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { suite.NoError(errWithCode) editedStatus := suite.testStatuses["remote_account_1_status_1"] - apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account) + apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, statusfilter.FilterContextNotifications, nil) suite.NoError(err) suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome) diff --git a/internal/processing/timeline/faved.go b/internal/processing/timeline/faved.go index 205b15069..c3b0e1837 100644 --- a/internal/processing/timeline/faved.go +++ b/internal/processing/timeline/faved.go @@ -24,6 +24,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -54,7 +55,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go index d12dd98c4..e174b3428 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" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -98,7 +99,13 @@ func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converte return nil, err } - return converter.StatusToAPIStatus(ctx, status, requestingAccount) + filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) + return nil, err + } + + return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters) } } diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go index 7356d1978..60cdbac7a 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" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -110,7 +111,13 @@ func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converte return nil, err } - return converter.StatusToAPIStatus(ctx, status, requestingAccount) + filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) + return nil, err + } + + return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters) } } diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go index 42f708999..f99664d62 100644 --- a/internal/processing/timeline/notification.go +++ b/internal/processing/timeline/notification.go @@ -43,6 +43,12 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma return util.EmptyPageableResponse(), nil } + filters, err := p.state.DB.GetFiltersForAccountID(ctx, authed.Account.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", authed.Account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + var ( items = make([]interface{}, 0, count) nextMaxIDValue string @@ -70,7 +76,7 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma continue } - item, err := p.converter.NotificationToAPINotification(ctx, n) + item, err := p.converter.NotificationToAPINotification(ctx, n, filters) if err != nil { log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err) continue @@ -104,7 +110,13 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou return nil, gtserror.NewErrorNotFound(err) } - apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif) + filters, err := p.state.DB.GetFiltersForAccountID(ctx, account.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters) if err != nil { if errors.Is(err, db.ErrNoEntries) { return nil, gtserror.NewErrorNotFound(err) diff --git a/internal/processing/timeline/public.go b/internal/processing/timeline/public.go index 87de04f4a..a0e594629 100644 --- a/internal/processing/timeline/public.go +++ b/internal/processing/timeline/public.go @@ -24,6 +24,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -46,6 +47,16 @@ func (p *Processor) PublicTimelineGet( items = make([]any, 0, limit) ) + var filters []*gtsmodel.Filter + if requester != nil { + var err error + filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + } + // Try a few times to select appropriate public // statuses from the db, paging up or down to // reattempt if nothing suitable is found. @@ -87,7 +98,10 @@ outer: continue inner } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters) + if errors.Is(err, statusfilter.ErrHideStatus) { + continue + } if err != nil { log.Errorf(ctx, "error converting to api status: %v", err) continue inner diff --git a/internal/processing/timeline/tag.go b/internal/processing/timeline/tag.go index 45632ce06..5308cac59 100644 --- a/internal/processing/timeline/tag.go +++ b/internal/processing/timeline/tag.go @@ -24,6 +24,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -111,6 +112,12 @@ func (p *Processor) packageTagResponse( prevMinIDValue = statuses[0].ID ) + filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAcct.ID) + if err != nil { + err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAcct.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + for _, s := range statuses { timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s) if err != nil { @@ -122,7 +129,10 @@ func (p *Processor) packageTagResponse( continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters) + if errors.Is(err, statusfilter.ErrHideStatus) { + continue + } if err != nil { log.Errorf(ctx, "error converting to api status: %v", err) continue diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index c7c6e5c27..6a12ce043 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/messages" @@ -154,6 +155,8 @@ func (suite *FromClientAPITestSuite) statusJSON( ctx, status, requestingAccount, + statusfilter.FilterContextNone, + nil, ) if err != nil { suite.FailNow(err.Error()) @@ -258,7 +261,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { suite.FailNow("timed out waiting for new status notification") } - apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif) + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index be729fa7e..a31946cc8 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -467,7 +467,12 @@ func (s *Surface) Notify( unlock() // Stream notification to the user. - apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif) + filters, err := s.State.DB.GetFiltersForAccountID(ctx, targetAccount.ID) + if err != nil { + return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err) + } + + apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters) if err != nil { return gtserror.Newf("error converting notification to api representation: %w", err) } diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index 65b039939..32fdd66e2 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -22,6 +22,7 @@ import ( "errors" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -111,6 +112,11 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( continue } + filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID) + if err != nil { + return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) + } + // Add status to any relevant lists // for this follow, if applicable. s.listTimelineStatusForFollow( @@ -118,6 +124,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( status, follow, &errs, + filters, ) // Add status to home timeline for owner @@ -129,6 +136,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( follow.Account, status, stream.TimelineHome, + filters, ) if err != nil { errs.Appendf("error home timelining status: %w", err) @@ -180,6 +188,7 @@ func (s *Surface) listTimelineStatusForFollow( status *gtsmodel.Status, follow *gtsmodel.Follow, errs *gtserror.MultiError, + filters []*gtsmodel.Filter, ) { // To put this status in appropriate list timelines, // we need to get each listEntry that pertains to @@ -222,6 +231,7 @@ func (s *Surface) listTimelineStatusForFollow( follow.Account, status, stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list + filters, ); err != nil { errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) // implicit continue @@ -332,6 +342,7 @@ func (s *Surface) timelineStatus( account *gtsmodel.Account, status *gtsmodel.Status, streamType string, + filters []*gtsmodel.Filter, ) (bool, error) { // Ingest status into given timeline using provided function. if inserted, err := ingest(ctx, timelineID, status); err != nil { @@ -343,7 +354,12 @@ func (s *Surface) timelineStatus( } // The status was inserted so stream it to the user. - apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account) + apiStatus, err := s.Converter.StatusToAPIStatus(ctx, + status, + account, + statusfilter.FilterContextHome, + filters, + ) if err != nil { err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) return true, err @@ -457,6 +473,11 @@ func (s *Surface) timelineStatusUpdateForFollowers( continue } + filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID) + if err != nil { + return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) + } + // Add status to any relevant lists // for this follow, if applicable. s.listTimelineStatusUpdateForFollow( @@ -464,6 +485,7 @@ func (s *Surface) timelineStatusUpdateForFollowers( status, follow, &errs, + filters, ) // Add status to home timeline for owner @@ -473,6 +495,7 @@ func (s *Surface) timelineStatusUpdateForFollowers( follow.Account, status, stream.TimelineHome, + filters, ) if err != nil { errs.Appendf("error home timelining status: %w", err) @@ -490,6 +513,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow( status *gtsmodel.Status, follow *gtsmodel.Follow, errs *gtserror.MultiError, + filters []*gtsmodel.Filter, ) { // To put this status in appropriate list timelines, // we need to get each listEntry that pertains to @@ -530,6 +554,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow( follow.Account, status, stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list + filters, ); err != nil { errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) // implicit continue @@ -544,8 +569,13 @@ func (s *Surface) timelineStreamStatusUpdate( account *gtsmodel.Account, status *gtsmodel.Status, streamType string, + filters []*gtsmodel.Filter, ) error { - apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account) + apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters) + if errors.Is(err, statusfilter.ErrHideStatus) { + // Don't put this status in the stream. + return nil + } if err != nil { err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) return err diff --git a/internal/timeline/prepare.go b/internal/timeline/prepare.go index 07bde79fa..ec595ce42 100644 --- a/internal/timeline/prepare.go +++ b/internal/timeline/prepare.go @@ -24,6 +24,7 @@ import ( "codeberg.org/gruf/go-kv" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -121,6 +122,12 @@ func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID for e, entry := range toPrepare { prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID) if err != nil { + if errors.Is(err, statusfilter.ErrHideStatus) { + // This item has been filtered out by the requesting user's filters. + // Remove it and skip past it. + t.items.data.Remove(e) + continue + } if errors.Is(err, db.ErrNoEntries) { // ErrNoEntries means something has been deleted, // so we'll likely not be able to ever prepare this. diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go index 716a39c29..fc873a94b 100644 --- a/internal/typeutils/converter_test.go +++ b/internal/typeutils/converter_test.go @@ -473,16 +473,19 @@ const ( type TypeUtilsTestSuite struct { suite.Suite - db db.DB - state state.State - testAccounts map[string]*gtsmodel.Account - testStatuses map[string]*gtsmodel.Status - testAttachments map[string]*gtsmodel.MediaAttachment - testPeople map[string]vocab.ActivityStreamsPerson - testEmojis map[string]*gtsmodel.Emoji - testReports map[string]*gtsmodel.Report - testMentions map[string]*gtsmodel.Mention - testPollVotes map[string]*gtsmodel.PollVote + db db.DB + state state.State + testAccounts map[string]*gtsmodel.Account + testStatuses map[string]*gtsmodel.Status + testAttachments map[string]*gtsmodel.MediaAttachment + testPeople map[string]vocab.ActivityStreamsPerson + testEmojis map[string]*gtsmodel.Emoji + testReports map[string]*gtsmodel.Report + testMentions map[string]*gtsmodel.Mention + testPollVotes map[string]*gtsmodel.PollVote + testFilters map[string]*gtsmodel.Filter + testFilterKeywords map[string]*gtsmodel.FilterKeyword + testFilterStatues map[string]*gtsmodel.FilterStatus typeconverter *typeutils.Converter } @@ -506,6 +509,9 @@ func (suite *TypeUtilsTestSuite) SetupTest() { suite.testReports = testrig.NewTestReports() suite.testMentions = testrig.NewTestMentions() suite.testPollVotes = testrig.NewTestPollVotes() + suite.testFilters = testrig.NewTestFilters() + suite.testFilterKeywords = testrig.NewTestFilterKeywords() + suite.testFilterStatues = testrig.NewTestFilterStatuses() suite.typeconverter = typeutils.NewConverter(&suite.state) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index cbd4c6c5c..7a5572267 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -22,17 +22,21 @@ import ( "errors" "fmt" "math" + "regexp" "strconv" "strings" + "time" 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" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/language" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -684,12 +688,19 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor // (frontend) representation for serialization on the API. // // Requesting account can be nil. +// +// Filter context can be the empty string if these statuses are not being filtered. +// +// If there is a matching "hide" filter, the returned status will be nil with a ErrHideStatus error; +// callers need to handle that case by excluding it from results. func (c *Converter) StatusToAPIStatus( ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account, + filterContext statusfilter.FilterContext, + filters []*gtsmodel.Filter, ) (*apimodel.Status, error) { - apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount) + apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters) if err != nil { return nil, err } @@ -704,6 +715,142 @@ func (c *Converter) StatusToAPIStatus( return apiStatus, nil } +// statusToAPIFilterResults applies filters to a status and returns an API filter result object. +// The result may be nil if no filters matched. +// If the status should not be returned at all, it returns the ErrHideStatus error. +func (c *Converter) statusToAPIFilterResults( + ctx context.Context, + s *gtsmodel.Status, + requestingAccount *gtsmodel.Account, + filterContext statusfilter.FilterContext, + filters []*gtsmodel.Filter, +) ([]apimodel.FilterResult, error) { + if filterContext == "" || len(filters) == 0 || s.AccountID == requestingAccount.ID { + return nil, nil + } + + filterResults := make([]apimodel.FilterResult, 0, len(filters)) + + now := time.Now() + for _, filter := range filters { + if !filterAppliesInContext(filter, filterContext) { + // Filter doesn't apply to this context. + continue + } + if !filter.ExpiresAt.IsZero() && filter.ExpiresAt.Before(now) { + // Filter is expired. + continue + } + + // List all matching keywords. + keywordMatches := make([]string, 0, len(filter.Keywords)) + fields := filterableTextFields(s) + for _, filterKeyword := range filter.Keywords { + wholeWord := util.PtrValueOr(filterKeyword.WholeWord, false) + wordBreak := `` + if wholeWord { + wordBreak = `\b` + } + re, err := regexp.Compile(`(?i)` + wordBreak + regexp.QuoteMeta(filterKeyword.Keyword) + wordBreak) + if err != nil { + return nil, err + } + var isMatch bool + for _, field := range fields { + if re.MatchString(field) { + isMatch = true + break + } + } + if isMatch { + keywordMatches = append(keywordMatches, filterKeyword.Keyword) + } + } + + // A status has only one ID. Not clear why this is a list in the Mastodon API. + statusMatches := make([]string, 0, 1) + for _, filterStatus := range filter.Statuses { + if s.ID == filterStatus.StatusID { + statusMatches = append(statusMatches, filterStatus.StatusID) + break + } + } + + if len(keywordMatches) > 0 || len(statusMatches) > 0 { + switch filter.Action { + case gtsmodel.FilterActionWarn: + // Record what matched. + apiFilter, err := c.FilterToAPIFilterV2(ctx, filter) + if err != nil { + return nil, err + } + filterResults = append(filterResults, apimodel.FilterResult{ + Filter: *apiFilter, + KeywordMatches: keywordMatches, + StatusMatches: statusMatches, + }) + + case gtsmodel.FilterActionHide: + // Don't show this status. Immediate return. + return nil, statusfilter.ErrHideStatus + } + } + } + + return filterResults, nil +} + +// filterableTextFields returns all text from a status that we might want to filter on: +// - content +// - content warning +// - media descriptions +// - poll options +func filterableTextFields(s *gtsmodel.Status) []string { + fieldCount := 2 + len(s.Attachments) + if s.Poll != nil { + fieldCount += len(s.Poll.Options) + } + fields := make([]string, 0, fieldCount) + + if s.Content != "" { + fields = append(fields, text.SanitizeToPlaintext(s.Content)) + } + if s.ContentWarning != "" { + fields = append(fields, s.ContentWarning) + } + for _, attachment := range s.Attachments { + if attachment.Description != "" { + fields = append(fields, attachment.Description) + } + } + if s.Poll != nil { + for _, option := range s.Poll.Options { + if option != "" { + fields = append(fields, option) + } + } + } + + return fields +} + +// filterAppliesInContext returns whether a given filter applies in a given context. +func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool { + switch filterContext { + case statusfilter.FilterContextHome: + return util.PtrValueOr(filter.ContextHome, false) + case statusfilter.FilterContextNotifications: + return util.PtrValueOr(filter.ContextNotifications, false) + case statusfilter.FilterContextPublic: + return util.PtrValueOr(filter.ContextPublic, false) + case statusfilter.FilterContextThread: + return util.PtrValueOr(filter.ContextThread, false) + case statusfilter.FilterContextAccount: + return util.PtrValueOr(filter.ContextAccount, false) + } + return false +} + // StatusToWebStatus converts a gts model status into an // api representation suitable for serving into a web template. // @@ -713,7 +860,7 @@ func (c *Converter) StatusToWebStatus( s *gtsmodel.Status, requestingAccount *gtsmodel.Account, ) (*apimodel.Status, error) { - webStatus, err := c.statusToFrontend(ctx, s, requestingAccount) + webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil) if err != nil { return nil, err } @@ -815,6 +962,8 @@ func (c *Converter) statusToFrontend( ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account, + filterContext statusfilter.FilterContext, + filters []*gtsmodel.Filter, ) (*apimodel.Status, error) { // Try to populate status struct pointer fields. // We can continue in many cases of partial failure, @@ -913,7 +1062,11 @@ func (c *Converter) statusToFrontend( } if s.BoostOf != nil { - reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount) + reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters) + if errors.Is(err, statusfilter.ErrHideStatus) { + // If we'd hide the original status, hide the boost. + return nil, err + } if err != nil { return nil, gtserror.Newf("error converting boosted status: %w", err) } @@ -977,6 +1130,13 @@ func (c *Converter) statusToFrontend( s.URL = s.URI } + // Apply filters. + filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters) + if err != nil { + return nil, fmt.Errorf("error applying filters: %w", err) + } + apiStatus.Filtered = filterResults + return apiStatus, nil } @@ -1252,7 +1412,7 @@ func (c *Converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmod } // NotificationToAPINotification converts a gts notification into a api notification -func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) { +func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification, filters []*gtsmodel.Filter) (*apimodel.Notification, error) { if n.TargetAccount == nil { tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID) if err != nil { @@ -1293,7 +1453,7 @@ func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmod } var err error - apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount) + apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, filters) if err != nil { return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err) } @@ -1446,7 +1606,7 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo } } for _, s := range r.Statuses { - status, err := c.StatusToAPIStatus(ctx, s, requestingAccount) + status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil) if err != nil { return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err) } @@ -1687,6 +1847,55 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor } filter := filterKeyword.Filter + return &apimodel.FilterV1{ + // v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID. + ID: filterKeyword.ID, + Phrase: filterKeyword.Keyword, + Context: filterToAPIFilterContexts(filter), + WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false), + ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt), + Irreversible: filter.Action == gtsmodel.FilterActionHide, + }, nil +} + +// FilterToAPIFilterV2 converts one GTS model filter into an API v2 filter. +func (c *Converter) FilterToAPIFilterV2(ctx context.Context, filter *gtsmodel.Filter) (*apimodel.FilterV2, error) { + apiFilterKeywords := make([]apimodel.FilterKeyword, 0, len(filter.Keywords)) + for _, filterKeyword := range filter.Keywords { + apiFilterKeywords = append(apiFilterKeywords, apimodel.FilterKeyword{ + ID: filterKeyword.ID, + Keyword: filterKeyword.Keyword, + WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false), + }) + } + + apiFilterStatuses := make([]apimodel.FilterStatus, 0, len(filter.Keywords)) + for _, filterStatus := range filter.Statuses { + apiFilterStatuses = append(apiFilterStatuses, apimodel.FilterStatus{ + ID: filterStatus.ID, + StatusID: filterStatus.StatusID, + }) + } + + return &apimodel.FilterV2{ + ID: filter.ID, + Title: filter.Title, + Context: filterToAPIFilterContexts(filter), + ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt), + FilterAction: filterActionToAPIFilterAction(filter.Action), + Keywords: apiFilterKeywords, + Statuses: apiFilterStatuses, + }, nil +} + +func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string { + if expiresAt.IsZero() { + return nil + } + return util.Ptr(util.FormatISO8601(expiresAt)) +} + +func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext { apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues) if util.PtrValueOr(filter.ContextHome, false) { apiContexts = append(apiContexts, apimodel.FilterContextHome) @@ -1703,21 +1912,17 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor if util.PtrValueOr(filter.ContextAccount, false) { apiContexts = append(apiContexts, apimodel.FilterContextAccount) } + return apiContexts +} - var expiresAt *string - if !filter.ExpiresAt.IsZero() { - expiresAt = util.Ptr(util.FormatISO8601(filter.ExpiresAt)) +func filterActionToAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterAction { + switch m { + case gtsmodel.FilterActionWarn: + return apimodel.FilterActionWarn + case gtsmodel.FilterActionHide: + return apimodel.FilterActionHide } - - return &apimodel.FilterV1{ - // v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID. - ID: filterKeyword.ID, - Phrase: filterKeyword.Keyword, - Context: apiContexts, - WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false), - ExpiresAt: expiresAt, - Irreversible: filter.Action == gtsmodel.FilterActionHide, - }, nil + return apimodel.FilterActionNone } // convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied. diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 77ea80fcc..2c4f28a9b 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -427,7 +428,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { testStatus := suite.testStatuses["admin_account_status_1"] requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -537,11 +538,186 @@ 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() { + testStatus := suite.testStatuses["admin_account_status_1"] + testStatus.Content += " fnord" + testStatus.Text += " fnord" + requestingAccount := suite.testAccounts["local_account_1"] + expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"] + expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"] + expectedMatchingFilterKeyword.Filter = expectedMatchingFilter + expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword} + requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter} + apiStatus, err := suite.typeconverter.StatusToAPIStatus( + context.Background(), + testStatus, + requestingAccount, + statusfilter.FilterContextHome, + requestingAccountFilters, + ) + suite.NoError(err) + + b, err := json.MarshalIndent(apiStatus, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "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": "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.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "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": [ + { + "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.jpg", + "remote_url": null, + "preview_remote_url": null, + "meta": { + "original": { + "width": 1200, + "height": 630, + "size": "1200x630", + "aspect": 1.9047619 + }, + "small": { + "width": 256, + "height": 134, + "size": "256x134", + "aspect": 1.9104477 + }, + "focus": { + "x": 0, + "y": 0 + } + }, + "description": "Black and white image of some 50's style text saying: Welcome On Board", + "blurhash": "LNJRdVM{00Rj%Mayt7j[4nWBofRj" + } + ], + "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": [] + } + ] +}`, 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() { + testStatus := suite.testStatuses["admin_account_status_1"] + testStatus.Content += " fnord" + testStatus.Text += " fnord" + 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"] + expectedMatchingFilterKeyword.Filter = expectedMatchingFilter + expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword} + requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter} + _, err := suite.typeconverter.StatusToAPIStatus( + context.Background(), + testStatus, + requestingAccount, + statusfilter.FilterContextHome, + requestingAccountFilters, + ) + suite.ErrorIs(err, statusfilter.ErrHideStatus) +} + func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments() { testStatus := suite.testStatuses["remote_account_2_status_1"] requestingAccount := suite.testAccounts["admin_account"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -774,7 +950,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() *testStatus = *suite.testStatuses["admin_account_status_1"] testStatus.Language = "" requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ")