diff options
Diffstat (limited to 'internal/api')
41 files changed, 4012 insertions, 23 deletions
diff --git a/internal/api/client.go b/internal/api/client.go index ce95e3dfc..07fc82aaa 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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), diff --git a/internal/api/client/filters/v1/filterdelete.go b/internal/api/client/filters/v1/filterdelete.go index d86b277a6..267dd16d0 100644 --- a/internal/api/client/filters/v1/filterdelete.go +++ b/internal/api/client/filters/v1/filterdelete.go @@ -41,7 +41,7 @@ import ( // - // name: id // type: string -// description: ID of the list +// description: ID of the filter // in: path // required: true // diff --git a/internal/api/client/filters/v1/filterdelete_test.go b/internal/api/client/filters/v1/filterdelete_test.go index 83155f08a..20fd4351b 100644 --- a/internal/api/client/filters/v1/filterdelete_test.go +++ b/internal/api/client/filters/v1/filterdelete_test.go @@ -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 diff --git a/internal/api/client/filters/v1/filterget_test.go b/internal/api/client/filters/v1/filterget_test.go index a9dbf6dbb..e8fdedfaa 100644 --- a/internal/api/client/filters/v1/filterget_test.go +++ b/internal/api/client/filters/v1/filterget_test.go @@ -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 diff --git a/internal/api/client/filters/v1/filterpost.go b/internal/api/client/filters/v1/filterpost.go index 2d19f69cf..9d92b9187 100644 --- a/internal/api/client/filters/v1/filterpost.go +++ b/internal/api/client/filters/v1/filterpost.go @@ -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': diff --git a/internal/api/client/filters/v1/filterpost_test.go b/internal/api/client/filters/v1/filterpost_test.go index 729b2bd72..893415d99 100644 --- a/internal/api/client/filters/v1/filterpost_test.go +++ b/internal/api/client/filters/v1/filterpost_test.go @@ -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()) - } -} diff --git a/internal/api/client/filters/v1/filterput.go b/internal/api/client/filters/v1/filterput.go index bb9fa809f..2a81f89fc 100644 --- a/internal/api/client/filters/v1/filterput.go +++ b/internal/api/client/filters/v1/filterput.go @@ -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': diff --git a/internal/api/client/filters/v1/filterput_test.go b/internal/api/client/filters/v1/filterput_test.go index 0308e53d9..d810930d6 100644 --- a/internal/api/client/filters/v1/filterput_test.go +++ b/internal/api/client/filters/v1/filterput_test.go @@ -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" diff --git a/internal/api/client/filters/v1/filtersget_test.go b/internal/api/client/filters/v1/filtersget_test.go index a568239ef..281ee4f63 100644 --- a/internal/api/client/filters/v1/filtersget_test.go +++ b/internal/api/client/filters/v1/filtersget_test.go @@ -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 diff --git a/internal/api/client/filters/v1/validate.go b/internal/api/client/filters/v1/validate.go index b539c9563..550df54fa 100644 --- a/internal/api/client/filters/v1/validate.go +++ b/internal/api/client/filters/v1/validate.go @@ -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 } diff --git a/internal/api/client/filters/v2/filter.go b/internal/api/client/filters/v2/filter.go new file mode 100644 index 000000000..58e7905ae --- /dev/null +++ b/internal/api/client/filters/v2/filter.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filter_test.go b/internal/api/client/filters/v2/filter_test.go new file mode 100644 index 000000000..f85357482 --- /dev/null +++ b/internal/api/client/filters/v2/filter_test.go @@ -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)) +} diff --git a/internal/api/client/filters/v2/filterdelete.go b/internal/api/client/filters/v2/filterdelete.go new file mode 100644 index 000000000..7292fd631 --- /dev/null +++ b/internal/api/client/filters/v2/filterdelete.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterdelete_test.go b/internal/api/client/filters/v2/filterdelete_test.go new file mode 100644 index 000000000..ff9bf23f5 --- /dev/null +++ b/internal/api/client/filters/v2/filterdelete_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterget.go b/internal/api/client/filters/v2/filterget.go new file mode 100644 index 000000000..a3481e0e0 --- /dev/null +++ b/internal/api/client/filters/v2/filterget.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterget_test.go b/internal/api/client/filters/v2/filterget_test.go new file mode 100644 index 000000000..256732132 --- /dev/null +++ b/internal/api/client/filters/v2/filterget_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterkeyworddelete.go b/internal/api/client/filters/v2/filterkeyworddelete.go new file mode 100644 index 000000000..41ef12bfb --- /dev/null +++ b/internal/api/client/filters/v2/filterkeyworddelete.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterkeyworddelete_test.go b/internal/api/client/filters/v2/filterkeyworddelete_test.go new file mode 100644 index 000000000..fc949593d --- /dev/null +++ b/internal/api/client/filters/v2/filterkeyworddelete_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterkeywordget.go b/internal/api/client/filters/v2/filterkeywordget.go new file mode 100644 index 000000000..2df6fd10a --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordget.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterkeywordget_test.go b/internal/api/client/filters/v2/filterkeywordget_test.go new file mode 100644 index 000000000..a5d8754a6 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordget_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterkeywordpost.go b/internal/api/client/filters/v2/filterkeywordpost.go new file mode 100644 index 000000000..7ec595820 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordpost.go @@ -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 +} diff --git a/internal/api/client/filters/v2/filterkeywordpost_test.go b/internal/api/client/filters/v2/filterkeywordpost_test.go new file mode 100644 index 000000000..85cc72f05 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordpost_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterkeywordput.go b/internal/api/client/filters/v2/filterkeywordput.go new file mode 100644 index 000000000..5ef0fe976 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordput.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterkeywordput_test.go b/internal/api/client/filters/v2/filterkeywordput_test.go new file mode 100644 index 000000000..55253066d --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordput_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterkeywordsget.go b/internal/api/client/filters/v2/filterkeywordsget.go new file mode 100644 index 000000000..3414c5d8c --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordsget.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterkeywordsget_test.go b/internal/api/client/filters/v2/filterkeywordsget_test.go new file mode 100644 index 000000000..0b0b69e03 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordsget_test.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go new file mode 100644 index 000000000..cbe499fa6 --- /dev/null +++ b/internal/api/client/filters/v2/filterpost.go @@ -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 +} diff --git a/internal/api/client/filters/v2/filterpost_test.go b/internal/api/client/filters/v2/filterpost_test.go new file mode 100644 index 000000000..cad803895 --- /dev/null +++ b/internal/api/client/filters/v2/filterpost_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterput.go b/internal/api/client/filters/v2/filterput.go new file mode 100644 index 000000000..e24ec0b4d --- /dev/null +++ b/internal/api/client/filters/v2/filterput.go @@ -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 +} diff --git a/internal/api/client/filters/v2/filterput_test.go b/internal/api/client/filters/v2/filterput_test.go new file mode 100644 index 000000000..8b4576abe --- /dev/null +++ b/internal/api/client/filters/v2/filterput_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filtersget.go b/internal/api/client/filters/v2/filtersget.go new file mode 100644 index 000000000..511a62d36 --- /dev/null +++ b/internal/api/client/filters/v2/filtersget.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filtersget_test.go b/internal/api/client/filters/v2/filtersget_test.go new file mode 100644 index 000000000..b77df42a6 --- /dev/null +++ b/internal/api/client/filters/v2/filtersget_test.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterstatusdelete.go b/internal/api/client/filters/v2/filterstatusdelete.go new file mode 100644 index 000000000..e10125a32 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusdelete.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterstatusdelete_test.go b/internal/api/client/filters/v2/filterstatusdelete_test.go new file mode 100644 index 000000000..c6627b728 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusdelete_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterstatusesget.go b/internal/api/client/filters/v2/filterstatusesget.go new file mode 100644 index 000000000..3b05ca73d --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusesget.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterstatusesget_test.go b/internal/api/client/filters/v2/filterstatusesget_test.go new file mode 100644 index 000000000..6b8262f26 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusesget_test.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterstatusget.go b/internal/api/client/filters/v2/filterstatusget.go new file mode 100644 index 000000000..9e62e4466 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusget.go @@ -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) +} diff --git a/internal/api/client/filters/v2/filterstatusget_test.go b/internal/api/client/filters/v2/filterstatusget_test.go new file mode 100644 index 000000000..5df3971a8 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusget_test.go @@ -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()) + } +} diff --git a/internal/api/client/filters/v2/filterstatuspost.go b/internal/api/client/filters/v2/filterstatuspost.go new file mode 100644 index 000000000..2a763197d --- /dev/null +++ b/internal/api/client/filters/v2/filterstatuspost.go @@ -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") +} diff --git a/internal/api/client/filters/v2/filterstatuspost_test.go b/internal/api/client/filters/v2/filterstatuspost_test.go new file mode 100644 index 000000000..924b8ecc2 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatuspost_test.go @@ -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()) + } +} diff --git a/internal/api/model/filterv2.go b/internal/api/model/filterv2.go index 797c97213..51dabacb2 100644 --- a/internal/api/model/filterv2.go +++ b/internal/api/model/filterv2.go @@ -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"` +} |