diff options
Diffstat (limited to 'internal')
23 files changed, 739 insertions, 130 deletions
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 <http://www.gnu.org/licenses/>. + +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 <http://www.gnu.org/licenses/>. + +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 <http://www.gnu.org/licenses/>. + +// 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, "", "  ")  | 
