summaryrefslogtreecommitdiff
path: root/internal/api/client/filters
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/client/filters')
-rw-r--r--internal/api/client/filters/v1/filter.go (renamed from internal/api/client/filters/filter.go)13
-rw-r--r--internal/api/client/filters/v1/filter_test.go117
-rw-r--r--internal/api/client/filters/v1/filterdelete.go90
-rw-r--r--internal/api/client/filters/v1/filterdelete_test.go112
-rw-r--r--internal/api/client/filters/v1/filterget.go93
-rw-r--r--internal/api/client/filters/v1/filterget_test.go121
-rw-r--r--internal/api/client/filters/v1/filterpost.go147
-rw-r--r--internal/api/client/filters/v1/filterpost_test.go239
-rw-r--r--internal/api/client/filters/v1/filterput.go159
-rw-r--r--internal/api/client/filters/v1/filterput_test.go269
-rw-r--r--internal/api/client/filters/v1/filtersget.go (renamed from internal/api/client/filters/filtersget.go)45
-rw-r--r--internal/api/client/filters/v1/filtersget_test.go114
-rw-r--r--internal/api/client/filters/v1/validate.go68
13 files changed, 1580 insertions, 7 deletions
diff --git a/internal/api/client/filters/filter.go b/internal/api/client/filters/v1/filter.go
index 68c99e825..9daeb75d3 100644
--- a/internal/api/client/filters/filter.go
+++ b/internal/api/client/filters/v1/filter.go
@@ -15,20 +15,23 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
-package filter
+package v1
import (
- "net/http"
-
"github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
+ "net/http"
)
const (
// BasePath is the base path for serving the filters API, minus the 'api' prefix
BasePath = "/v1/filters"
+ // BasePathWithID is the base path with the ID key in it, for operations on an existing filter.
+ BasePathWithID = BasePath + "/:" + apiutil.IDKey
)
+// Module implements APIs for client-side aka "v1" filtering.
type Module struct {
processor *processing.Processor
}
@@ -41,4 +44,8 @@ func New(processor *processing.Processor) *Module {
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodGet, BasePath, m.FiltersGETHandler)
+ attachHandler(http.MethodPost, BasePath, m.FilterPOSTHandler)
+ attachHandler(http.MethodGet, BasePathWithID, m.FilterGETHandler)
+ attachHandler(http.MethodPut, BasePathWithID, m.FilterPUTHandler)
+ attachHandler(http.MethodDelete, BasePathWithID, m.FilterDELETEHandler)
}
diff --git a/internal/api/client/filters/v1/filter_test.go b/internal/api/client/filters/v1/filter_test.go
new file mode 100644
index 000000000..c92e22a05
--- /dev/null
+++ b/internal/api/client/filters/v1/filter_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 v1_test
+
+import (
+ "github.com/stretchr/testify/suite"
+ filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+ "testing"
+)
+
+type FiltersTestSuite struct {
+ suite.Suite
+ db db.DB
+ storage *storage.Driver
+ mediaManager *media.Manager
+ federator *federation.Federator
+ processor *processing.Processor
+ emailSender email.Sender
+ sentEmails map[string]string
+ state state.State
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testStatuses map[string]*gtsmodel.Status
+ testFilters map[string]*gtsmodel.Filter
+ testFilterKeywords map[string]*gtsmodel.FilterKeyword
+ testFilterStatuses map[string]*gtsmodel.FilterStatus
+
+ // module being tested
+ filtersModule *filtersV1.Module
+}
+
+func (suite *FiltersTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testStatuses = testrig.NewTestStatuses()
+ suite.testFilters = testrig.NewTestFilters()
+ suite.testFilterKeywords = testrig.NewTestFilterKeywords()
+ suite.testFilterStatuses = testrig.NewTestFilterStatuses()
+}
+
+func (suite *FiltersTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+ testrig.StartNoopWorkers(&suite.state)
+
+ testrig.InitTestConfig()
+ config.Config(func(cfg *config.Configuration) {
+ cfg.WebAssetBaseDir = "../../../../../web/assets/"
+ cfg.WebTemplateBaseDir = "../../../../../web/templates/"
+ })
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB(&suite.state)
+ suite.state.DB = suite.db
+ suite.storage = testrig.NewInMemoryStorage()
+ suite.state.Storage = suite.storage
+
+ testrig.StartTimelines(
+ &suite.state,
+ visibility.NewFilter(&suite.state),
+ typeutils.NewConverter(&suite.state),
+ )
+
+ suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
+ suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager)
+ suite.sentEmails = make(map[string]string)
+ suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails)
+ suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.filtersModule = filtersV1.New(suite.processor)
+
+ testrig.StandardDBSetup(suite.db, nil)
+ testrig.StandardStorageSetup(suite.storage, "../../../../../testrig/media")
+}
+
+func (suite *FiltersTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+ testrig.StopWorkers(&suite.state)
+}
+
+func TestFiltersTestSuite(t *testing.T) {
+ suite.Run(t, new(FiltersTestSuite))
+}
diff --git a/internal/api/client/filters/v1/filterdelete.go b/internal/api/client/filters/v1/filterdelete.go
new file mode 100644
index 000000000..d86b277a6
--- /dev/null
+++ b/internal/api/client/filters/v1/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 v1
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// FilterDELETEHandler swagger:operation DELETE /api/v1/filters/{id} filterV1Delete
+//
+// Delete a single filter with the given ID.
+//
+// ---
+// tags:
+// - filters
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: ID of the list
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:filters
+//
+// responses:
+// '200':
+// description: filter deleted
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) FilterDELETEHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ errWithCode = m.processor.FiltersV1().Delete(c.Request.Context(), authed.Account, id)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiutil.EmptyJSONObject)
+}
diff --git a/internal/api/client/filters/v1/filterdelete_test.go b/internal/api/client/filters/v1/filterdelete_test.go
new file mode 100644
index 000000000..83155f08a
--- /dev/null
+++ b/internal/api/client/filters/v1/filterdelete_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 v1_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+
+ filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *FiltersTestSuite) deleteFilter(
+ filterKeywordID string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) error {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath+"/"+filterKeywordID, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ ctx.AddParam("id", filterKeywordID)
+
+ // trigger the handler
+ suite.filtersModule.FilterDELETEHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return errs.Combine()
+ }
+
+ resp := &struct{}{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (suite *FiltersTestSuite) TestDeleteFilter() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+
+ err := suite.deleteFilter(id, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() {
+ id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
+
+ err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestDeleteNonexistentFilter() {
+ id := "not_even_a_real_ULID"
+
+ err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
diff --git a/internal/api/client/filters/v1/filterget.go b/internal/api/client/filters/v1/filterget.go
new file mode 100644
index 000000000..35c44b60c
--- /dev/null
+++ b/internal/api/client/filters/v1/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 v1
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// FilterGETHandler swagger:operation GET /api/v1/filters/{id} filterV1Get
+//
+// Get a single filter with the given ID.
+//
+// ---
+// tags:
+// - filters
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: ID of the filter
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - read:filters
+//
+// responses:
+// '200':
+// name: filter
+// description: Requested filter.
+// schema:
+// "$ref": "#/definitions/filterV1"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) FilterGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiFilter, errWithCode := m.processor.FiltersV1().Get(c.Request.Context(), authed.Account, id)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiFilter)
+}
diff --git a/internal/api/client/filters/v1/filterget_test.go b/internal/api/client/filters/v1/filterget_test.go
new file mode 100644
index 000000000..a9dbf6dbb
--- /dev/null
+++ b/internal/api/client/filters/v1/filterget_test.go
@@ -0,0 +1,121 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package v1_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+
+ filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *FiltersTestSuite) getFilter(
+ filterKeywordID string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.FilterV1, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath+"/"+filterKeywordID, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ ctx.AddParam("id", filterKeywordID)
+
+ // trigger the handler
+ suite.filtersModule.FilterGETHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := &apimodel.FilterV1{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func (suite *FiltersTestSuite) TestGetFilter() {
+ // v1 filters map to individual filter keywords, but also use the settings of the associated filter.
+ expectedFilterGtsModel := suite.testFilters["local_account_1_filter_1"]
+ expectedFilterKeywordGtsModel := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
+
+ filter, err := suite.getFilter(expectedFilterKeywordGtsModel.ID, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.NotEmpty(filter)
+ suite.Equal(expectedFilterGtsModel.Action == gtsmodel.FilterActionHide, filter.Irreversible)
+ suite.Equal(expectedFilterKeywordGtsModel.ID, filter.ID)
+ suite.Equal(expectedFilterKeywordGtsModel.Keyword, filter.Phrase)
+}
+
+func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() {
+ id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
+
+ _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestGetNonexistentFilter() {
+ id := "not_even_a_real_ULID"
+
+ _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
diff --git a/internal/api/client/filters/v1/filterpost.go b/internal/api/client/filters/v1/filterpost.go
new file mode 100644
index 000000000..b0a626199
--- /dev/null
+++ b/internal/api/client/filters/v1/filterpost.go
@@ -0,0 +1,147 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package v1
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// FilterPOSTHandler swagger:operation POST /api/v1/filters filterV1Post
+//
+// Create a single filter.
+//
+// ---
+// tags:
+// - filters
+//
+// consumes:
+// - application/json
+// - application/xml
+// - application/x-www-form-urlencoded
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: phrase
+// in: formData
+// required: true
+// description: The text to be filtered.
+// maxLength: 40
+// type: string
+// example: "fnord"
+// -
+// name: context
+// in: formData
+// required: true
+// description: The contexts in which the filter should be applied.
+// enum:
+// - home
+// - notifications
+// - public
+// - thread
+// - account
+// example:
+// - home
+// - public
+// items:
+// $ref: '#/definitions/filterContext'
+// minLength: 1
+// type: array
+// uniqueItems: true
+// -
+// name: expires_in
+// in: formData
+// description: Number of seconds from now that the filter should expire. If omitted, filter never expires.
+// type: number
+// example: 86400
+// -
+// name: irreversible
+// in: formData
+// description: Should matching entities be removed from the user's timelines/views, instead of hidden? Not supported yet.
+// type: boolean
+// default: false
+// example: false
+// -
+// name: whole_word
+// in: formData
+// description: Should the filter consider word boundaries?
+// type: boolean
+// default: false
+// example: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:filters
+//
+// responses:
+// '200':
+// name: filter
+// description: New filter.
+// schema:
+// "$ref": "#/definitions/filterV1"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '422':
+// description: unprocessable content
+// '500':
+// description: internal server error
+func (m *Module) FilterPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ form := &apimodel.FilterCreateUpdateRequestV1{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if err := validateNormalizeCreateUpdateFilter(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ apiFilter, errWithCode := m.processor.FiltersV1().Create(c.Request.Context(), authed.Account, form)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, apiFilter)
+}
diff --git a/internal/api/client/filters/v1/filterpost_test.go b/internal/api/client/filters/v1/filterpost_test.go
new file mode 100644
index 000000000..729b2bd72
--- /dev/null
+++ b/internal/api/client/filters/v1/filterpost_test.go
@@ -0,0 +1,239 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package v1_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+
+ filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *FiltersTestSuite) postFilter(
+ phrase *string,
+ context *[]string,
+ irreversible *bool,
+ wholeWord *bool,
+ expiresIn *int,
+ requestJson *string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.FilterV1, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+ if requestJson != nil {
+ ctx.Request.Header.Set("content-type", "application/json")
+ ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson))
+ } else {
+ ctx.Request.Form = make(url.Values)
+ if phrase != nil {
+ ctx.Request.Form["phrase"] = []string{*phrase}
+ }
+ if context != nil {
+ ctx.Request.Form["context[]"] = *context
+ }
+ if irreversible != nil {
+ ctx.Request.Form["irreversible"] = []string{strconv.FormatBool(*irreversible)}
+ }
+ if wholeWord != nil {
+ ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)}
+ }
+ if expiresIn != nil {
+ ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
+ }
+ }
+
+ // trigger the handler
+ suite.filtersModule.FilterPOSTHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := &apimodel.FilterV1{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func (suite *FiltersTestSuite) TestPostFilterFull() {
+ phrase := "GNU/Linux"
+ context := []string{"home", "public"}
+ irreversible := false
+ wholeWord := true
+ expiresIn := 86400
+ filter, err := suite.postFilter(&phrase, &context, &irreversible, &wholeWord, &expiresIn, nil, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(phrase, filter.Phrase)
+ filterContext := make([]string, 0, len(filter.Context))
+ for _, c := range filter.Context {
+ filterContext = append(filterContext, string(c))
+ }
+ suite.ElementsMatch(context, filterContext)
+ suite.Equal(irreversible, filter.Irreversible)
+ suite.Equal(wholeWord, filter.WholeWord)
+ if suite.NotNil(filter.ExpiresAt) {
+ suite.NotEmpty(*filter.ExpiresAt)
+ }
+}
+
+func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
+ // Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "expires_in".
+ requestJson := `{
+ "phrase":"GNU/Linux",
+ "context": ["home", "public"],
+ "irreversible": false,
+ "whole_word": true,
+ "expires_in": 86400.1
+ }`
+ filter, err := suite.postFilter(nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal("GNU/Linux", filter.Phrase)
+ suite.ElementsMatch(
+ []apimodel.FilterContext{
+ apimodel.FilterContextHome,
+ apimodel.FilterContextPublic,
+ },
+ filter.Context,
+ )
+ suite.Equal(false, filter.Irreversible)
+ suite.Equal(true, filter.WholeWord)
+ if suite.NotNil(filter.ExpiresAt) {
+ suite.NotEmpty(*filter.ExpiresAt)
+ }
+}
+
+func (suite *FiltersTestSuite) TestPostFilterMinimal() {
+ phrase := "GNU/Linux"
+ context := []string{"home"}
+ filter, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(phrase, filter.Phrase)
+ filterContext := make([]string, 0, len(filter.Context))
+ for _, c := range filter.Context {
+ filterContext = append(filterContext, string(c))
+ }
+ suite.ElementsMatch(context, filterContext)
+ suite.False(filter.Irreversible)
+ suite.False(filter.WholeWord)
+ suite.Nil(filter.ExpiresAt)
+}
+
+func (suite *FiltersTestSuite) TestPostFilterEmptyPhrase() {
+ phrase := ""
+ context := []string{"home"}
+ _, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestPostFilterMissingPhrase() {
+ context := []string{"home"}
+ _, err := suite.postFilter(nil, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
+ phrase := "GNU/Linux"
+ context := []string{}
+ _, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
+ phrase := "GNU/Linux"
+ _, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+// There should be a filter with this phrase as its title in our test fixtures. Creating another should fail.
+func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
+ phrase := "fnord"
+ _, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+// FUTURE: this should be removed once we support server-side filters.
+func (suite *FiltersTestSuite) TestPostFilterIrreversibleNotSupported() {
+ phrase := "GNU/Linux"
+ context := []string{"home"}
+ irreversible := true
+ _, err := suite.postFilter(&phrase, &context, &irreversible, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
diff --git a/internal/api/client/filters/v1/filterput.go b/internal/api/client/filters/v1/filterput.go
new file mode 100644
index 000000000..c686e4515
--- /dev/null
+++ b/internal/api/client/filters/v1/filterput.go
@@ -0,0 +1,159 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package v1
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// FilterPUTHandler swagger:operation PUT /api/v1/filters/{id} filterV1Put
+//
+// Update a single filter with the given ID.
+//
+// ---
+// tags:
+// - filters
+//
+// consumes:
+// - application/json
+// - application/xml
+// - application/x-www-form-urlencoded
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// in: path
+// type: string
+// required: true
+// description: ID of the filter.
+// -
+// name: phrase
+// in: formData
+// required: true
+// description: The text to be filtered.
+// maxLength: 40
+// type: string
+// example: "fnord"
+// -
+// name: context
+// in: formData
+// required: true
+// description: The contexts in which the filter should be applied.
+// enum:
+// - home
+// - notifications
+// - public
+// - thread
+// - account
+// example:
+// - home
+// - public
+// items:
+// $ref: '#/definitions/filterContext'
+// minLength: 1
+// type: array
+// uniqueItems: true
+// -
+// name: expires_in
+// in: formData
+// description: Number of seconds from now that the filter should expire. If omitted, filter never expires.
+// type: number
+// example: 86400
+// -
+// name: irreversible
+// in: formData
+// description: Should matching entities be removed from the user's timelines/views, instead of hidden? Not supported yet.
+// type: boolean
+// default: false
+// example: false
+// -
+// name: whole_word
+// in: formData
+// description: Should the filter consider word boundaries?
+// type: boolean
+// default: false
+// example: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:filters
+//
+// responses:
+// '200':
+// name: filter
+// description: Updated filter.
+// schema:
+// "$ref": "#/definitions/filterV1"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '422':
+// description: unprocessable content
+// '500':
+// description: internal server error
+func (m *Module) FilterPUTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); 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.FilterCreateUpdateRequestV1{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if err := validateNormalizeCreateUpdateFilter(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ apiFilter, errWithCode := m.processor.FiltersV1().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)
+}
diff --git a/internal/api/client/filters/v1/filterput_test.go b/internal/api/client/filters/v1/filterput_test.go
new file mode 100644
index 000000000..0308e53d9
--- /dev/null
+++ b/internal/api/client/filters/v1/filterput_test.go
@@ -0,0 +1,269 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package v1_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+
+ filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *FiltersTestSuite) putFilter(
+ filterKeywordID string,
+ phrase *string,
+ context *[]string,
+ irreversible *bool,
+ wholeWord *bool,
+ expiresIn *int,
+ requestJson *string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.FilterV1, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath+"/"+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 phrase != nil {
+ ctx.Request.Form["phrase"] = []string{*phrase}
+ }
+ if context != nil {
+ ctx.Request.Form["context[]"] = *context
+ }
+ if irreversible != nil {
+ ctx.Request.Form["irreversible"] = []string{strconv.FormatBool(*irreversible)}
+ }
+ if wholeWord != nil {
+ ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)}
+ }
+ if expiresIn != nil {
+ ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
+ }
+ }
+
+ ctx.AddParam("id", filterKeywordID)
+
+ // 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 we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := &apimodel.FilterV1{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func (suite *FiltersTestSuite) TestPutFilterFull() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ phrase := "GNU/Linux"
+ context := []string{"home", "public"}
+ irreversible := false
+ wholeWord := true
+ expiresIn := 86400
+ filter, err := suite.putFilter(id, &phrase, &context, &irreversible, &wholeWord, &expiresIn, nil, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(phrase, filter.Phrase)
+ filterContext := make([]string, 0, len(filter.Context))
+ for _, c := range filter.Context {
+ filterContext = append(filterContext, string(c))
+ }
+ suite.ElementsMatch(context, filterContext)
+ suite.Equal(irreversible, filter.Irreversible)
+ suite.Equal(wholeWord, filter.WholeWord)
+ if suite.NotNil(filter.ExpiresAt) {
+ suite.NotEmpty(*filter.ExpiresAt)
+ }
+}
+
+func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ // Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "expires_in".
+ requestJson := `{
+ "phrase":"GNU/Linux",
+ "context": ["home", "public"],
+ "irreversible": false,
+ "whole_word": true,
+ "expires_in": 86400.1
+ }`
+ filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal("GNU/Linux", filter.Phrase)
+ suite.ElementsMatch(
+ []apimodel.FilterContext{
+ apimodel.FilterContextHome,
+ apimodel.FilterContextPublic,
+ },
+ filter.Context,
+ )
+ suite.Equal(false, filter.Irreversible)
+ suite.Equal(true, filter.WholeWord)
+ if suite.NotNil(filter.ExpiresAt) {
+ suite.NotEmpty(*filter.ExpiresAt)
+ }
+}
+
+func (suite *FiltersTestSuite) TestPutFilterMinimal() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ phrase := "GNU/Linux"
+ context := []string{"home"}
+ filter, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(phrase, filter.Phrase)
+ filterContext := make([]string, 0, len(filter.Context))
+ for _, c := range filter.Context {
+ filterContext = append(filterContext, string(c))
+ }
+ suite.ElementsMatch(context, filterContext)
+ suite.False(filter.Irreversible)
+ suite.False(filter.WholeWord)
+ suite.Nil(filter.ExpiresAt)
+}
+
+func (suite *FiltersTestSuite) TestPutFilterEmptyPhrase() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ phrase := ""
+ context := []string{"home"}
+ _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestPutFilterMissingPhrase() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ context := []string{"home"}
+ _, err := suite.putFilter(id, nil, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ phrase := "GNU/Linux"
+ context := []string{}
+ _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *FiltersTestSuite) TestPutFilterMissingContext() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ phrase := "GNU/Linux"
+ _, err := suite.putFilter(id, &phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+// There should be a filter with this phrase as its title in our test fixtures. Changing ours to that title should fail.
+func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {
+ id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
+ phrase := "metasyntactic variables"
+ _, err := suite.putFilter(id, &phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+// FUTURE: this should be removed once we support server-side filters.
+func (suite *FiltersTestSuite) 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"
+ context := []string{"home"}
+ _, err := suite.putFilter(id, &phrase, &context, nil, 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, nil, http.StatusNotFound, `{"error":"Not Found"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
diff --git a/internal/api/client/filters/filtersget.go b/internal/api/client/filters/v1/filtersget.go
index 38dd330a7..84d638676 100644
--- a/internal/api/client/filters/filtersget.go
+++ b/internal/api/client/filters/v1/filtersget.go
@@ -15,7 +15,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
-package filter
+package v1
import (
"net/http"
@@ -26,9 +26,40 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
-// FiltersGETHandler returns a list of filters set by/for the authed account
+// FiltersGETHandler swagger:operation GET /api/v1/filters filtersV1Get
+//
+// Get all filters for the authenticated account.
+//
+// ---
+// tags:
+// - filters
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - read:filters
+//
+// responses:
+// '200':
+// name: filter
+// description: Requested filters.
+// schema:
+// "$ref": "#/definitions/filterV1"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
func (m *Module) FiltersGETHandler(c *gin.Context) {
- if _, err := oauth.Authed(c, true, true, true, true); err != nil {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
@@ -38,5 +69,11 @@ func (m *Module) FiltersGETHandler(c *gin.Context) {
return
}
- apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
+ apiFilters, errWithCode := m.processor.FiltersV1().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/v1/filtersget_test.go b/internal/api/client/filters/v1/filtersget_test.go
new file mode 100644
index 000000000..a568239ef
--- /dev/null
+++ b/internal/api/client/filters/v1/filtersget_test.go
@@ -0,0 +1,114 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package v1_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+
+ filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *FiltersTestSuite) getFilters(
+ expectedHTTPStatus int,
+ expectedBody string,
+) ([]*apimodel.FilterV1, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV1.BasePath, 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 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.FilterV1, 0)
+ if err := json.Unmarshal(b, &resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func (suite *FiltersTestSuite) TestGetFilters() {
+ // v1 filters map to individual filter keywords.
+ expectedFilterIDs := make([]string, 0, len(suite.testFilterKeywords))
+ expectedFilterKeywords := make([]string, 0, len(suite.testFilterKeywords))
+ for _, filterKeyword := range suite.testFilterKeywords {
+ if filterKeyword.AccountID == suite.testAccounts["local_account_1"].ID {
+ expectedFilterIDs = append(expectedFilterIDs, filterKeyword.ID)
+ expectedFilterKeywords = append(expectedFilterKeywords, filterKeyword.Keyword)
+ }
+ }
+ suite.NotEmpty(expectedFilterIDs)
+ suite.NotEmpty(expectedFilterKeywords)
+
+ // 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.
+ actualFilterIDs := make([]string, 0, len(filters))
+ actualFilterKeywords := make([]string, 0, len(filters))
+ for _, filter := range filters {
+ actualFilterIDs = append(actualFilterIDs, filter.ID)
+ actualFilterKeywords = append(actualFilterKeywords, filter.Phrase)
+ }
+ suite.ElementsMatch(expectedFilterIDs, actualFilterIDs)
+ suite.ElementsMatch(expectedFilterKeywords, actualFilterKeywords)
+}
diff --git a/internal/api/client/filters/v1/validate.go b/internal/api/client/filters/v1/validate.go
new file mode 100644
index 000000000..b539c9563
--- /dev/null
+++ b/internal/api/client/filters/v1/validate.go
@@ -0,0 +1,68 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package v1
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/superseriousbusiness/gotosocial/internal/validate"
+)
+
+func validateNormalizeCreateUpdateFilter(form *model.FilterCreateUpdateRequestV1) error {
+ if err := validate.FilterKeyword(form.Phrase); err != nil {
+ return err
+ }
+ if err := validate.FilterContexts(form.Context); err != nil {
+ return err
+ }
+
+ // Apply defaults for missing fields.
+ form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false))
+ form.Irreversible = util.Ptr(util.PtrValueOr(form.Irreversible, false))
+
+ if *form.Irreversible {
+ return errors.New("irreversible aka server-side drop filters are not supported yet")
+ }
+
+ // 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
+}