[feature] Filters v1 (#2594)

* Implement client-side v1 filters

* Exclude linter false positives

* Update test/envparsing.sh

* Fix minor Swagger, style, and Bun usage issues

* Regenerate Swagger

* De-generify filter keywords

* Remove updating filter statuses

This is an operation that the Mastodon v2 filter API doesn't actually have, because filter statuses, unlike keywords, don't have options: the only info they contain is the status ID to be filtered.

* Add a test for filter statuses specifically

* De-generify filter statuses

* Inline FilterEntry

* Use vertical style for Bun operations consistently

* Add comment on Filter DB interface

* Remove GoLand linter control comments

Our existing linters should catch these, or they don't matter very much

* Reduce memory ratio for filters
This commit is contained in:
Vyr Cossont 2024-03-06 02:15:58 -08:00 committed by GitHub
parent 7bc536d1f7
commit 61a2b91f45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 4672 additions and 52 deletions

View file

@ -83,3 +83,12 @@ linters-settings:
# Enable all checks, but disable SA1012: nil context passing.
# See: https://staticcheck.io/docs/configuration/options/#checks
checks: ["all", "-SA1012"]
issues:
exclude-rules:
# Exclude VSCode custom folding region comments in files that use them.
# Already fixed in go-critic and can be removed next time go-critic is updated.
- linters:
- gocritic
path: internal/db/filter.go
text: 'commentFormatting: put a space between `//` and comment text'

View file

@ -1209,6 +1209,61 @@ definitions:
type: object
x-go-name: Field
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
filterContext:
description: v1 and v2 filter APIs use the same set of contexts.
title: FilterContext represents the context in which to apply a filter.
type: string
x-go-name: FilterContext
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.
If whole_word is true, client app should do:
Define word constituent character for your app. In the official implementation, its [A-Za-z0-9_] in JavaScript, and [[:word:]] in Ruby.
Ruby uses the POSIX character class (Letter | Mark | Decimal_Number | Connector_Punctuation).
If the phrase starts with a word character, and if the previous character before matched range is a word character, its matched range should be treated to not match.
If the phrase ends with a word character, and if the next character after matched range is a word character, its matched range should be treated to not match.
Please check app/javascript/mastodon/selectors/index.js and app/lib/feed_manager.rb in the Mastodon source code for more details.
properties:
context:
description: The contexts in which the filter should be applied.
example:
- home
- public
items:
$ref: '#/definitions/filterContext'
minLength: 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
id:
description: The ID of the filter in the database.
type: string
x-go-name: ID
irreversible:
description: Should matching entities be removed from the user's timelines/views, instead of hidden?
example: false
type: boolean
x-go-name: Irreversible
phrase:
description: The text to be filtered.
example: fnord
type: string
x-go-name: Phrase
whole_word:
description: Should the filter consider word boundaries?
example: true
type: boolean
x-go-name: WholeWord
title: FilterV1 represents a user-defined filter for determining which statuses should not be shown to the user.
type: object
x-go-name: FilterV1
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
headerFilterCreateRequest:
properties:
header:
@ -5570,6 +5625,246 @@ paths:
summary: Get an array of all hashtags that you currently have featured on your profile.
tags:
- featured_tags
/api/v1/filters:
get:
operationId: filtersV1Get
produces:
- application/json
responses:
"200":
description: Requested filters.
schema:
$ref: '#/definitions/filterV1'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- read:filters
summary: Get all filters for the authenticated account.
tags:
- filters
post:
consumes:
- application/json
- application/xml
- application/x-www-form-urlencoded
operationId: filterV1Post
parameters:
- description: The text to be filtered.
example: fnord
in: formData
maxLength: 40
name: phrase
required: true
type: string
- description: The contexts in which the filter should be applied.
enum:
- home
- notifications
- public
- thread
- account
example:
- home
- public
in: formData
items:
$ref: '#/definitions/filterContext'
minLength: 1
name: context
required: true
type: array
uniqueItems: true
- description: Number of seconds from now that the filter should expire. If omitted, filter never expires.
example: 86400
in: formData
name: expires_in
type: number
- default: false
description: Should matching entities be removed from the user's timelines/views, instead of hidden? Not supported yet.
example: false
in: formData
name: irreversible
type: boolean
- default: false
description: Should the filter consider word boundaries?
example: true
in: formData
name: whole_word
type: boolean
produces:
- application/json
responses:
"200":
description: New filter.
schema:
$ref: '#/definitions/filterV1'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"422":
description: unprocessable content
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:filters
summary: Create a single filter.
tags:
- filters
/api/v1/filters/{id}:
delete:
operationId: filterV1Delete
parameters:
- description: ID of the list
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: filter deleted
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:filters
summary: Delete a single filter with the given ID.
tags:
- filters
get:
operationId: filterV1Get
parameters:
- description: ID of the filter
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Requested filter.
schema:
$ref: '#/definitions/filterV1'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- read:filters
summary: Get a single filter with the given ID.
tags:
- filters
put:
consumes:
- application/json
- application/xml
- application/x-www-form-urlencoded
operationId: filterV1Put
parameters:
- description: ID of the filter.
in: path
name: id
required: true
type: string
- description: The text to be filtered.
example: fnord
in: formData
maxLength: 40
name: phrase
required: true
type: string
- description: The contexts in which the filter should be applied.
enum:
- home
- notifications
- public
- thread
- account
example:
- home
- public
in: formData
items:
$ref: '#/definitions/filterContext'
minLength: 1
name: context
required: true
type: array
uniqueItems: true
- description: Number of seconds from now that the filter should expire. If omitted, filter never expires.
example: 86400
in: formData
name: expires_in
type: number
- default: false
description: Should matching entities be removed from the user's timelines/views, instead of hidden? Not supported yet.
example: false
in: formData
name: irreversible
type: boolean
- default: false
description: Should the filter consider word boundaries?
example: true
in: formData
name: whole_word
type: boolean
produces:
- application/json
responses:
"200":
description: Updated filter.
schema:
$ref: '#/definitions/filterV1'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"422":
description: unprocessable content
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:filters
summary: Update a single filter with the given ID.
tags:
- filters
/api/v1/follow_requests:
get:
description: |-
@ -7971,6 +8266,7 @@ securityDefinitions:
read:blocks: grant read access to blocks
read:custom_emojis: grant read access to custom_emojis
read:favourites: grant read access to favourites
read:filters: grant read access to filters
read:follows: grant read access to follows
read:lists: grant read access to lists
read:media: grant read access to media
@ -7983,6 +8279,7 @@ securityDefinitions:
write: grants write access to everything
write:accounts: grants write access to accounts
write:blocks: grants write access to blocks
write:filters: grants write access to filters
write:follows: grants write access to follows
write:lists: grants write access to lists
write:media: grants write access to media

View file

@ -36,6 +36,7 @@
// read:blocks: grant read access to blocks
// read:custom_emojis: grant read access to custom_emojis
// read:favourites: grant read access to favourites
// read:filters: grant read access to filters
// read:follows: grant read access to follows
// read:lists: grant read access to lists
// read:media: grant read access to media
@ -48,6 +49,7 @@
// write: grants write access to everything
// write:accounts: grants write access to accounts
// write:blocks: grants write access to blocks
// write:filters: grants write access to filters
// write:follows: grants write access to follows
// write:lists: grants write access to lists
// write:media: grants write access to media

View file

@ -29,7 +29,7 @@
"github.com/superseriousbusiness/gotosocial/internal/api/client/customemojis"
"github.com/superseriousbusiness/gotosocial/internal/api/client/favourites"
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
filter "github.com/superseriousbusiness/gotosocial/internal/api/client/filters"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
"github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
@ -62,7 +62,7 @@ type Client struct {
customEmojis *customemojis.Module // api/v1/custom_emojis
favourites *favourites.Module // api/v1/favourites
featuredTags *featuredtags.Module // api/v1/featured_tags
filters *filter.Module // api/v1/filters
filtersV1 *filtersV1.Module // api/v1/filters
followRequests *followrequests.Module // api/v1/follow_requests
instance *instance.Module // api/v1/instance
lists *lists.Module // api/v1/lists
@ -104,7 +104,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.customEmojis.Route(h)
c.favourites.Route(h)
c.featuredTags.Route(h)
c.filters.Route(h)
c.filtersV1.Route(h)
c.followRequests.Route(h)
c.instance.Route(h)
c.lists.Route(h)
@ -134,7 +134,7 @@ func NewClient(db db.DB, p *processing.Processor) *Client {
customEmojis: customemojis.New(p),
favourites: favourites.New(p),
featuredTags: featuredtags.New(p),
filters: filter.New(p),
filtersV1: filtersV1.New(p),
followRequests: followrequests.New(p),
instance: instance.New(p),
lists: lists.New(p),

View file

@ -15,20 +15,23 @@
// 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 filter
package v1
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"net/http"
)
const (
// BasePath is the base path for serving the filters API, minus the 'api' prefix
BasePath = "/v1/filters"
// BasePathWithID is the base path with the ID key in it, for operations on an existing filter.
BasePathWithID = BasePath + "/:" + apiutil.IDKey
)
// Module implements APIs for client-side aka "v1" filtering.
type Module struct {
processor *processing.Processor
}
@ -41,4 +44,8 @@ func New(processor *processing.Processor) *Module {
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodGet, BasePath, m.FiltersGETHandler)
attachHandler(http.MethodPost, BasePath, m.FilterPOSTHandler)
attachHandler(http.MethodGet, BasePathWithID, m.FilterGETHandler)
attachHandler(http.MethodPut, BasePathWithID, m.FilterPUTHandler)
attachHandler(http.MethodDelete, BasePathWithID, m.FilterDELETEHandler)
}

View file

@ -0,0 +1,117 @@
// 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 v1_test
import (
"github.com/stretchr/testify/suite"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
"testing"
)
type FiltersTestSuite struct {
suite.Suite
db db.DB
storage *storage.Driver
mediaManager *media.Manager
federator *federation.Federator
processor *processing.Processor
emailSender email.Sender
sentEmails map[string]string
state state.State
// standard suite models
testTokens map[string]*gtsmodel.Token
testClients map[string]*gtsmodel.Client
testApplications map[string]*gtsmodel.Application
testUsers map[string]*gtsmodel.User
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testFilters map[string]*gtsmodel.Filter
testFilterKeywords map[string]*gtsmodel.FilterKeyword
testFilterStatuses map[string]*gtsmodel.FilterStatus
// module being tested
filtersModule *filtersV1.Module
}
func (suite *FiltersTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testStatuses = testrig.NewTestStatuses()
suite.testFilters = testrig.NewTestFilters()
suite.testFilterKeywords = testrig.NewTestFilterKeywords()
suite.testFilterStatuses = testrig.NewTestFilterStatuses()
}
func (suite *FiltersTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.StartNoopWorkers(&suite.state)
testrig.InitTestConfig()
config.Config(func(cfg *config.Configuration) {
cfg.WebAssetBaseDir = "../../../../../web/assets/"
cfg.WebTemplateBaseDir = "../../../../../web/templates/"
})
testrig.InitTestLog()
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage
testrig.StartTimelines(
&suite.state,
visibility.NewFilter(&suite.state),
typeutils.NewConverter(&suite.state),
)
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails)
suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
suite.filtersModule = filtersV1.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../../testrig/media")
}
func (suite *FiltersTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
testrig.StandardStorageTeardown(suite.storage)
testrig.StopWorkers(&suite.state)
}
func TestFiltersTestSuite(t *testing.T) {
suite.Run(t, new(FiltersTestSuite))
}

View file

@ -0,0 +1,90 @@
// 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 v1
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// FilterDELETEHandler swagger:operation DELETE /api/v1/filters/{id} filterV1Delete
//
// Delete a single filter with the given ID.
//
// ---
// tags:
// - filters
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the list
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// description: filter deleted
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FilterDELETEHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
errWithCode = m.processor.FiltersV1().Delete(c.Request.Context(), authed.Account, id)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiutil.EmptyJSONObject)
}

View file

@ -0,0 +1,112 @@
// 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 v1_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) deleteFilter(
filterKeywordID string,
expectedHTTPStatus int,
expectedBody string,
) error {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// create the request
ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath+"/"+filterKeywordID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterKeywordID)
// trigger the handler
suite.filtersModule.FilterDELETEHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return errs.Combine()
}
resp := &struct{}{}
if err := json.Unmarshal(b, resp); err != nil {
return err
}
return nil
}
func (suite *FiltersTestSuite) TestDeleteFilter() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
err := suite.deleteFilter(id, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() {
id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestDeleteNonexistentFilter() {
id := "not_even_a_real_ULID"
err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,93 @@
// 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 v1
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// FilterGETHandler swagger:operation GET /api/v1/filters/{id} filterV1Get
//
// Get a single filter with the given ID.
//
// ---
// tags:
// - filters
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the filter
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - read:filters
//
// responses:
// '200':
// name: filter
// description: Requested filter.
// schema:
// "$ref": "#/definitions/filterV1"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FilterGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV1().Get(c.Request.Context(), authed.Account, id)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiFilter)
}

View file

@ -0,0 +1,121 @@
// 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 v1_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) getFilter(
filterKeywordID string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterV1, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// create the request
ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath+"/"+filterKeywordID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterKeywordID)
// trigger the handler
suite.filtersModule.FilterGETHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return nil, errs.Combine()
}
resp := &apimodel.FilterV1{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestGetFilter() {
// v1 filters map to individual filter keywords, but also use the settings of the associated filter.
expectedFilterGtsModel := suite.testFilters["local_account_1_filter_1"]
expectedFilterKeywordGtsModel := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
filter, err := suite.getFilter(expectedFilterKeywordGtsModel.ID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.NotEmpty(filter)
suite.Equal(expectedFilterGtsModel.Action == gtsmodel.FilterActionHide, filter.Irreversible)
suite.Equal(expectedFilterKeywordGtsModel.ID, filter.ID)
suite.Equal(expectedFilterKeywordGtsModel.Keyword, filter.Phrase)
}
func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() {
id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
_, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestGetNonexistentFilter() {
id := "not_even_a_real_ULID"
_, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,147 @@
// 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 v1
import (
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// FilterPOSTHandler swagger:operation POST /api/v1/filters filterV1Post
//
// Create a single filter.
//
// ---
// tags:
// - filters
//
// consumes:
// - application/json
// - application/xml
// - application/x-www-form-urlencoded
//
// produces:
// - application/json
//
// parameters:
// -
// name: phrase
// in: formData
// required: true
// description: The text to be filtered.
// maxLength: 40
// type: string
// example: "fnord"
// -
// name: context
// in: formData
// required: true
// description: The contexts in which the filter should be applied.
// enum:
// - home
// - notifications
// - public
// - thread
// - account
// example:
// - home
// - public
// items:
// $ref: '#/definitions/filterContext'
// minLength: 1
// type: array
// uniqueItems: true
// -
// name: expires_in
// in: formData
// description: Number of seconds from now that the filter should expire. If omitted, filter never expires.
// type: number
// example: 86400
// -
// name: irreversible
// in: formData
// description: Should matching entities be removed from the user's timelines/views, instead of hidden? Not supported yet.
// type: boolean
// default: false
// example: false
// -
// name: whole_word
// in: formData
// description: Should the filter consider word boundaries?
// type: boolean
// default: false
// example: true
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// name: filter
// description: New filter.
// schema:
// "$ref": "#/definitions/filterV1"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '422':
// description: unprocessable content
// '500':
// description: internal server error
func (m *Module) FilterPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := &apimodel.FilterCreateUpdateRequestV1{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateNormalizeCreateUpdateFilter(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV1().Create(c.Request.Context(), authed.Account, form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiFilter)
}

View file

@ -0,0 +1,239 @@
// 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 v1_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) postFilter(
phrase *string,
context *[]string,
irreversible *bool,
wholeWord *bool,
expiresIn *int,
requestJson *string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterV1, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// create the request
ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath, nil)
ctx.Request.Header.Set("accept", "application/json")
if requestJson != nil {
ctx.Request.Header.Set("content-type", "application/json")
ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson))
} else {
ctx.Request.Form = make(url.Values)
if phrase != nil {
ctx.Request.Form["phrase"] = []string{*phrase}
}
if context != nil {
ctx.Request.Form["context[]"] = *context
}
if irreversible != nil {
ctx.Request.Form["irreversible"] = []string{strconv.FormatBool(*irreversible)}
}
if wholeWord != nil {
ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)}
}
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
}
}
// trigger the handler
suite.filtersModule.FilterPOSTHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return nil, errs.Combine()
}
resp := &apimodel.FilterV1{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestPostFilterFull() {
phrase := "GNU/Linux"
context := []string{"home", "public"}
irreversible := false
wholeWord := true
expiresIn := 86400
filter, err := suite.postFilter(&phrase, &context, &irreversible, &wholeWord, &expiresIn, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(phrase, filter.Phrase)
filterContext := make([]string, 0, len(filter.Context))
for _, c := range filter.Context {
filterContext = append(filterContext, string(c))
}
suite.ElementsMatch(context, filterContext)
suite.Equal(irreversible, filter.Irreversible)
suite.Equal(wholeWord, filter.WholeWord)
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
}
func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
// Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "expires_in".
requestJson := `{
"phrase":"GNU/Linux",
"context": ["home", "public"],
"irreversible": false,
"whole_word": true,
"expires_in": 86400.1
}`
filter, err := suite.postFilter(nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal("GNU/Linux", filter.Phrase)
suite.ElementsMatch(
[]apimodel.FilterContext{
apimodel.FilterContextHome,
apimodel.FilterContextPublic,
},
filter.Context,
)
suite.Equal(false, filter.Irreversible)
suite.Equal(true, filter.WholeWord)
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
}
func (suite *FiltersTestSuite) TestPostFilterMinimal() {
phrase := "GNU/Linux"
context := []string{"home"}
filter, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(phrase, filter.Phrase)
filterContext := make([]string, 0, len(filter.Context))
for _, c := range filter.Context {
filterContext = append(filterContext, string(c))
}
suite.ElementsMatch(context, filterContext)
suite.False(filter.Irreversible)
suite.False(filter.WholeWord)
suite.Nil(filter.ExpiresAt)
}
func (suite *FiltersTestSuite) TestPostFilterEmptyPhrase() {
phrase := ""
context := []string{"home"}
_, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterMissingPhrase() {
context := []string{"home"}
_, err := suite.postFilter(nil, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
phrase := "GNU/Linux"
context := []string{}
_, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
phrase := "GNU/Linux"
_, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
// There should be a filter with this phrase as its title in our test fixtures. Creating another should fail.
func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
phrase := "fnord"
_, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
// FUTURE: this should be removed once we support server-side filters.
func (suite *FiltersTestSuite) TestPostFilterIrreversibleNotSupported() {
phrase := "GNU/Linux"
context := []string{"home"}
irreversible := true
_, err := suite.postFilter(&phrase, &context, &irreversible, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,159 @@
// 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 v1
import (
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// FilterPUTHandler swagger:operation PUT /api/v1/filters/{id} filterV1Put
//
// Update a single filter with the given ID.
//
// ---
// tags:
// - filters
//
// consumes:
// - application/json
// - application/xml
// - application/x-www-form-urlencoded
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// in: path
// type: string
// required: true
// description: ID of the filter.
// -
// name: phrase
// in: formData
// required: true
// description: The text to be filtered.
// maxLength: 40
// type: string
// example: "fnord"
// -
// name: context
// in: formData
// required: true
// description: The contexts in which the filter should be applied.
// enum:
// - home
// - notifications
// - public
// - thread
// - account
// example:
// - home
// - public
// items:
// $ref: '#/definitions/filterContext'
// minLength: 1
// type: array
// uniqueItems: true
// -
// name: expires_in
// in: formData
// description: Number of seconds from now that the filter should expire. If omitted, filter never expires.
// type: number
// example: 86400
// -
// name: irreversible
// in: formData
// description: Should matching entities be removed from the user's timelines/views, instead of hidden? Not supported yet.
// type: boolean
// default: false
// example: false
// -
// name: whole_word
// in: formData
// description: Should the filter consider word boundaries?
// type: boolean
// default: false
// example: true
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// name: filter
// description: Updated filter.
// schema:
// "$ref": "#/definitions/filterV1"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '422':
// description: unprocessable content
// '500':
// description: internal server error
func (m *Module) FilterPUTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...);