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 <tobi.smethurst@protonmail.com>
This commit is contained in:
Vyr Cossont 2024-05-06 04:49:08 -07:00 committed by GitHub
parent a0d066844f
commit 45f4afe60e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 855 additions and 130 deletions

View file

@ -1,5 +1,9 @@
basePath: / basePath: /
definitions: 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: InstanceConfigurationEmojis:
properties: properties:
emoji_size_limit: emoji_size_limit:
@ -1037,6 +1041,60 @@ definitions:
type: string type: string
x-go-name: FilterContext x-go-name: FilterContext
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model 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: filterV1:
description: |- description: |-
Note that v1 filters are mapped to v2 filters and v2 filter keywords internally. Note that v1 filters are mapped to v2 filters and v2 filter keywords internally.
@ -1086,6 +1144,52 @@ definitions:
type: object type: object
x-go-name: FilterV1 x-go-name: FilterV1
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model 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: headerFilter:
properties: properties:
created_at: created_at:
@ -2118,6 +2222,12 @@ definitions:
format: int64 format: int64
type: integer type: integer
x-go-name: FavouritesCount 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: id:
description: ID of the status. description: ID of the status.
example: 01FBVD42CQ3ZEEVMW180SBX03B example: 01FBVD42CQ3ZEEVMW180SBX03B
@ -2321,6 +2431,12 @@ definitions:
format: int64 format: int64
type: integer type: integer
x-go-name: FavouritesCount 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: id:
description: ID of the status. description: ID of the status.
example: 01FBVD42CQ3ZEEVMW180SBX03B example: 01FBVD42CQ3ZEEVMW180SBX03B

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -100,6 +100,8 @@ type Status struct {
// so the user may redraft from the source text without the client having to reverse-engineer // so the user may redraft from the source text without the client having to reverse-engineer
// the original text from the HTML content. // the original text from the HTML content.
Text string `json:"text,omitempty"` 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 // Additional fields not exposed via JSON
// (used only internally for templating etc). // (used only internally for templating etc).

View file

@ -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"
)

View file

@ -23,6 +23,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -74,7 +75,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode
} }
// Convert the status. // 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 { if err != nil {
log.Errorf(ctx, "error converting bookmarked status to api: %s", err) log.Errorf(ctx, "error converting bookmarked status to api: %s", err)
continue continue

View file

@ -24,6 +24,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -96,9 +97,15 @@ func (p *Processor) StatusesGet(
return nil, gtserror.NewErrorInternalError(err) 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 { for _, s := range filtered {
// Convert filtered statuses to API statuses. // 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 { if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err) log.Errorf(ctx, "error convering to api status: %v", err)
continue continue

View file

@ -24,6 +24,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -184,7 +185,7 @@ func (p *Processor) GetAPIStatus(
apiStatus *apimodel.Status, apiStatus *apimodel.Status,
errWithCode gtserror.WithCode, 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 { if err != nil {
err = gtserror.Newf("error converting status: %w", err) err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
@ -192,87 +193,6 @@ func (p *Processor) GetAPIStatus(
return apiStatus, nil 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 // InvalidateTimelinedStatus is a shortcut function for invalidating the cached
// representation one status in the home timeline and all list timelines of the // 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 // given accountID. It should only be called in cases where a status update

View file

@ -21,6 +21,7 @@
"context" "context"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -113,7 +114,7 @@ func (p *Processor) packageStatuses(
continue continue
} }
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount) apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil)
if err != nil { if err != nil {
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err) log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err)
continue continue

View file

@ -23,6 +23,7 @@
"strings" "strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util" "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. // 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) { 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 // WebContextGet is like ContextGet, but is explicitly

View file

@ -24,6 +24,7 @@
"testing" "testing"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/stream"
"github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/typeutils"
) )
@ -39,7 +40,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
suite.NoError(errWithCode) suite.NoError(errWithCode)
editedStatus := suite.testStatuses["remote_account_1_status_1"] 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.NoError(err)
suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome) suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome)

View file

@ -24,6 +24,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
@ -54,7 +55,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma
continue 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 { if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err) log.Errorf(ctx, "error convering to api status: %v", err)
continue continue

View file

@ -23,6 +23,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -98,7 +99,13 @@ func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err 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)
} }
} }

View file

@ -23,6 +23,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -110,7 +111,13 @@ func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err 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)
} }
} }

View file

@ -43,6 +43,12 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
return util.EmptyPageableResponse(), nil 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 ( var (
items = make([]interface{}, 0, count) items = make([]interface{}, 0, count)
nextMaxIDValue string nextMaxIDValue string
@ -70,7 +76,7 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
continue continue
} }
item, err := p.converter.NotificationToAPINotification(ctx, n) item, err := p.converter.NotificationToAPINotification(ctx, n, filters)
if err != nil { if err != nil {
log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err) log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
continue continue
@ -104,7 +110,13 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou
return nil, gtserror.NewErrorNotFound(err) 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 err != nil {
if errors.Is(err, db.ErrNoEntries) { if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err) return nil, gtserror.NewErrorNotFound(err)

View file

@ -24,6 +24,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -46,6 +47,16 @@ func (p *Processor) PublicTimelineGet(
items = make([]any, 0, limit) 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 // Try a few times to select appropriate public
// statuses from the db, paging up or down to // statuses from the db, paging up or down to
// reattempt if nothing suitable is found. // reattempt if nothing suitable is found.
@ -87,7 +98,10 @@ func (p *Processor) PublicTimelineGet(
continue inner 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 { if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err) log.Errorf(ctx, "error converting to api status: %v", err)
continue inner continue inner

View file

@ -24,6 +24,7 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -111,6 +112,12 @@ func (p *Processor) packageTagResponse(
prevMinIDValue = statuses[0].ID 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 { for _, s := range statuses {
timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s) timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s)
if err != nil { if err != nil {
@ -122,7 +129,10 @@ func (p *Processor) packageTagResponse(
continue 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 { if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err) log.Errorf(ctx, "error converting to api status: %v", err)
continue continue

View file

@ -28,6 +28,7 @@
"github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/messages"
@ -154,6 +155,8 @@ func (suite *FromClientAPITestSuite) statusJSON(
ctx, ctx,
status, status,
requestingAccount, requestingAccount,
statusfilter.FilterContextNone,
nil,
) )
if err != nil { if err != nil {
suite.FailNow(err.Error()) suite.FailNow(err.Error())
@ -258,7 +261,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
suite.FailNow("timed out waiting for new status notification") 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 { if err != nil {
suite.FailNow(err.Error()) suite.FailNow(err.Error())
} }

View file

@ -467,7 +467,12 @@ func (s *Surface) Notify(
unlock() unlock()
// Stream notification to the user. // 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 { if err != nil {
return gtserror.Newf("error converting notification to api representation: %w", err) return gtserror.Newf("error converting notification to api representation: %w", err)
} }

View file

@ -22,6 +22,7 @@
"errors" "errors"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -111,6 +112,11 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
continue 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 // Add status to any relevant lists
// for this follow, if applicable. // for this follow, if applicable.
s.listTimelineStatusForFollow( s.listTimelineStatusForFollow(
@ -118,6 +124,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
status, status,
follow, follow,
&errs, &errs,
filters,
) )
// Add status to home timeline for owner // Add status to home timeline for owner
@ -129,6 +136,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
follow.Account, follow.Account,
status, status,
stream.TimelineHome, stream.TimelineHome,
filters,
) )
if err != nil { if err != nil {
errs.Appendf("error home timelining status: %w", err) errs.Appendf("error home timelining status: %w", err)
@ -180,6 +188,7 @@ func (s *Surface) listTimelineStatusForFollow(
status *gtsmodel.Status, status *gtsmodel.Status,
follow *gtsmodel.Follow, follow *gtsmodel.Follow,
errs *gtserror.MultiError, errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
) { ) {
// To put this status in appropriate list timelines, // To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to // we need to get each listEntry that pertains to
@ -222,6 +231,7 @@ func (s *Surface) listTimelineStatusForFollow(
follow.Account, follow.Account,
status, status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
); err != nil { ); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue // implicit continue
@ -332,6 +342,7 @@ func (s *Surface) timelineStatus(
account *gtsmodel.Account, account *gtsmodel.Account,
status *gtsmodel.Status, status *gtsmodel.Status,
streamType string, streamType string,
filters []*gtsmodel.Filter,
) (bool, error) { ) (bool, error) {
// Ingest status into given timeline using provided function. // Ingest status into given timeline using provided function.
if inserted, err := ingest(ctx, timelineID, status); err != nil { 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. // 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 { if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
return true, err return true, err
@ -457,6 +473,11 @@ func (s *Surface) timelineStatusUpdateForFollowers(
continue 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 // Add status to any relevant lists
// for this follow, if applicable. // for this follow, if applicable.
s.listTimelineStatusUpdateForFollow( s.listTimelineStatusUpdateForFollow(
@ -464,6 +485,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
status, status,
follow, follow,
&errs, &errs,
filters,
) )
// Add status to home timeline for owner // Add status to home timeline for owner
@ -473,6 +495,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
follow.Account, follow.Account,
status, status,
stream.TimelineHome, stream.TimelineHome,
filters,
) )
if err != nil { if err != nil {
errs.Appendf("error home timelining status: %w", err) errs.Appendf("error home timelining status: %w", err)
@ -490,6 +513,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
status *gtsmodel.Status, status *gtsmodel.Status,
follow *gtsmodel.Follow, follow *gtsmodel.Follow,
errs *gtserror.MultiError, errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
) { ) {
// To put this status in appropriate list timelines, // To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to // we need to get each listEntry that pertains to
@ -530,6 +554,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
follow.Account, follow.Account,
status, status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
); err != nil { ); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue // implicit continue
@ -544,8 +569,13 @@ func (s *Surface) timelineStreamStatusUpdate(
account *gtsmodel.Account, account *gtsmodel.Account,
status *gtsmodel.Status, status *gtsmodel.Status,
streamType string, streamType string,
filters []*gtsmodel.Filter,
) error { ) 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 { if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
return err return err

View file

@ -24,6 +24,7 @@
"codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log" "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 { for e, entry := range toPrepare {
prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID) prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID)
if err != nil { 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) { if errors.Is(err, db.ErrNoEntries) {
// ErrNoEntries means something has been deleted, // ErrNoEntries means something has been deleted,
// so we'll likely not be able to ever prepare this. // so we'll likely not be able to ever prepare this.

View file

@ -483,6 +483,9 @@ type TypeUtilsTestSuite struct {
testReports map[string]*gtsmodel.Report testReports map[string]*gtsmodel.Report
testMentions map[string]*gtsmodel.Mention testMentions map[string]*gtsmodel.Mention
testPollVotes map[string]*gtsmodel.PollVote testPollVotes map[string]*gtsmodel.PollVote
testFilters map[string]*gtsmodel.Filter
testFilterKeywords map[string]*gtsmodel.FilterKeyword
testFilterStatues map[string]*gtsmodel.FilterStatus
typeconverter *typeutils.Converter typeconverter *typeutils.Converter
} }
@ -506,6 +509,9 @@ func (suite *TypeUtilsTestSuite) SetupTest() {
suite.testReports = testrig.NewTestReports() suite.testReports = testrig.NewTestReports()
suite.testMentions = testrig.NewTestMentions() suite.testMentions = testrig.NewTestMentions()
suite.testPollVotes = testrig.NewTestPollVotes() suite.testPollVotes = testrig.NewTestPollVotes()
suite.testFilters = testrig.NewTestFilters()
suite.testFilterKeywords = testrig.NewTestFilterKeywords()
suite.testFilterStatues = testrig.NewTestFilterStatuses()
suite.typeconverter = typeutils.NewConverter(&suite.state) suite.typeconverter = typeutils.NewConverter(&suite.state)
testrig.StandardDBSetup(suite.db, nil) testrig.StandardDBSetup(suite.db, nil)

View file

@ -22,17 +22,21 @@
"errors" "errors"
"fmt" "fmt"
"math" "math"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/language" "github.com/superseriousbusiness/gotosocial/internal/language"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util" "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. // (frontend) representation for serialization on the API.
// //
// Requesting account can be nil. // 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( func (c *Converter) StatusToAPIStatus(
ctx context.Context, ctx context.Context,
s *gtsmodel.Status, s *gtsmodel.Status,
requestingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
) (*apimodel.Status, error) { ) (*apimodel.Status, error) {
apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount) apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -704,6 +715,142 @@ func (c *Converter) StatusToAPIStatus(
return apiStatus, nil 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 // StatusToWebStatus converts a gts model status into an
// api representation suitable for serving into a web template. // api representation suitable for serving into a web template.
// //
@ -713,7 +860,7 @@ func (c *Converter) StatusToWebStatus(
s *gtsmodel.Status, s *gtsmodel.Status,
requestingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account,
) (*apimodel.Status, error) { ) (*apimodel.Status, error) {
webStatus, err := c.statusToFrontend(ctx, s, requestingAccount) webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -815,6 +962,8 @@ func (c *Converter) statusToFrontend(
ctx context.Context, ctx context.Context,
s *gtsmodel.Status, s *gtsmodel.Status,
requestingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
) (*apimodel.Status, error) { ) (*apimodel.Status, error) {
// Try to populate status struct pointer fields. // Try to populate status struct pointer fields.
// We can continue in many cases of partial failure, // We can continue in many cases of partial failure,
@ -913,7 +1062,11 @@ func (c *Converter) statusToFrontend(
} }
if s.BoostOf != nil { 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 { if err != nil {
return nil, gtserror.Newf("error converting boosted status: %w", err) return nil, gtserror.Newf("error converting boosted status: %w", err)
} }
@ -977,6 +1130,13 @@ func (c *Converter) statusToFrontend(
s.URL = s.URI 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 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 // 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 { if n.TargetAccount == nil {
tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID) tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID)
if err != nil { if err != nil {
@ -1293,7 +1453,7 @@ func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmod
} }
var err error 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 { if err != nil {
return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err) 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 { 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 { if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err) 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 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) apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues)
if util.PtrValueOr(filter.ContextHome, false) { if util.PtrValueOr(filter.ContextHome, false) {
apiContexts = append(apiContexts, apimodel.FilterContextHome) apiContexts = append(apiContexts, apimodel.FilterContextHome)
@ -1703,21 +1912,17 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
if util.PtrValueOr(filter.ContextAccount, false) { if util.PtrValueOr(filter.ContextAccount, false) {
apiContexts = append(apiContexts, apimodel.FilterContextAccount) apiContexts = append(apiContexts, apimodel.FilterContextAccount)
} }
return apiContexts
var expiresAt *string
if !filter.ExpiresAt.IsZero() {
expiresAt = util.Ptr(util.FormatISO8601(filter.ExpiresAt))
} }
return &apimodel.FilterV1{ func filterActionToAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterAction {
// v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID. switch m {
ID: filterKeyword.ID, case gtsmodel.FilterActionWarn:
Phrase: filterKeyword.Keyword, return apimodel.FilterActionWarn
Context: apiContexts, case gtsmodel.FilterActionHide:
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false), return apimodel.FilterActionHide
ExpiresAt: expiresAt, }
Irreversible: filter.Action == gtsmodel.FilterActionHide, return apimodel.FilterActionNone
}, nil
} }
// convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied. // convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied.

View file

@ -25,6 +25,7 @@
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "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/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -427,7 +428,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"] testStatus := suite.testStatuses["admin_account_status_1"]
requestingAccount := suite.testAccounts["local_account_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) suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ") b, err := json.MarshalIndent(apiStatus, "", " ")
@ -537,11 +538,186 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
}`, string(b)) }`, 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() { func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments() {
testStatus := suite.testStatuses["remote_account_2_status_1"] testStatus := suite.testStatuses["remote_account_2_status_1"]
requestingAccount := suite.testAccounts["admin_account"] 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) suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ") b, err := json.MarshalIndent(apiStatus, "", " ")
@ -774,7 +950,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
*testStatus = *suite.testStatuses["admin_account_status_1"] *testStatus = *suite.testStatuses["admin_account_status_1"]
testStatus.Language = "" testStatus.Language = ""
requestingAccount := suite.testAccounts["local_account_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) suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ") b, err := json.MarshalIndent(apiStatus, "", " ")