[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
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

@ -31,6 +31,7 @@ import (
"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 @@ import (
// -
// 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 @@ import (
// The text to be filtered.
//
// Sample: fnord
// minLength: 1
// maxLength: 40
// type: string
// -
@ -120,6 +121,8 @@ import (
// 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 @@ import (
// The text to be filtered.
//
// Sample: fnord
// minLength: 1
// maxLength: 40
// type: string
// -
@ -126,6 +127,8 @@ import (
// 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"`
}