[feature] Implement Filter API v2 (#2936)

* Use correct entity name

* We support server-side filters now

* Document filter v1 methods that can throw a 409

* Validate v1 filter phrase as filter title

* Always check v1 filter API status codes in tests

* Document keyword minimum requirement on filter API v1

* Make it possible to specify filter keyword update columns per filter keyword

* Implement v2 filter API

* Fix lint and tests

* Update Swagger spec

* Fix filter update test

* Update Swagger spec *correctly*

* Update actual files Swagger spec was generated from

* Remove keywords_attributes and statuses_attributes

* Add test for serialization of empty filter

* More helpful messages when object is owned by wrong account
This commit is contained in:
Vyr Cossont 2024-05-31 03:55:56 -07:00 committed by GitHub
parent 4db596b8b9
commit 61a8d36255
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 5601 additions and 55 deletions

View file

@ -1053,7 +1053,7 @@ definitions:
type: string
x-go-name: Keyword
whole_word:
description: Should the filter consider word boundaries?
description: Should the filter keyword consider word boundaries?
example: true
type: boolean
x-go-name: WholeWord
@ -5971,6 +5971,7 @@ paths:
Sample: fnord
in: formData
maxLength: 40
minLength: 1
name: phrase
required: true
type: string
@ -6031,6 +6032,8 @@ paths:
description: not found
"406":
description: not acceptable
"409":
description: conflict (duplicate keyword)
"422":
description: unprocessable content
"500":
@ -6045,7 +6048,7 @@ paths:
delete:
operationId: filterV1Delete
parameters:
- description: ID of the list
- description: ID of the filter
in: path
name: id
required: true
@ -6120,6 +6123,7 @@ paths:
Sample: fnord
in: formData
maxLength: 40
minLength: 1
name: phrase
required: true
type: string
@ -6180,6 +6184,8 @@ paths:
description: not found
"406":
description: not acceptable
"409":
description: conflict (duplicate keyword)
"422":
description: unprocessable content
"500":
@ -8759,6 +8765,547 @@ paths:
summary: View + page through known accounts according to given filters.
tags:
- admin
/api/v2/filters:
get:
operationId: filtersV2Get
produces:
- application/json
responses:
"200":
description: Requested filters.
schema:
items:
$ref: '#/definitions/filterV2'
type: array
"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: filterV2Post
parameters:
- description: |-
The name of the filter.
Sample: illuminati nonsense
in: formData
maxLength: 200
minLength: 1
name: title
required: true
type: string
- collectionFormat: multi
description: |-
The contexts in which the filter should be applied.
Sample: home, public
enum:
- home
- notifications
- public
- thread
- account
in: formData
items:
type: string
minItems: 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.
Sample: 86400
in: formData
name: expires_in
type: number
- default: warn
description: |-
The action to be taken when a status matches this filter.
Sample: warn
enum:
- warn
- hide
in: formData
name: filter_action
type: string
produces:
- application/json
responses:
"200":
description: New filter.
schema:
$ref: '#/definitions/filterV2'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"409":
description: conflict (duplicate title, keyword, or status)
"422":
description: unprocessable content
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:filters
summary: Create a single filter.
tags:
- filters
/api/v2/filters/{id}:
delete:
operationId: filterV2Delete
parameters:
- description: ID of the filter
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: filterV2Get
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/filterV2'
"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
description: |-
Note that this is actually closer to a PATCH operation:
only provided fields will be updated, and omitted fields will remain set to previous values.
operationId: filterV2Put
parameters:
- description: ID of the filter.
in: path
name: id
required: true
type: string
- description: |-
The name of the filter.
Sample: illuminati nonsense
in: formData
maxLength: 200
minLength: 1
name: title
required: true
type: string
- collectionFormat: multi
description: |-
The contexts in which the filter should be applied.
Sample: home, public
enum:
- home
- notifications
- public
- thread
- account
in: formData
items:
type: string
minItems: 1
name: context[]
required: true
type: array
uniqueItems: true
- description: |-
Number of seconds from now that the filter should expire.
Sample: 86400
in: formData
name: expires_in
type: number
produces:
- application/json
responses:
"200":
description: Updated filter.
schema:
$ref: '#/definitions/filterV2'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"409":
description: conflict (duplicate title, keyword, or status)
"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/v2/filters/{id}/keywords:
get:
operationId: filterKeywordsGet
parameters:
- description: ID of the filter
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Requested filter keywords.
schema:
items:
$ref: '#/definitions/filterKeyword'
type: array
"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 filter keywords for a given filter.
tags:
- filters
post:
consumes:
- application/json
- application/xml
- application/x-www-form-urlencoded
operationId: filterKeywordPost
parameters:
- description: ID of the filter to add the filtered status to.
in: path
name: id
required: true
type: string
- description: |-
The text to be filtered
Sample: fnord
in: formData
maxLength: 40
minLength: 1
name: keyword
required: true
type: string
- default: false
description: |-
Should the filter consider word boundaries?
Sample: true
in: formData
name: whole_word
type: boolean
produces:
- application/json
responses:
"200":
description: New filter keyword.
schema:
$ref: '#/definitions/filterKeyword'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"409":
description: conflict (duplicate keyword)
"422":
description: unprocessable content
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:filters
summary: Add a filter keyword to an existing filter.
tags:
- filters
/api/v2/filters/{id}/statuses:
get:
operationId: filterStatusesGet
parameters:
- description: ID of the filter
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Requested filter statuses.
schema:
items:
$ref: '#/definitions/filterStatus'
type: array
"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 filter statuses for a given filter.
tags:
- filters
post:
consumes:
- application/json
- application/xml
- application/x-www-form-urlencoded
operationId: filterStatusPost
parameters:
- description: ID of the filter to add the filtered status to.
in: path
name: id
required: true
type: string
- description: |-
The ID of the status to filter.
Sample: 01HXA2NE0K8T1C70K90E74GYD0
in: formData
name: status_id
required: true
type: string
produces:
- application/json
responses:
"200":
description: New filter status.
schema:
$ref: '#/definitions/filterStatus'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"409":
description: conflict (duplicate status)
"422":
description: unprocessable content
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:filters
summary: Add a filter status to an existing filter.
tags:
- filters
/api/v2/filters/keywords/{id}:
get:
operationId: filterKeywordGet
parameters:
- description: ID of the filter keyword
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Requested filter keyword.
schema:
$ref: '#/definitions/filterKeyword'
"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 keyword with the given ID.
tags:
- filters
/api/v2/filters/keywords{id}:
put:
consumes:
- application/json
- application/xml
- application/x-www-form-urlencoded
operationId: filterKeywordPut
parameters:
- description: ID of the filter keyword to update.
in: path
name: id
required: true
type: string
- description: |-
The text to be filtered
Sample: fnord
in: formData
maxLength: 40
minLength: 1
name: keyword
required: true
type: string
- description: |-
Should the filter consider word boundaries?
Sample: true
in: formData
name: whole_word
type: boolean
produces:
- application/json
responses:
"200":
description: Updated filter keyword.
schema:
$ref: '#/definitions/filterKeyword'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"409":
description: conflict (duplicate keyword)
"422":
description: unprocessable content
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:filters
summary: Update a single filter keyword with the given ID.
tags:
- filters
/api/v2/filters/statuses/{id}:
get:
operationId: filterStatusGet
parameters:
- description: ID of the filter status
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Requested filter status.
schema:
$ref: '#/definitions/filterStatus'
"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 status with the given ID.
tags:
- filters
/api/v2/instance:
get:
operationId: instanceGetV2

View file

@ -31,6 +31,7 @@
"github.com/superseriousbusiness/gotosocial/internal/api/client/favourites"
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
"github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
@ -67,6 +68,7 @@ type Client struct {
favourites *favourites.Module // api/v1/favourites
featuredTags *featuredtags.Module // api/v1/featured_tags
filtersV1 *filtersV1.Module // api/v1/filters
filtersV2 *filtersV2.Module // api/v2/filters
followRequests *followrequests.Module // api/v1/follow_requests
instance *instance.Module // api/v1/instance
lists *lists.Module // api/v1/lists
@ -111,6 +113,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.favourites.Route(h)
c.featuredTags.Route(h)
c.filtersV1.Route(h)
c.filtersV2.Route(h)
c.followRequests.Route(h)
c.instance.Route(h)
c.lists.Route(h)
@ -143,6 +146,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
favourites: favourites.New(p),
featuredTags: featuredtags.New(p),
filtersV1: filtersV1.New(p),
filtersV2: filtersV2.New(p),
followRequests: followrequests.New(p),
instance: instance.New(p),
lists: lists.New(p),

View file

@ -41,7 +41,7 @@
// -
// name: id
// type: string
// description: ID of the list
// description: ID of the filter
// in: path
// required: true
//

View file

@ -66,6 +66,9 @@ func (suite *FiltersTestSuite) deleteFilter(
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return errs.Combine()
}
}
// if we got an expected body, return early

View file

@ -68,6 +68,9 @@ func (suite *FiltersTestSuite) getFilter(
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early

View file

@ -52,6 +52,7 @@
// The text to be filtered.
//
// Sample: fnord
// minLength: 1
// maxLength: 40
// type: string
// -
@ -120,6 +121,8 @@
// description: not found
// '406':
// description: not acceptable
// '409':
// description: conflict (duplicate keyword)
// '422':
// description: unprocessable content
// '500':

View file

@ -94,6 +94,9 @@ func (suite *FiltersTestSuite) postFilter(
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early
@ -226,14 +229,3 @@ func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
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

@ -58,6 +58,7 @@
// The text to be filtered.
//
// Sample: fnord
// minLength: 1
// maxLength: 40
// type: string
// -
@ -126,6 +127,8 @@
// description: not found
// '406':
// description: not acceptable
// '409':
// description: conflict (duplicate keyword)
// '422':
// description: unprocessable content
// '500':

View file

@ -97,6 +97,9 @@ func (suite *FiltersTestSuite) putFilter(
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early
@ -238,16 +241,6 @@ func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {
}
}
// FUTURE: this should be removed once we support server-side filters.
func (suite *FiltersTestSuite) TestPutFilterIrreversibleNotSupported() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
irreversible := true
_, err := suite.putFilter(id, nil, nil, &irreversible, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() {
id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
phrase := "GNU/Linux"

View file

@ -64,6 +64,9 @@ func (suite *FiltersTestSuite) getFilters(
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early

View file

@ -31,6 +31,10 @@ func validateNormalizeCreateUpdateFilter(form *model.FilterCreateUpdateRequestV1
if err := validate.FilterKeyword(form.Phrase); err != nil {
return err
}
// For filter v1 forwards compatibility, the phrase is used as the title of a v2 filter, so it must pass that as well.
if err := validate.FilterTitle(form.Phrase); err != nil {
return err
}
if err := validate.FilterContexts(form.Context); err != nil {
return err
}

View file

@ -0,0 +1,80 @@
// 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 v2
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
)
const (
// BasePath is the base path for serving the filters API, minus the 'api' prefix
BasePath = "/v2/filters"
// BasePathWithID is the base path with the ID key in it, for operations on an existing filter.
BasePathWithID = BasePath + "/:" + apiutil.IDKey
// FilterKeywordsPathWithID is the path for operations on an existing filter's keywords.
FilterKeywordsPathWithID = BasePathWithID + "/keywords"
// FilterStatusesPathWithID is the path for operations on an existing filter's statuses.
FilterStatusesPathWithID = BasePathWithID + "/statuses"
// KeywordPath is the base path for operations on filter keywords that don't require a filter ID.
KeywordPath = BasePath + "/keywords"
// KeywordPathWithKeywordID is the path for operations on an existing filter keyword.
KeywordPathWithKeywordID = KeywordPath + "/:" + apiutil.IDKey
// StatusPath is the base path for operations on filter statuses that don't require a filter ID.
StatusPath = BasePath + "/statuses"
// StatusPathWithStatusID is the path for operations on an existing filter status.
StatusPathWithStatusID = StatusPath + "/:" + apiutil.IDKey
)
// Module implements APIs for client-side aka "v1" filtering.
type Module struct {
processor *processing.Processor
}
func New(processor *processing.Processor) *Module {
return &Module{
processor: processor,
}
}
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)
attachHandler(http.MethodGet, FilterKeywordsPathWithID, m.FilterKeywordsGETHandler)
attachHandler(http.MethodPost, FilterKeywordsPathWithID, m.FilterKeywordPOSTHandler)
attachHandler(http.MethodGet, KeywordPathWithKeywordID, m.FilterKeywordGETHandler)
attachHandler(http.MethodPut, KeywordPathWithKeywordID, m.FilterKeywordPUTHandler)
attachHandler(http.MethodDelete, KeywordPathWithKeywordID, m.FilterKeywordDELETEHandler)
attachHandler(http.MethodGet, FilterStatusesPathWithID, m.FilterStatusesGETHandler)
attachHandler(http.MethodPost, FilterStatusesPathWithID, m.FilterStatusPOSTHandler)
attachHandler(http.MethodGet, StatusPathWithStatusID, m.FilterStatusGETHandler)
attachHandler(http.MethodDelete, StatusPathWithStatusID, m.FilterStatusDELETEHandler)
}

View file

@ -0,0 +1,118 @@
// 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 v2_test
import (
"testing"
"github.com/stretchr/testify/suite"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"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"
)
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 *filtersV2.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 = filtersV2.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 v2
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/v2/filters/{id} filterV2Delete
//
// Delete 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:
// - 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.FiltersV2().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,115 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"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(
filterID 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/"+filtersV2.BasePath+"/"+filterID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterID)
// 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 expectedBody == "" {
return errs.Combine()
}
}
// 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.testFilters["local_account_1_filter_1"].ID
err := suite.deleteFilter(id, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() {
id := suite.testFilters["local_account_2_filter_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 v2
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/v2/filters/{id} filterV2Get
//
// 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/filterV2"
// '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.FiltersV2().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,133 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) getFilter(
filterID string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterV2, 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/"+filtersV2.BasePath+"/"+filterID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterID)
// 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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterV2{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestGetFilter() {
expectedFilter := suite.testFilters["local_account_1_filter_1"]
filter, err := suite.getFilter(expectedFilter.ID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.NotEmpty(filter)
suite.Equal(expectedFilter.Action, typeutils.APIFilterActionToFilterAction(filter.FilterAction))
suite.Equal(expectedFilter.ID, filter.ID)
suite.Equal(expectedFilter.Title, filter.Title)
}
func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() {
id := suite.testFilters["local_account_2_filter_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())
}
}
// Test that an empty filter with no keywords or statuses serializes the keywords and statuses arrays as empty arrays,
// not as null values or entirely omitted fields.
func (suite *FiltersTestSuite) TestGetEmptyFilter() {
id := suite.testFilters["local_account_1_filter_4"].ID
_, err := suite.getFilter(id, http.StatusOK, `{"id":"01HZ55WWWP82WYP2A1BKWK8Y9Q","title":"empty filter with no keywords or statuses","context":["home","public"],"expires_at":null,"filter_action":"warn","keywords":[],"statuses":[]}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,54 @@
// 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 v2
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"
)
func (m *Module) FilterKeywordDELETEHandler(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.FiltersV2().KeywordDelete(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,115 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"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) deleteFilterKeyword(
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/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterKeywordID)
// trigger the handler
suite.filtersModule.FilterKeywordDELETEHandler(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 expectedBody == "" {
return errs.Combine()
}
}
// 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) TestDeleteFilterKeyword() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
err := suite.deleteFilterKeyword(id, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterKeyword() {
id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestDeleteNonexistentFilterKeyword() {
id := "not_even_a_real_ULID"
err := suite.deleteFilterKeyword(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 v2
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"
)
// FilterKeywordGETHandler swagger:operation GET /api/v2/filters/keywords/{id} filterKeywordGet
//
// Get a single filter keyword with the given ID.
//
// ---
// tags:
// - filters
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the filter keyword
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - read:filters
//
// responses:
// '200':
// name: filterKeyword
// description: Requested filter keyword.
// schema:
// "$ref": "#/definitions/filterKeyword"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FilterKeywordGETHandler(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.FiltersV2().KeywordGet(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,122 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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/internal/util"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) getFilterKeyword(
filterKeywordID string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterKeyword, 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/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterKeywordID)
// trigger the handler
suite.filtersModule.FilterKeywordGETHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterKeyword{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestGetFilterKeyword() {
expectedFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
filterKeyword, err := suite.getFilterKeyword(expectedFilterKeyword.ID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.NotEmpty(filterKeyword)
suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID)
suite.Equal(expectedFilterKeyword.Keyword, filterKeyword.Keyword)
suite.Equal(util.PtrValueOr(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() {
id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
_, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestGetNonexistentFilterKeyword() {
id := "not_even_a_real_ULID"
_, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,151 @@
// 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 v2
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"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
// FilterKeywordPOSTHandler swagger:operation POST /api/v2/filters/{id}/keywords filterKeywordPost
//
// Add a filter keyword to an existing filter.
//
// ---
// 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 to add the filtered status to.
// -
// name: keyword
// in: formData
// required: true
// description: |-
// The text to be filtered
//
// Sample: fnord
// type: string
// minLength: 1
// maxLength: 40
// -
// name: whole_word
// in: formData
// description: |-
// Should the filter consider word boundaries?
//
// Sample: true
// type: boolean
// default: false
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// name: filterKeyword
// description: New filter keyword.
// schema:
// "$ref": "#/definitions/filterKeyword"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: conflict (duplicate keyword)
// '422':
// description: unprocessable content
// '500':
// description: internal server error
func (m *Module) FilterKeywordPOSTHandler(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 authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
form := &apimodel.FilterKeywordCreateUpdateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateNormalizeCreateUpdateFilterKeyword(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV2().KeywordCreate(c.Request.Context(), authed.Account, filterID, form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiFilter)
}
func validateNormalizeCreateUpdateFilterKeyword(form *apimodel.FilterKeywordCreateUpdateRequest) error {
if err := validate.FilterKeyword(form.Keyword); err != nil {
return err
}
form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
return nil
}

View file

@ -0,0 +1,192 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) postFilterKeyword(
filterID string,
keyword *string,
wholeWord *bool,
requestJson *string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterKeyword, 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/"+filtersV2.BasePath+"/"+filterID+"/keywords", 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 keyword != nil {
ctx.Request.Form["keyword"] = []string{*keyword}
}
if wholeWord != nil {
ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)}
}
}
ctx.AddParam("id", filterID)
// trigger the handler
suite.filtersModule.FilterKeywordPOSTHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterKeyword{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestPostFilterKeywordFull() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
keyword := "fnords"
wholeWord := true
filterKeyword, err := suite.postFilterKeyword(filterID, &keyword, &wholeWord, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(keyword, filterKeyword.Keyword)
suite.Equal(wholeWord, filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestPostFilterKeywordFullJSON() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
requestJson := `{
"keyword": "fnords",
"whole_word": true
}`
filterKeyword, err := suite.postFilterKeyword(filterID, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal("fnords", filterKeyword.Keyword)
suite.True(filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestPostFilterKeywordMinimal() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
keyword := "fnords"
filterKeyword, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(keyword, filterKeyword.Keyword)
suite.False(filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestPostFilterKeywordEmptyKeyword() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
keyword := ""
_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterKeywordMissingKeyword() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
_, err := suite.postFilterKeyword(filterID, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
// Creating another filter keyword in the same filter with the same keyword should fail.
func (suite *FiltersTestSuite) TestPostFilterKeywordKeywordConflict() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
keyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].Keyword
_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusConflict, `{"error":"Conflict: duplicate keyword"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterKeywordAnotherAccountsFilter() {
filterID := suite.testFilters["local_account_2_filter_1"].ID
keyword := "fnords"
_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterKeywordNonexistentFilter() {
filterID := "not_even_a_real_ULID"
keyword := "fnords"
_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,138 @@
// 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 v2
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"
)
// FilterKeywordPUTHandler swagger:operation PUT /api/v2/filters/keywords{id} filterKeywordPut
//
// Update a single filter keyword 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 keyword to update.
// -
// name: keyword
// in: formData
// required: true
// description: |-
// The text to be filtered
//
// Sample: fnord
// type: string
// minLength: 1
// maxLength: 40
// -
// name: whole_word
// in: formData
// description: |-
// Should the filter consider word boundaries?
//
// Sample: true
// type: boolean
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// name: filterKeyword
// description: Updated filter keyword.
// schema:
// "$ref": "#/definitions/filterKeyword"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: conflict (duplicate keyword)
// '422':
// description: unprocessable content
// '500':
// description: internal server error
func (m *Module) FilterKeywordPUTHandler(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 authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
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
}
form := &apimodel.FilterKeywordCreateUpdateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateNormalizeCreateUpdateFilterKeyword(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV2().KeywordUpdate(c.Request.Context(), authed.Account, id, form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiFilter)
}

View file

@ -0,0 +1,192 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) putFilterKeyword(
filterKeywordID string,
keyword *string,
wholeWord *bool,
requestJson *string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterKeyword, 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.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.KeywordPath+"/"+filterKeywordID, 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 keyword != nil {
ctx.Request.Form["keyword"] = []string{*keyword}
}
if wholeWord != nil {
ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)}
}
}
ctx.AddParam("id", filterKeywordID)
// trigger the handler
suite.filtersModule.FilterKeywordPUTHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterKeyword{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestPutFilterKeywordFull() {
filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
keyword := "fnords"
wholeWord := true
filterKeyword, err := suite.putFilterKeyword(filterKeywordID, &keyword, &wholeWord, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(keyword, filterKeyword.Keyword)
suite.Equal(wholeWord, filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestPutFilterKeywordFullJSON() {
filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
requestJson := `{
"keyword": "fnords",
"whole_word": true
}`
filterKeyword, err := suite.putFilterKeyword(filterKeywordID, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal("fnords", filterKeyword.Keyword)
suite.True(filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestPutFilterKeywordMinimal() {
filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
keyword := "fnords"
filterKeyword, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(keyword, filterKeyword.Keyword)
suite.False(filterKeyword.WholeWord)
}
func (suite *FiltersTestSuite) TestPutFilterKeywordEmptyKeyword() {
filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
keyword := ""
_, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPutFilterKeywordMissingKeyword() {
filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
_, err := suite.putFilterKeyword(filterKeywordID, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
// Changing our filter keyword to the same keyword as another filter keyword in the same filter should fail.
func (suite *FiltersTestSuite) TestPutFilterKeywordKeywordConflict() {
filterKeywordID := suite.testFilterKeywords["local_account_1_filter_2_keyword_1"].ID
conflictingKeyword := suite.testFilterKeywords["local_account_1_filter_2_keyword_2"].Keyword
_, err := suite.putFilterKeyword(filterKeywordID, &conflictingKeyword, nil, nil, http.StatusConflict, `{"error":"Conflict: duplicate keyword"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPutFilterKeywordAnotherAccountsFilterKeyword() {
filterKeywordID := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
keyword := "fnord"
_, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPutFilterKeywordNonexistentFilterKeyword() {
filterKeywordID := "not_even_a_real_ULID"
keyword := "fnord"
_, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,95 @@
// 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 v2
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"
)
// FilterKeywordsGETHandler swagger:operation GET /api/v2/filters/{id}/keywords filterKeywordsGet
//
// Get all filter keywords for a given filter.
//
// ---
// 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: filterKeywords
// description: Requested filter keywords.
// schema:
// type: array
// items:
// "$ref": "#/definitions/filterKeyword"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FilterKeywordsGETHandler(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
}
filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV2().KeywordsGetForFilterID(c.Request.Context(), authed.Account, filterID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiFilter)
}

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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) getFilterKeywords(
filterID string,
expectedHTTPStatus int,
expectedBody string,
) ([]*apimodel.FilterKeyword, 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/"+filtersV2.BasePath+"/"+filterID+"/keywords", nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterID)
// trigger the handler
suite.filtersModule.FilterKeywordsGETHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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 := make([]*apimodel.FilterKeyword, 0)
if err := json.Unmarshal(b, &resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestGetFilterKeywords() {
// Collect the sets of filter keyword IDs we expect to see.
filterID := suite.testFilters["local_account_1_filter_1"].ID
expectedFilterKeywordIDs := []string{}
for _, filterKeyword := range suite.testFilterKeywords {
if filterKeyword.FilterID == filterID {
expectedFilterKeywordIDs = append(expectedFilterKeywordIDs, filterKeyword.ID)
}
}
suite.NotEmpty(expectedFilterKeywordIDs)
// Fetch all filter keywords for the test filter.
filterKeywords, err := suite.getFilterKeywords(filterID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.NotEmpty(filterKeywords)
// Check that we got the right ones.
suite.Len(filterKeywords, len(expectedFilterKeywordIDs))
actualFilterKeywordIDs := []string{}
for _, filterKeyword := range filterKeywords {
actualFilterKeywordIDs = append(actualFilterKeywordIDs, filterKeyword.ID)
}
suite.ElementsMatch(expectedFilterKeywordIDs, actualFilterKeywordIDs)
}

View file

@ -0,0 +1,202 @@
// 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 v2
import (
"fmt"
"net/http"
"strconv"
"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"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
// FilterPOSTHandler swagger:operation POST /api/v2/filters filterV2Post
//
// Create a single filter.
//
// ---
// tags:
// - filters
//
// consumes:
// - application/json
// - application/xml
// - application/x-www-form-urlencoded
//
// produces:
// - application/json
//
// parameters:
// -
// name: title
// in: formData
// required: true
// description: |-
// The name of the filter.
//
// Sample: illuminati nonsense
// type: string
// minLength: 1
// maxLength: 200
// -
// name: context[]
// in: formData
// required: true
// description: |-
// The contexts in which the filter should be applied.
//
// Sample: home, public
// enum:
// - home
// - notifications
// - public
// - thread
// - account
// type: array
// items:
// type:
// string
// collectionFormat: multi
// minItems: 1
// uniqueItems: true
// -
// name: expires_in
// in: formData
// description: |-
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
//
// Sample: 86400
// type: number
// -
// name: filter_action
// in: formData
// description: |-
// The action to be taken when a status matches this filter.
//
// Sample: warn
// type: string
// enum:
// - warn
// - hide
// default: warn
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// name: filter
// description: New filter.
// schema:
// "$ref": "#/definitions/filterV2"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: conflict (duplicate title, keyword, or status)
// '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 authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := &apimodel.FilterCreateRequestV2{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateNormalizeCreateFilter(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV2().Create(c.Request.Context(), authed.Account, form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiFilter)
}
func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
if err := validate.FilterTitle(form.Title); err != nil {
return err
}
action := util.PtrValueOr(form.FilterAction, apimodel.FilterActionWarn)
if err := validate.FilterAction(action); err != nil {
return err
}
if err := validate.FilterContexts(form.Context); err != nil {
return err
}
// Apply defaults for missing fields.
form.FilterAction = util.Ptr(action)
// Normalize filter expiry if necessary.
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
if ei := form.ExpiresInI; ei != nil {
switch e := ei.(type) {
case float64:
form.ExpiresIn = util.Ptr(int(e))
case string:
expiresIn, err := strconv.Atoi(e)
if err != nil {
return fmt.Errorf("could not parse expires_in value %s as integer: %w", e, err)
}
form.ExpiresIn = &expiresIn
default:
return fmt.Errorf("could not parse expires_in type %T as integer", ei)
}
}
return nil
}

View file

@ -0,0 +1,221 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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(title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, 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/"+filtersV2.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 title != nil {
ctx.Request.Form["title"] = []string{*title}
}
if context != nil {
ctx.Request.Form["context[]"] = *context
}
if action != nil {
ctx.Request.Form["filter_action"] = []string{*action}
}
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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterV2{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestPostFilterFull() {
title := "GNU/Linux"
context := []string{"home", "public"}
action := "warn"
expiresIn := 86400
filter, err := suite.postFilter(&title, &context, &action, &expiresIn, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(title, filter.Title)
filterContext := make([]string, 0, len(filter.Context))
for _, c := range filter.Context {
filterContext = append(filterContext, string(c))
}
suite.ElementsMatch(context, filterContext)
suite.Equal(apimodel.FilterActionWarn, filter.FilterAction)
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Empty(filter.Keywords)
suite.Empty(filter.Statuses)
}
func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
// Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "expires_in".
requestJson := `{
"title": "GNU/Linux",
"context": ["home", "public"],
"filter_action": "warn",
"whole_word": true,
"expires_in": 86400.1
}`
filter, err := suite.postFilter(nil, nil, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal("GNU/Linux", filter.Title)
suite.ElementsMatch(
[]apimodel.FilterContext{
apimodel.FilterContextHome,
apimodel.FilterContextPublic,
},
filter.Context,
)
suite.Equal(apimodel.FilterActionWarn, filter.FilterAction)
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Empty(filter.Keywords)
suite.Empty(filter.Statuses)
}
func (suite *FiltersTestSuite) TestPostFilterMinimal() {
title := "GNU/Linux"
context := []string{"home"}
filter, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(title, filter.Title)
filterContext := make([]string, 0, len(filter.Context))
for _, c := range filter.Context {
filterContext = append(filterContext, string(c))
}
suite.ElementsMatch(context, filterContext)
suite.Equal(apimodel.FilterActionWarn, filter.FilterAction)
suite.Nil(filter.ExpiresAt)
suite.Empty(filter.Keywords)
suite.Empty(filter.Statuses)
}
func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() {
title := ""
context := []string{"home"}
_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterMissingTitle() {
context := []string{"home"}
_, err := suite.postFilter(nil, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
title := "GNU/Linux"
context := []string{}
_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
title := "GNU/Linux"
_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
// Creating another filter with the same title should fail.
func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
title := suite.testFilters["local_account_1_filter_1"].Title
_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,206 @@
// 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 v2
import (
"fmt"
"net/http"
"strconv"
"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"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
// FilterPUTHandler swagger:operation PUT /api/v2/filters/{id} filterV2Put
//
// Update a single filter with the given ID.
// Note that this is actually closer to a PATCH operation:
// only provided fields will be updated, and omitted fields will remain set to previous values.
//
// ---
// 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: title
// in: formData
// required: true
// description: |-
// The name of the filter.
//
// Sample: illuminati nonsense
// type: string
// minLength: 1
// maxLength: 200
// -
// name: context[]
// in: formData
// required: true
// description: |-
// The contexts in which the filter should be applied.
//
// Sample: home, public
// enum:
// - home
// - notifications
// - public
// - thread
// - account
// type: array
// items:
// type:
// string
// collectionFormat: multi
// minItems: 1
// uniqueItems: true
// -
// name: expires_in
// in: formData
// description: |-
// Number of seconds from now that the filter should expire.
//
// Sample: 86400
// type: number
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// name: filter
// description: Updated filter.
// schema:
// "$ref": "#/definitions/filterV2"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: conflict (duplicate title, keyword, or status)
// '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 authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
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
}
form := &apimodel.FilterUpdateRequestV2{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateNormalizeUpdateFilter(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV2().Update(c.Request.Context(), authed.Account, id, form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiFilter)
}
func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
if form.Title != nil {
if err := validate.FilterTitle(*form.Title); err != nil {
return err
}
}
if form.FilterAction != nil {
if err := validate.FilterAction(*form.FilterAction); err != nil {
return err
}
}
if form.Context != nil {
if err := validate.FilterContexts(*form.Context); err != nil {
return err
}
}
// Normalize filter expiry if necessary.
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
if ei := form.ExpiresInI; ei != nil {
switch e := ei.(type) {
case float64:
form.ExpiresIn = util.Ptr(int(e))
case string:
expiresIn, err := strconv.Atoi(e)
if err != nil {
return fmt.Errorf("could not parse expires_in value %s as integer: %w", e, err)
}
form.ExpiresIn = &expiresIn
default:
return fmt.Errorf("could not parse expires_in type %T as integer", ei)
}
}
return nil
}

View file

@ -0,0 +1,230 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, 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.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID, 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 title != nil {
ctx.Request.Form["title"] = []string{*title}
}
if context != nil {
ctx.Request.Form["context[]"] = *context
}
if action != nil {
ctx.Request.Form["filter_action"] = []string{*action}
}
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
}
}
ctx.AddParam("id", filterID)
// trigger the handler
suite.filtersModule.FilterPUTHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterV2{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestPutFilterFull() {
id := suite.testFilters["local_account_1_filter_2"].ID
title := "messy synoptic varblabbles"
context := []string{"home", "public"}
action := "hide"
expiresIn := 86400
filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(title, filter.Title)
filterContext := make([]string, 0, len(filter.Context))
for _, c := range filter.Context {
filterContext = append(filterContext, string(c))
}
suite.ElementsMatch(context, filterContext)
suite.Equal(apimodel.FilterActionHide, filter.FilterAction)
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Len(filter.Keywords, 3)
suite.Len(filter.Statuses, 0)
}
func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
id := suite.testFilters["local_account_1_filter_2"].ID
// Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "expires_in".
requestJson := `{
"title": "messy synoptic varblabbles",
"context": ["home", "public"],
"filter_action": "hide",
"expires_in": 86400.1
}`
filter, err := suite.putFilter(id, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal("messy synoptic varblabbles", filter.Title)
suite.ElementsMatch(
[]apimodel.FilterContext{
apimodel.FilterContextHome,
apimodel.FilterContextPublic,
},
filter.Context,
)
suite.Equal(apimodel.FilterActionHide, filter.FilterAction)
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Len(filter.Keywords, 3)
suite.Len(filter.Statuses, 0)
}
func (suite *FiltersTestSuite) TestPutFilterMinimal() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := "GNU/Linux"
context := []string{"home"}
filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(title, filter.Title)
filterContext := make([]string, 0, len(filter.Context))
for _, c := range filter.Context {
filterContext = append(filterContext, string(c))
}
suite.ElementsMatch(context, filterContext)
suite.Equal(apimodel.FilterActionWarn, filter.FilterAction)
suite.Nil(filter.ExpiresAt)
}
func (suite *FiltersTestSuite) TestPutFilterEmptyTitle() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := ""
context := []string{"home"}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := "GNU/Linux"
context := []string{}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
// Changing our title to a title used by an existing filter should fail.
func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := suite.testFilters["local_account_1_filter_2"].Title
_, err := suite.putFilter(id, &title, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() {
id := suite.testFilters["local_account_2_filter_1"].ID
title := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPutNonexistentFilter() {
id := "not_even_a_real_ULID"
phrase := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,81 @@
// 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 v2
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"
)
// FiltersGETHandler swagger:operation GET /api/v2/filters filtersV2Get
//
// Get all filters for the authenticated account.
//
// ---
// tags:
// - filters
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - read:filters
//
// responses:
// '200':
// name: filters
// description: Requested filters.
// schema:
// type: array
// items:
// "$ref": "#/definitions/filterV2"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FiltersGETHandler(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
}
apiFilters, errWithCode := m.processor.FiltersV2().GetAll(c.Request.Context(), authed.Account)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiFilters)
}

View file

@ -0,0 +1,154 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) getFilters(
expectedHTTPStatus int,
expectedBody string,
) ([]*apimodel.FilterV2, 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/"+filtersV2.BasePath, nil)
ctx.Request.Header.Set("accept", "application/json")
// trigger the handler
suite.filtersModule.FiltersGETHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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 := make([]*apimodel.FilterV2, 0)
if err := json.Unmarshal(b, &resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestGetFilters() {
// Set of filter IDs for the test user.
expectedFilterIDs := []string{}
// Map of filter IDs to filter keyword and status IDs.
expectedFilters := map[string]struct {
keywordIDs []string
statusIDs []string
}{}
// Collect the sets of IDs we expect to see.
accountID := suite.testAccounts["local_account_1"].ID
for _, filter := range suite.testFilters {
if filter.AccountID == accountID {
expectedFilterIDs = append(expectedFilterIDs, filter.ID)
expectedFilters[filter.ID] = struct {
keywordIDs []string
statusIDs []string
}{}
}
}
for _, filterKeyword := range suite.testFilterKeywords {
if filterKeyword.AccountID == accountID {
expectedIDsForFilter := expectedFilters[filterKeyword.FilterID]
expectedIDsForFilter.keywordIDs = append(expectedIDsForFilter.keywordIDs, filterKeyword.ID)
expectedFilters[filterKeyword.FilterID] = expectedIDsForFilter
}
}
for _, filterStatus := range suite.testFilterStatuses {
if filterStatus.AccountID == accountID {
expectedIDsForFilter := expectedFilters[filterStatus.FilterID]
expectedIDsForFilter.statusIDs = append(expectedIDsForFilter.statusIDs, filterStatus.ID)
expectedFilters[filterStatus.FilterID] = expectedIDsForFilter
}
}
suite.NotEmpty(expectedFilterIDs)
suite.NotEmpty(expectedFilters)
// Fetch all filters for the logged-in account.
filters, err := suite.getFilters(http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.NotEmpty(filters)
// Check that we got the right ones.
suite.Len(filters, len(expectedFilters))
actualFilterIDs := []string{}
for _, filter := range filters {
actualFilterIDs = append(actualFilterIDs, filter.ID)
expectedIDsForFilter := expectedFilters[filter.ID]
actualFilterKeywordIDs := []string{}
for _, filterKeyword := range filter.Keywords {
actualFilterKeywordIDs = append(actualFilterKeywordIDs, filterKeyword.ID)
}
suite.ElementsMatch(actualFilterKeywordIDs, expectedIDsForFilter.keywordIDs)
actualFilterStatusIDs := []string{}
for _, filterStatus := range filter.Statuses {
actualFilterStatusIDs = append(actualFilterStatusIDs, filterStatus.ID)
}
suite.ElementsMatch(actualFilterStatusIDs, expectedIDsForFilter.statusIDs)
}
suite.ElementsMatch(expectedFilterIDs, actualFilterIDs)
}

View file

@ -0,0 +1,54 @@
// 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 v2
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"
)
func (m *Module) FilterStatusDELETEHandler(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.FiltersV2().StatusDelete(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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"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) deleteFilterStatus(
filterStatusID 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/"+filtersV2.StatusPath+"/"+filterStatusID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterStatusID)
// 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) TestDeleteFilterStatus() {
id := suite.testFilterStatuses["local_account_1_filter_3_status_1"].ID
err := suite.deleteFilterStatus(id, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterStatus() {
id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID
err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestDeleteNonexistentFilterStatus() {
id := "not_even_a_real_ULID"
err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,95 @@
// 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 v2
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"
)
// FilterStatusesGETHandler swagger:operation GET /api/v2/filters/{id}/statuses filterStatusesGet
//
// Get all filter statuses for a given filter.
//
// ---
// 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: filterStatuses
// description: Requested filter statuses.
// schema:
// type: array
// items:
// "$ref": "#/definitions/filterStatus"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FilterStatusesGETHandler(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
}
filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV2().StatusesGetForFilterID(c.Request.Context(), authed.Account, filterID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiFilter)
}

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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) getFilterStatuses(
filterID string,
expectedHTTPStatus int,
expectedBody string,
) ([]*apimodel.FilterStatus, 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/"+filtersV2.BasePath+"/"+filterID+"/statuses", nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterID)
// trigger the handler
suite.filtersModule.FilterStatusesGETHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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 := make([]*apimodel.FilterStatus, 0)
if err := json.Unmarshal(b, &resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestGetFilterStatuses() {
// Collect the sets of filter status IDs we expect to see.
filterID := suite.testFilters["local_account_1_filter_3"].ID
expectedFilterStatusIDs := []string{}
for _, filterStatus := range suite.testFilterStatuses {
if filterStatus.FilterID == filterID {
expectedFilterStatusIDs = append(expectedFilterStatusIDs, filterStatus.ID)
}
}
suite.NotEmpty(expectedFilterStatusIDs)
// Fetch all filter statuses for the test filter.
filterStatuses, err := suite.getFilterStatuses(filterID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.NotEmpty(filterStatuses)
// Check that we got the right ones.
suite.Len(filterStatuses, len(expectedFilterStatusIDs))
actualFilterStatusIDs := []string{}
for _, filterStatus := range filterStatuses {
actualFilterStatusIDs = append(actualFilterStatusIDs, filterStatus.ID)
}
suite.ElementsMatch(expectedFilterStatusIDs, actualFilterStatusIDs)
}

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 v2
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"
)
// FilterStatusGETHandler swagger:operation GET /api/v2/filters/statuses/{id} filterStatusGet
//
// Get a single filter status with the given ID.
//
// ---
// tags:
// - filters
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the filter status
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - read:filters
//
// responses:
// '200':
// name: filterStatus
// description: Requested filter status.
// schema:
// "$ref": "#/definitions/filterStatus"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) FilterStatusGETHandler(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.FiltersV2().StatusGet(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,120 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) getFilterStatus(
filterStatusID string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterStatus, 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/"+filtersV2.StatusPath+"/"+filterStatusID, nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", filterStatusID)
// trigger the handler
suite.filtersModule.FilterStatusGETHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterStatus{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestGetFilterStatus() {
expectedFilterStatus := suite.testFilterStatuses["local_account_1_filter_3_status_1"]
filterStatus, err := suite.getFilterStatus(expectedFilterStatus.ID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.NotEmpty(filterStatus)
suite.Equal(expectedFilterStatus.ID, filterStatus.ID)
suite.Equal(expectedFilterStatus.StatusID, filterStatus.StatusID)
}
func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterStatus() {
id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID
_, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestGetNonexistentFilterStatus() {
id := "not_even_a_real_ULID"
_, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,133 @@
// 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 v2
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"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
// FilterStatusPOSTHandler swagger:operation POST /api/v2/filters/{id}/statuses filterStatusPost
//
// Add a filter status to an existing filter.
//
// ---
// 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 to add the filtered status to.
// -
// name: status_id
// in: formData
// required: true
// description: |-
// The ID of the status to filter.
//
// Sample: 01HXA2NE0K8T1C70K90E74GYD0
// type: string
//
// security:
// - OAuth2 Bearer:
// - write:filters
//
// responses:
// '200':
// name: filterStatus
// description: New filter status.
// schema:
// "$ref": "#/definitions/filterStatus"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: conflict (duplicate status)
// '422':
// description: unprocessable content
// '500':
// description: internal server error
func (m *Module) FilterStatusPOSTHandler(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 authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
form := &apimodel.FilterStatusCreateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateCreateFilterStatus(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiFilter, errWithCode := m.processor.FiltersV2().StatusCreate(c.Request.Context(), authed.Account, filterID, form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiFilter)
}
func validateCreateFilterStatus(form *apimodel.FilterStatusCreateRequest) error {
return validate.ULID(form.StatusID, "status_id")
}

View file

@ -0,0 +1,180 @@
// 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 v2_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
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) postFilterStatus(
filterID string,
statusID *string,
requestJson *string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.FilterStatus, 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/"+filtersV2.BasePath+"/"+filterID+"/statuses", 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 statusID != nil {
ctx.Request.Form["status_id"] = []string{*statusID}
}
}
ctx.AddParam("id", filterID)
// trigger the handler
suite.filtersModule.FilterStatusPOSTHandler(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 expectedBody == "" {
return nil, errs.Combine()
}
}
// 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.FilterStatus{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *FiltersTestSuite) TestPostFilterStatus() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
statusID := suite.testStatuses["admin_account_status_1"].ID
filterStatus, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(statusID, filterStatus.StatusID)
}
func (suite *FiltersTestSuite) TestPostFilterStatusJSON() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
requestJson := `{
"status_id": "01F8MH75CBF9JFX4ZAD54N0W0R"
}`
filterStatus, err := suite.postFilterStatus(filterID, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(suite.testStatuses["admin_account_status_1"].ID, filterStatus.StatusID)
}
func (suite *FiltersTestSuite) TestPostFilterStatusEmptyStatusID() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
statusID := ""
_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id must be provided"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterStatusInvalidStatusID() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
statusID := "112401162517176488" // ma'am, that's clearly a Mastodon ID, this is a Wendy's
_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id didn't match the expected ULID format for an ID (26 characters from the set 0123456789ABCDEFGHJKMNPQRSTVWXYZ)"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterStatusMissingStatusID() {
filterID := suite.testFilters["local_account_1_filter_1"].ID
_, err := suite.postFilterStatus(filterID, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id must be provided"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
// Creating another filter status in the same filter with the same status ID should fail.
func (suite *FiltersTestSuite) TestPostFilterStatusStatusIDConflict() {
filterID := suite.testFilters["local_account_1_filter_3"].ID
statusID := suite.testFilterStatuses["local_account_1_filter_3_status_1"].StatusID
_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusConflict, `{"error":"Conflict: duplicate status"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterStatusAnotherAccountsFilter() {
filterID := suite.testFilters["local_account_2_filter_1"].ID
statusID := suite.testStatuses["admin_account_status_1"].ID
_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *FiltersTestSuite) TestPostFilterStatusNonexistentFilter() {
filterID := "not_even_a_real_ULID"
statusID := suite.testStatuses["admin_account_status_1"].ID
_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -85,7 +85,7 @@ type FilterKeyword struct {
//
// Example: fnord
Keyword string `json:"keyword"`
// Should the filter consider word boundaries?
// Should the filter keyword consider word boundaries?
//
// Example: true
WholeWord bool `json:"whole_word"`
@ -104,3 +104,88 @@ type FilterStatus struct {
// The status ID to be filtered.
StatusID string `json:"phrase"`
}
// FilterCreateRequestV2 captures params for creating a v2 filter.
//
// swagger:ignore
type FilterCreateRequestV2 struct {
// The name of the filter.
//
// Required: true
// Example: fnord
Title string `form:"title" json:"title" xml:"title"`
// The contexts in which the filter should be applied.
//
// Required: true
// Minimum length: 1
// Unique: true
// Enum: home,notifications,public,thread,account
// Example: ["home", "public"]
Context []FilterContext `form:"context[]" json:"context" xml:"context"`
// The action to be taken when a status matches this filter. If omitted, defaults to warn.
// Enum:
// - warn
// - hide
// Example: warn
FilterAction *FilterAction `form:"filter_action" json:"filter_action" xml:"filter_action"`
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
ExpiresIn *int `json:"-" form:"expires_in" xml:"expires_in"`
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
//
// Example: 86400
ExpiresInI interface{} `json:"expires_in"`
}
// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword.
//
// swagger:ignore
type FilterKeywordCreateUpdateRequest struct {
// The text to be filtered.
//
// Example: fnord
// Maximum length: 40
Keyword string `form:"keyword" json:"keyword" xml:"keyword"`
// Should the filter keyword consider word boundaries?
//
// Example: true
WholeWord *bool `form:"whole_word" json:"whole_word" xml:"whole_word"`
}
// FilterStatusCreateRequest captures params for creating a filter status.
//
// swagger:ignore
type FilterStatusCreateRequest struct {
// The status ID to be filtered.
StatusID string `form:"status_id" json:"status_id" xml:"status_id"`
}
// FilterUpdateRequestV2 captures params for creating a v2 filter.
//
// swagger:ignore
type FilterUpdateRequestV2 struct {
// The name of the filter.
//
// Example: illuminati nonsense
Title *string `form:"title" json:"title" xml:"title"`
// The contexts in which the filter should be applied.
//
// Minimum length: 1
// Unique: true
// Enum: home,notifications,public,thread,account
// Example: ["home", "public"]
Context *[]FilterContext `form:"context[]" json:"context" xml:"context"`
// The action to be taken when a status matches this filter.
// Enum:
// - warn
// - hide
// Example: warn
FilterAction *FilterAction `form:"filter_action" json:"filter_action" xml:"filter_action"`
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
ExpiresIn *int `json:"-" form:"expires_in" xml:"expires_in"`
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
//
// Example: 86400
ExpiresInI interface{} `json:"expires_in"`
}

View file

@ -19,6 +19,7 @@
import (
"context"
"errors"
"slices"
"time"
@ -197,10 +198,14 @@ func (f *filterDB) UpdateFilter(
ctx context.Context,
filter *gtsmodel.Filter,
filterColumns []string,
filterKeywordColumns []string,
filterKeywordColumns [][]string,
deleteFilterKeywordIDs []string,
deleteFilterStatusIDs []string,
) error {
if len(filter.Keywords) != len(filterKeywordColumns) {
return errors.New("number of filter keywords must match number of lists of filter keyword columns")
}
updatedAt := time.Now()
filter.UpdatedAt = updatedAt
for _, filterKeyword := range filter.Keywords {
@ -214,8 +219,10 @@ func (f *filterDB) UpdateFilter(
if len(filterColumns) > 0 {
filterColumns = append(filterColumns, "updated_at")
}
if len(filterKeywordColumns) > 0 {
filterKeywordColumns = append(filterKeywordColumns, "updated_at")
for i := range filterKeywordColumns {
if len(filterKeywordColumns[i]) > 0 {
filterKeywordColumns[i] = append(filterKeywordColumns[i], "updated_at")
}
}
// Update database.
@ -229,11 +236,11 @@ func (f *filterDB) UpdateFilter(
return err
}
if len(filter.Keywords) > 0 {
for i, filterKeyword := range filter.Keywords {
if _, err := NewUpsert(tx).
Model(&filter.Keywords).
Model(filterKeyword).
Constraint("id").
Column(filterKeywordColumns...).
Column(filterKeywordColumns[i]...).
Exec(ctx); err != nil {
return err
}

View file

@ -127,7 +127,7 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
}
check.Statuses = append(check.Statuses, newStatus)
if err := suite.db.UpdateFilter(ctx, check, nil, nil, nil, nil); err != nil {
if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{nil, nil}, nil, nil); err != nil {
t.Fatalf("error updating filter: %v", err)
}
// Now fetch newly updated filter.
@ -175,7 +175,7 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
check.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
check.Statuses = nil
if err := suite.db.UpdateFilter(ctx, check, nil, nil, []string{newKeyword.ID}, nil); err != nil {
if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{{"whole_word"}}, []string{newKeyword.ID}, nil); err != nil {
t.Fatalf("error updating filter: %v", err)
}
check, err = suite.db.GetFilterByID(ctx, filter.ID)
@ -222,7 +222,7 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
StatusID: newStatus.StatusID,
}
check.Statuses = []*gtsmodel.FilterStatus{redundantStatus}
if err := suite.db.UpdateFilter(ctx, check, nil, nil, nil, nil); err != nil {
if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{nil}, nil, nil); err != nil {
t.Fatalf("error updating filter: %v", err)
}
check, err = suite.db.GetFilterByID(ctx, filter.ID)

View file

@ -42,11 +42,13 @@ type Filter interface {
// and deletes indicated filter keywords and statuses by ID.
// It uses a transaction to ensure no partial updates.
// The column lists are optional; if not specified, all columns will be updated.
// The filter keyword columns list is *per keyword*.
// To update all keyword columns, provide a list where every element is an empty list.
UpdateFilter(
ctx context.Context,
filter *gtsmodel.Filter,
filterColumns []string,
filterKeywordColumns []string,
filterKeywordColumns [][]string,
deleteFilterKeywordIDs []string,
deleteFilterStatusIDs []string,
) error

View file

@ -81,6 +81,8 @@ type FilterStatus struct {
type FilterAction string
const (
// FilterActionNone filters should not exist, except internally, for partially constructed or invalid filters.
FilterActionNone FilterAction = ""
// FilterActionWarn means that the status should be shown behind a warning.
FilterActionWarn FilterAction = "warn"
// FilterActionHide means that the status should be removed from timeline results entirely.

View file

@ -59,8 +59,8 @@ func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*a
}
apiFilters := make([]*apimodel.FilterV1, 0, len(filters))
for _, list := range filters {
apiFilter, errWithCode := p.apiFilter(ctx, list)
for _, filter := range filters {
apiFilter, errWithCode := p.apiFilter(ctx, filter)
if errWithCode != nil {
return nil, errWithCode
}

View file

@ -149,9 +149,11 @@ func (p *Processor) Update(
"context_thread",
"context_account",
}
filterKeywordColumns := []string{
filterKeywordColumns := [][]string{
{
"keyword",
"whole_word",
},
}
if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, nil, nil); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {

View file

@ -0,0 +1,38 @@
// 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 v2
import (
"context"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// apiFilter is a shortcut to return the API v2 filter version of the given
// filter, or return an appropriate error if conversion fails.
func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.Filter) (*apimodel.FilterV2, gtserror.WithCode) {
apiFilter, err := p.converter.FilterToAPIFilterV2(ctx, filterKeyword)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter to API v2 filter: %w", err))
}
return apiFilter, nil
}

View file

@ -0,0 +1,75 @@
// 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 v2
import (
"context"
"errors"
"fmt"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// Create a new filter for the given account, using the provided parameters.
// These params should have already been validated by the time they reach this function.
func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateRequestV2) (*apimodel.FilterV2, gtserror.WithCode) {
filter := &gtsmodel.Filter{
ID: id.NewULID(),
AccountID: account.ID,
Title: form.Title,
Action: typeutils.APIFilterActionToFilterAction(*form.FilterAction),
}
if form.ExpiresIn != nil {
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
for _, context := range form.Context {
switch context {
case apimodel.FilterContextHome:
filter.ContextHome = util.Ptr(true)
case apimodel.FilterContextNotifications:
filter.ContextNotifications = util.Ptr(true)
case apimodel.FilterContextPublic:
filter.ContextPublic = util.Ptr(true)
case apimodel.FilterContextThread:
filter.ContextThread = util.Ptr(true)
case apimodel.FilterContextAccount:
filter.ContextAccount = util.Ptr(true)
default:
return nil, gtserror.NewErrorUnprocessableEntity(
fmt.Errorf("unsupported filter context '%s'", context),
)
}
}
if err := p.state.DB.PutFilter(ctx, filter); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("duplicate title, keyword, or status")
return nil, gtserror.NewErrorConflict(err, err.Error())
}
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiFilter(ctx, filter)
}

View file

@ -0,0 +1,53 @@
// 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 v2
import (
"context"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Delete an existing filter and all its attached keywords and statuses for the given account.
func (p *Processor) Delete(
ctx context.Context,
account *gtsmodel.Account,
filterID string,
) gtserror.WithCode {
// Get the filter for this keyword.
filter, err := p.state.DB.GetFilterByID(ctx, filterID)
if err != nil {
return gtserror.NewErrorNotFound(err)
}
// Check that the account owns it.
if filter.AccountID != account.ID {
return gtserror.NewErrorNotFound(
fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
)
}
// Delete the entire filter.
if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil {
return gtserror.NewErrorInternalError(err)
}
return nil
}

View file

@ -0,0 +1,35 @@
// 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 v2
import (
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
type Processor struct {
state *state.State
converter *typeutils.Converter
}
func New(state *state.State, converter *typeutils.Converter) Processor {
return Processor{
state: state,
converter: converter,
}
}

View file

@ -0,0 +1,81 @@
// 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 v2
import (
"context"
"errors"
"fmt"
"slices"
"strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Get looks up a filter by ID and returns it with keywords and statuses.
func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterID string) (*apimodel.FilterV2, gtserror.WithCode) {
filter, err := p.state.DB.GetFilterByID(ctx, filterID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filter.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
)
}
return p.apiFilter(ctx, filter)
}
// GetAll looks up all filters for the current account and returns them with keywords and statuses.
func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.FilterV2, gtserror.WithCode) {
filters, err := p.state.DB.GetFiltersForAccountID(
ctx,
account.ID,
)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, nil
}
return nil, gtserror.NewErrorInternalError(err)
}
apiFilters := make([]*apimodel.FilterV2, 0, len(filters))
for _, filter := range filters {
apiFilter, errWithCode := p.apiFilter(ctx, filter)
if errWithCode != nil {
return nil, errWithCode
}
apiFilters = append(apiFilters, apiFilter)
}
// Sort them by ID so that they're in a stable order.
// Clients may opt to sort them lexically in a locale-aware manner.
slices.SortFunc(apiFilters, func(lhs *apimodel.FilterV2, rhs *apimodel.FilterV2) int {
return strings.Compare(lhs.ID, rhs.ID)
})
return apiFilters, nil
}

View file

@ -0,0 +1,67 @@
// 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 v2
import (
"context"
"errors"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
)
// KeywordCreate adds a filter keyword to an existing filter for the given account, using the provided parameters.
// These params should have already been normalized and validated by the time they reach this function.
func (p *Processor) KeywordCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterKeywordCreateUpdateRequest) (*apimodel.FilterKeyword, gtserror.WithCode) {
// Check that the filter is owned by the given account.
filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filter.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
)
}
filterKeyword := &gtsmodel.FilterKeyword{
ID: id.NewULID(),
AccountID: account.ID,
FilterID: filter.ID,
Keyword: form.Keyword,
WholeWord: form.WholeWord,
}
if err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("duplicate keyword")
return nil, gtserror.NewErrorConflict(err, err.Error())
}
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil
}

View file

@ -0,0 +1,53 @@
// 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 v2
import (
"context"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// KeywordDelete deletes an existing filter keyword from a filter.
func (p *Processor) KeywordDelete(
ctx context.Context,
account *gtsmodel.Account,
filterID string,
) gtserror.WithCode {
// Get the filter keyword.
filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterID)
if err != nil {
return gtserror.NewErrorNotFound(err)
}
// Check that the account owns it.
if filterKeyword.AccountID != account.ID {
return gtserror.NewErrorNotFound(
fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID),
)
}
// Delete the filter keyword.
if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil {
return gtserror.NewErrorInternalError(err)
}
return nil
}

View file

@ -0,0 +1,89 @@
// 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 v2
import (
"context"
"errors"
"fmt"
"slices"
"strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// KeywordGet looks up a filter keyword by ID.
func (p *Processor) KeywordGet(ctx context.Context, account *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterKeyword, gtserror.WithCode) {
filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterKeywordID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filterKeyword.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID),
)
}
return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil
}
// KeywordsGetForFilterID looks up all filter keywords for the given filter.
func (p *Processor) KeywordsGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterKeyword, gtserror.WithCode) {
// Check that the filter is owned by the given account.
filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filter.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(nil)
}
filterKeywords, err := p.state.DB.GetFilterKeywordsForFilterID(
ctx,
filter.ID,
)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, nil
}
return nil, gtserror.NewErrorInternalError(err)
}
apiFilterKeywords := make([]*apimodel.FilterKeyword, 0, len(filterKeywords))
for _, filterKeyword := range filterKeywords {
apiFilterKeywords = append(apiFilterKeywords, p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword))
}
// Sort them by ID so that they're in a stable order.
// Clients may opt to sort them lexically in a locale-aware manner.
slices.SortFunc(apiFilterKeywords, func(lhs *apimodel.FilterKeyword, rhs *apimodel.FilterKeyword) int {
return strings.Compare(lhs.ID, rhs.ID)
})
return apiFilterKeywords, nil
}

View file

@ -0,0 +1,66 @@
// 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 v2
import (
"context"
"errors"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// KeywordUpdate updates an existing filter keyword for the given account, using the provided parameters.
// These params should have already been validated by the time they reach this function.
func (p *Processor) KeywordUpdate(
ctx context.Context,
account *gtsmodel.Account,
filterKeywordID string,
form *apimodel.FilterKeywordCreateUpdateRequest,
) (*apimodel.FilterKeyword, gtserror.WithCode) {
// Get the filter keyword by ID.
filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filterKeyword.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID),
)
}
filterKeyword.Keyword = form.Keyword
filterKeyword.WholeWord = form.WholeWord
if err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, "keyword", "whole_word"); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("duplicate keyword")
return nil, gtserror.NewErrorConflict(err, err.Error())
}
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil
}

View file

@ -0,0 +1,66 @@
// 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 v2
import (
"context"
"errors"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
)
// StatusCreate adds a filter status to an existing filter for the given account, using the provided parameters.
// These params should have already been validated by the time they reach this function.
func (p *Processor) StatusCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterStatusCreateRequest) (*apimodel.FilterStatus, gtserror.WithCode) {
// Check that the filter is owned by the given account.
filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filter.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
)
}
filterStatus := &gtsmodel.FilterStatus{
ID: id.NewULID(),
AccountID: account.ID,
FilterID: filter.ID,
StatusID: form.StatusID,
}
if err := p.state.DB.PutFilterStatus(ctx, filterStatus); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("duplicate status")
return nil, gtserror.NewErrorConflict(err, err.Error())
}
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil
}

View file

@ -0,0 +1,53 @@
// 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 v2
import (
"context"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// StatusDelete deletes an existing filter status from a filter.
func (p *Processor) StatusDelete(
ctx context.Context,
account *gtsmodel.Account,
filterID string,
) gtserror.WithCode {
// Get the filter status.
filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterID)
if err != nil {
return gtserror.NewErrorNotFound(err)
}
// Check that the account owns it.
if filterStatus.AccountID != account.ID {
return gtserror.NewErrorNotFound(
fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID),
)
}
// Delete the filter status.
if err := p.state.DB.DeleteFilterStatusByID(ctx, filterStatus.ID); err != nil {
return gtserror.NewErrorInternalError(err)
}
return nil
}

View file

@ -0,0 +1,89 @@
// 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 v2
import (
"context"
"errors"
"fmt"
"slices"
"strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// StatusGet looks up a filter status by ID.
func (p *Processor) StatusGet(ctx context.Context, account *gtsmodel.Account, filterStatusID string) (*apimodel.FilterStatus, gtserror.WithCode) {
filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterStatusID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filterStatus.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID),
)
}
return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil
}
// StatusesGetForFilterID looks up all filter statuses for the given filter.
func (p *Processor) StatusesGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterStatus, gtserror.WithCode) {
// Check that the filter is owned by the given account.
filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filter.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(nil)
}
filterStatuses, err := p.state.DB.GetFilterStatusesForFilterID(
ctx,
filter.ID,
)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, nil
}
return nil, gtserror.NewErrorInternalError(err)
}
apiFilterStatuses := make([]*apimodel.FilterStatus, 0, len(filterStatuses))
for _, filterStatus := range filterStatuses {
apiFilterStatuses = append(apiFilterStatuses, p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus))
}
// Sort them by ID so that they're in a stable order.
// Clients may opt to sort them by status ID instead.
slices.SortFunc(apiFilterStatuses, func(lhs *apimodel.FilterStatus, rhs *apimodel.FilterStatus) int {
return strings.Compare(lhs.ID, rhs.ID)
})
return apiFilterStatuses, nil
}

View file

@ -0,0 +1,125 @@
// 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 v2
import (
"context"
"errors"
"fmt"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// Update an existing filter for the given account, using the provided parameters.
// These params should have already been validated by the time they reach this function.
func (p *Processor) Update(
ctx context.Context,
account *gtsmodel.Account,
filterID string,
form *apimodel.FilterUpdateRequestV2,
) (*apimodel.FilterV2, gtserror.WithCode) {
// Get the filter by ID, with existing keywords and statuses.
filter, err := p.state.DB.GetFilterByID(ctx, filterID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
if filter.AccountID != account.ID {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
)
}
// Filter columns that we're going to update.
filterColumns := []string{}
// Apply filter changes.
if form.Title != nil {
filterColumns = append(filterColumns, "title")
filter.Title = *form.Title
}
if form.FilterAction != nil {
filterColumns = append(filterColumns, "action")
filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction)
}
// TODO: (Vyr) is it possible to unset a filter expiration with this API?
if form.ExpiresIn != nil {
filterColumns = append(filterColumns, "expires_at")
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
if form.Context != nil {
filterColumns = append(filterColumns,
"context_home",
"context_notifications",
"context_public",
"context_thread",
"context_account",
)
filter.ContextHome = util.Ptr(false)
filter.ContextNotifications = util.Ptr(false)
filter.ContextPublic = util.Ptr(false)
filter.ContextThread = util.Ptr(false)
filter.ContextAccount = util.Ptr(false)
for _, context := range *form.Context {
switch context {
case apimodel.FilterContextHome:
filter.ContextHome = util.Ptr(true)
case apimodel.FilterContextNotifications:
filter.ContextNotifications = util.Ptr(true)
case apimodel.FilterContextPublic:
filter.ContextPublic = util.Ptr(true)
case apimodel.FilterContextThread:
filter.ContextThread = util.Ptr(true)
case apimodel.FilterContextAccount:
filter.ContextAccount = util.Ptr(true)
default:
return nil, gtserror.NewErrorUnprocessableEntity(
fmt.Errorf("unsupported filter context '%s'", context),
)
}
}
}
// Temporarily detach keywords and statuses from filter, since we're not updating them below.
filterKeywords := filter.Keywords
filterStatuses := filter.Statuses
filter.Keywords = nil
filter.Statuses = nil
if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, nil, nil, nil); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("you already have a filter with this title")
return nil, gtserror.NewErrorConflict(err, err.Error())
}
return nil, gtserror.NewErrorInternalError(err)
}
// Re-attach keywords and statuses before returning.
filter.Keywords = filterKeywords
filter.Statuses = filterStatuses
return p.apiFilter(ctx, filter)
}

View file

@ -30,6 +30,7 @@
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/processing/fedi"
filtersv1 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v1"
filtersv2 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v2"
"github.com/superseriousbusiness/gotosocial/internal/processing/list"
"github.com/superseriousbusiness/gotosocial/internal/processing/markers"
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
@ -73,6 +74,7 @@ type Processor struct {
admin admin.Processor
fedi fedi.Processor
filtersv1 filtersv1.Processor
filtersv2 filtersv2.Processor
list list.Processor
markers markers.Processor
media media.Processor
@ -102,6 +104,10 @@ func (p *Processor) FiltersV1() *filtersv1.Processor {
return &p.filtersv1
}
func (p *Processor) FiltersV2() *filtersv2.Processor {
return &p.filtersv2
}
func (p *Processor) List() *list.Processor {
return &p.list
}
@ -184,6 +190,7 @@ func NewProcessor(
processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender)
processor.fedi = fedi.New(state, &common, converter, federator, filter)
processor.filtersv1 = filtersv1.New(state, converter)
processor.filtersv2 = filtersv2.New(state, converter)
processor.list = list.New(state, converter)
processor.markers = markers.New(state, converter)
processor.polls = polls.New(&common, state, converter)

View file

@ -47,3 +47,13 @@ func APIMarkerNameToMarkerName(m apimodel.MarkerName) gtsmodel.MarkerName {
}
return ""
}
func APIFilterActionToFilterAction(m apimodel.FilterAction) gtsmodel.FilterAction {
switch m {
case apimodel.FilterActionWarn:
return gtsmodel.FilterActionWarn
case apimodel.FilterActionHide:
return gtsmodel.FilterActionHide
}
return gtsmodel.FilterActionNone
}

View file

@ -1852,19 +1852,12 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
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),
})
apiFilterKeywords = append(apiFilterKeywords, *c.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword))
}
apiFilterStatuses := make([]apimodel.FilterStatus, 0, len(filter.Keywords))
for _, filterStatus := range filter.Statuses {
apiFilterStatuses = append(apiFilterStatuses, apimodel.FilterStatus{
ID: filterStatus.ID,
StatusID: filterStatus.StatusID,
})
apiFilterStatuses = append(apiFilterStatuses, *c.FilterStatusToAPIFilterStatus(ctx, filterStatus))
}
return &apimodel.FilterV2{
@ -1915,6 +1908,23 @@ func filterActionToAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterActio
return apimodel.FilterActionNone
}
// FilterKeywordToAPIFilterKeyword converts a GTS model filter status into an API filter status.
func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) *apimodel.FilterKeyword {
return &apimodel.FilterKeyword{
ID: filterKeyword.ID,
Keyword: filterKeyword.Keyword,
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
}
}
// FilterStatusToAPIFilterStatus converts a GTS model filter status into an API filter status.
func (c *Converter) FilterStatusToAPIFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) *apimodel.FilterStatus {
return &apimodel.FilterStatus{
ID: filterStatus.ID,
StatusID: filterStatus.StatusID,
}
}
// convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied.
func (c *Converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]apimodel.Emoji, error) {
var errs gtserror.MultiError

View file

@ -45,6 +45,7 @@
maximumProfileFields = 6
maximumListTitleLength = 200
maximumFilterKeywordLength = 40
maximumFilterTitleLength = 200
)
// Password returns a helpful error if the given password
@ -242,9 +243,16 @@ func SiteTerms(t string) error {
return nil
}
// ULID returns true if the passed string is a valid ULID.
func ULID(i string) bool {
return regexes.ULID.MatchString(i)
// ULID returns an error if the passed string is not a valid ULID.
// The name param is used to form error messages.
func ULID(i string, name string) error {
if i == "" {
return fmt.Errorf("%s must be provided", name)
}
if !regexes.ULID.MatchString(i) {
return fmt.Errorf("%s didn't match the expected ULID format for an ID (26 characters from the set 0123456789ABCDEFGHJKMNPQRSTVWXYZ)", name)
}
return nil
}
// ProfileFields validates the length of provided fields slice,
@ -308,7 +316,7 @@ func MarkerName(name string) error {
return fmt.Errorf("marker timeline name '%s' was not recognized, valid options are '%s', '%s'", name, apimodel.MarkerNameHome, apimodel.MarkerNameNotifications)
}
// FilterKeyword validates the title of a new or updated List.
// FilterKeyword validates a filter keyword.
func FilterKeyword(keyword string) error {
if keyword == "" {
return fmt.Errorf("filter keyword must be provided, and must be no more than %d chars", maximumFilterKeywordLength)
@ -321,6 +329,19 @@ func FilterKeyword(keyword string) error {
return nil
}
// FilterTitle validates the title of a new or updated filter.
func FilterTitle(title string) error {
if title == "" {
return fmt.Errorf("filter title must be provided, and must be no more than %d chars", maximumFilterTitleLength)
}
if length := len([]rune(title)); length > maximumFilterTitleLength {
return fmt.Errorf("filter title length must be no more than %d chars, provided title was %d chars", maximumFilterTitleLength, length)
}
return nil
}
// FilterContexts validates the context of a new or updated filter.
func FilterContexts(contexts []apimodel.FilterContext) error {
if len(contexts) == 0 {
@ -349,6 +370,20 @@ func FilterContexts(contexts []apimodel.FilterContext) error {
return nil
}
func FilterAction(action apimodel.FilterAction) error {
switch action {
case apimodel.FilterActionWarn,
apimodel.FilterActionHide:
return nil
}
return fmt.Errorf(
"filter action '%s' was not recognized, valid options are '%s', '%s'",
action,
apimodel.FilterActionWarn,
apimodel.FilterActionHide,
)
}
// CreateAccount checks through all the prerequisites for
// creating a new account, according to the provided form.
// If the account isn't eligible, an error will be returned.

View file

@ -3288,6 +3288,26 @@ func NewTestFilters() map[string]*gtsmodel.Filter {
ContextHome: util.Ptr(true),
ContextPublic: util.Ptr(true),
},
"local_account_1_filter_3": {
ID: "01HWXQDXE4QX4R9EGMG729Y76C",
CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
Title: "puppies",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
ContextPublic: util.Ptr(true),
},
"local_account_1_filter_4": {
ID: "01HZ55WWWP82WYP2A1BKWK8Y9Q",
CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
Title: "empty filter with no keywords or statuses",
Action: gtsmodel.FilterActionWarn,
ContextHome: util.Ptr(true),
ContextPublic: util.Ptr(true),
},
"local_account_2_filter_1": {
ID: "01HNGFYJBED9FS0VWRVMY4TKXH",
CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
@ -3330,6 +3350,15 @@ func NewTestFilterKeywords() map[string]*gtsmodel.FilterKeyword {
Keyword: "bar",
WholeWord: util.Ptr(true),
},
"local_account_1_filter_2_keyword_3": {
ID: "01HXATJTGYT4BTG2YASE5M7GSD",
CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
FilterID: "01HN277FSPQAWXZXK92QPPYF79",
Keyword: "quux",
WholeWord: util.Ptr(true),
},
"local_account_2_filter_1_keyword_1": {
ID: "01HNGG51HV2JT67XQ5MQ7RA1WE",
CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
@ -3343,8 +3372,24 @@ func NewTestFilterKeywords() map[string]*gtsmodel.FilterKeyword {
}
func NewTestFilterStatuses() map[string]*gtsmodel.FilterStatus {
// FUTURE: (filters v2) test filter statuses
return map[string]*gtsmodel.FilterStatus{}
return map[string]*gtsmodel.FilterStatus{
"local_account_1_filter_3_status_1": {
ID: "01HWXQDY8EE182AWQKS45JV50W",
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
FilterID: "01HWXQDXE4QX4R9EGMG729Y76C",
StatusID: "01F8MHAAY43M6RJ473VQFCVH37",
},
"local_account_2_filter_1_status_1": {
ID: "01HX9WXVEH05E78ABR81FZFFFY",
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
AccountID: "01F8MH1VYJAE00TVVGMM5JNJ8X",
FilterID: "01HNGFYJBED9FS0VWRVMY4TKXH",
StatusID: "01FVW7JHQFSFK166WWKR8CBA6M",
},
}
}
// GetSignatureForActivity prepares a mock HTTP request as if it were going to deliver activity to destination signed for privkey and pubKeyID, signs the request and returns the header values.