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