Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: filters v2 server-side warning/hiding #2793

Merged
merged 13 commits into from
May 6, 2024
34 changes: 34 additions & 0 deletions internal/api/model/filterresult.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// GoToSocial
// Copyright (C) GoToSocial Authors [email protected]
// 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"`
}
106 changes: 106 additions & 0 deletions internal/api/model/filterv2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// GoToSocial
// Copyright (C) GoToSocial Authors [email protected]
// 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"`
NyaaaWhatsUpDoc marked this conversation as resolved.
Show resolved Hide resolved
// 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 (
// 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"

FilterActionNumValues = 2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unused.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's currently unused, so I'll remove it for now., and add it back in with the rest of the public v2 filter API.

)

// 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"`
}
2 changes: 2 additions & 0 deletions internal/api/model/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
45 changes: 45 additions & 0 deletions internal/filter/custom/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// GoToSocial
// Copyright (C) GoToSocial Authors [email protected]
// 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 custom represents custom filters managed by the user through the API.
package custom
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably rename this package to user instead of custom, since they're user filters. I think it's implicitly understood they're kinda custom/unique to a user. It reads a bit odd in other import paths to see stuff like custom.FilterContextNone, user.FilterContextNone makes it a bit more obvious for me as to what kind of filter this is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, no problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user sounds like it could be a filter for users as well as a filter managed by a user, so we ended up going with status, imported as statusfilter to avoid conflicts with variables named 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"
)
3 changes: 2 additions & 1 deletion internal/processing/account/bookmarks.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
Expand Down Expand Up @@ -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, custom.FilterContextNone, nil)
if err != nil {
log.Errorf(ctx, "error converting bookmarked status to api: %s", err)
continue
Expand Down
9 changes: 8 additions & 1 deletion internal/processing/account/statuses.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (

apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
Expand Down Expand Up @@ -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, custom.FilterContextAccount, filters)
if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err)
continue
Expand Down
83 changes: 1 addition & 82 deletions internal/processing/common/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,95 +184,14 @@ 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, "", nil)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, "", nil)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, custom.FilterContextNone, nil)

if err != nil {
err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
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
Expand Down
2 changes: 1 addition & 1 deletion internal/processing/search/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (p *Processor) packageStatuses(
continue
}

apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, "", nil)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, "", nil)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, custom.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
Expand Down
11 changes: 10 additions & 1 deletion internal/processing/status/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"strings"

apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
Expand Down Expand Up @@ -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, custom.FilterContextThread, filters)
}
return p.contextGet(ctx, requestingAccount, targetStatusID, convert)
}

// WebContextGet is like ContextGet, but is explicitly
Expand Down
3 changes: 2 additions & 1 deletion internal/processing/stream/statusupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"testing"

"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/stream"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
Expand All @@ -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, custom.FilterContextNotifications, nil)
suite.NoError(err)

suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome)
Expand Down
Loading