diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 7e61dcf1c..a83ea643a 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -1053,7 +1053,7 @@ definitions:
type: string
x-go-name: Keyword
whole_word:
- description: Should the filter consider word boundaries?
+ description: Should the filter keyword consider word boundaries?
example: true
type: boolean
x-go-name: WholeWord
@@ -5971,6 +5971,7 @@ paths:
Sample: fnord
in: formData
maxLength: 40
+ minLength: 1
name: phrase
required: true
type: string
@@ -6031,6 +6032,8 @@ paths:
description: not found
"406":
description: not acceptable
+ "409":
+ description: conflict (duplicate keyword)
"422":
description: unprocessable content
"500":
@@ -6045,7 +6048,7 @@ paths:
delete:
operationId: filterV1Delete
parameters:
- - description: ID of the list
+ - description: ID of the filter
in: path
name: id
required: true
@@ -6120,6 +6123,7 @@ paths:
Sample: fnord
in: formData
maxLength: 40
+ minLength: 1
name: phrase
required: true
type: string
@@ -6180,6 +6184,8 @@ paths:
description: not found
"406":
description: not acceptable
+ "409":
+ description: conflict (duplicate keyword)
"422":
description: unprocessable content
"500":
@@ -8759,6 +8765,547 @@ paths:
summary: View + page through known accounts according to given filters.
tags:
- admin
+ /api/v2/filters:
+ get:
+ operationId: filtersV2Get
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Requested filters.
+ schema:
+ items:
+ $ref: '#/definitions/filterV2'
+ type: array
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:filters
+ summary: Get all filters for the authenticated account.
+ tags:
+ - filters
+ post:
+ consumes:
+ - application/json
+ - application/xml
+ - application/x-www-form-urlencoded
+ operationId: filterV2Post
+ parameters:
+ - description: |-
+ The name of the filter.
+
+ Sample: illuminati nonsense
+ in: formData
+ maxLength: 200
+ minLength: 1
+ name: title
+ required: true
+ type: string
+ - collectionFormat: multi
+ description: |-
+ The contexts in which the filter should be applied.
+
+ Sample: home, public
+ enum:
+ - home
+ - notifications
+ - public
+ - thread
+ - account
+ in: formData
+ items:
+ type: string
+ minItems: 1
+ name: context[]
+ required: true
+ type: array
+ uniqueItems: true
+ - description: |-
+ Number of seconds from now that the filter should expire. If omitted, filter never expires.
+
+ Sample: 86400
+ in: formData
+ name: expires_in
+ type: number
+ - default: warn
+ description: |-
+ The action to be taken when a status matches this filter.
+
+ Sample: warn
+ enum:
+ - warn
+ - hide
+ in: formData
+ name: filter_action
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: New filter.
+ schema:
+ $ref: '#/definitions/filterV2'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "409":
+ description: conflict (duplicate title, keyword, or status)
+ "422":
+ description: unprocessable content
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:filters
+ summary: Create a single filter.
+ tags:
+ - filters
+ /api/v2/filters/{id}:
+ delete:
+ operationId: filterV2Delete
+ parameters:
+ - description: ID of the filter
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: filter deleted
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:filters
+ summary: Delete a single filter with the given ID.
+ tags:
+ - filters
+ get:
+ operationId: filterV2Get
+ parameters:
+ - description: ID of the filter
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Requested filter.
+ schema:
+ $ref: '#/definitions/filterV2'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:filters
+ summary: Get a single filter with the given ID.
+ tags:
+ - filters
+ put:
+ consumes:
+ - application/json
+ - application/xml
+ - application/x-www-form-urlencoded
+ description: |-
+ Note that this is actually closer to a PATCH operation:
+ only provided fields will be updated, and omitted fields will remain set to previous values.
+ operationId: filterV2Put
+ parameters:
+ - description: ID of the filter.
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: |-
+ The name of the filter.
+
+ Sample: illuminati nonsense
+ in: formData
+ maxLength: 200
+ minLength: 1
+ name: title
+ required: true
+ type: string
+ - collectionFormat: multi
+ description: |-
+ The contexts in which the filter should be applied.
+
+ Sample: home, public
+ enum:
+ - home
+ - notifications
+ - public
+ - thread
+ - account
+ in: formData
+ items:
+ type: string
+ minItems: 1
+ name: context[]
+ required: true
+ type: array
+ uniqueItems: true
+ - description: |-
+ Number of seconds from now that the filter should expire.
+
+ Sample: 86400
+ in: formData
+ name: expires_in
+ type: number
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Updated filter.
+ schema:
+ $ref: '#/definitions/filterV2'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "409":
+ description: conflict (duplicate title, keyword, or status)
+ "422":
+ description: unprocessable content
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:filters
+ summary: Update a single filter with the given ID.
+ tags:
+ - filters
+ /api/v2/filters/{id}/keywords:
+ get:
+ operationId: filterKeywordsGet
+ parameters:
+ - description: ID of the filter
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Requested filter keywords.
+ schema:
+ items:
+ $ref: '#/definitions/filterKeyword'
+ type: array
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:filters
+ summary: Get all filter keywords for a given filter.
+ tags:
+ - filters
+ post:
+ consumes:
+ - application/json
+ - application/xml
+ - application/x-www-form-urlencoded
+ operationId: filterKeywordPost
+ parameters:
+ - description: ID of the filter to add the filtered status to.
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: |-
+ The text to be filtered
+
+ Sample: fnord
+ in: formData
+ maxLength: 40
+ minLength: 1
+ name: keyword
+ required: true
+ type: string
+ - default: false
+ description: |-
+ Should the filter consider word boundaries?
+
+ Sample: true
+ in: formData
+ name: whole_word
+ type: boolean
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: New filter keyword.
+ schema:
+ $ref: '#/definitions/filterKeyword'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "409":
+ description: conflict (duplicate keyword)
+ "422":
+ description: unprocessable content
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:filters
+ summary: Add a filter keyword to an existing filter.
+ tags:
+ - filters
+ /api/v2/filters/{id}/statuses:
+ get:
+ operationId: filterStatusesGet
+ parameters:
+ - description: ID of the filter
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Requested filter statuses.
+ schema:
+ items:
+ $ref: '#/definitions/filterStatus'
+ type: array
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:filters
+ summary: Get all filter statuses for a given filter.
+ tags:
+ - filters
+ post:
+ consumes:
+ - application/json
+ - application/xml
+ - application/x-www-form-urlencoded
+ operationId: filterStatusPost
+ parameters:
+ - description: ID of the filter to add the filtered status to.
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: |-
+ The ID of the status to filter.
+
+ Sample: 01HXA2NE0K8T1C70K90E74GYD0
+ in: formData
+ name: status_id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: New filter status.
+ schema:
+ $ref: '#/definitions/filterStatus'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "409":
+ description: conflict (duplicate status)
+ "422":
+ description: unprocessable content
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:filters
+ summary: Add a filter status to an existing filter.
+ tags:
+ - filters
+ /api/v2/filters/keywords/{id}:
+ get:
+ operationId: filterKeywordGet
+ parameters:
+ - description: ID of the filter keyword
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Requested filter keyword.
+ schema:
+ $ref: '#/definitions/filterKeyword'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:filters
+ summary: Get a single filter keyword with the given ID.
+ tags:
+ - filters
+ /api/v2/filters/keywords{id}:
+ put:
+ consumes:
+ - application/json
+ - application/xml
+ - application/x-www-form-urlencoded
+ operationId: filterKeywordPut
+ parameters:
+ - description: ID of the filter keyword to update.
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: |-
+ The text to be filtered
+
+ Sample: fnord
+ in: formData
+ maxLength: 40
+ minLength: 1
+ name: keyword
+ required: true
+ type: string
+ - description: |-
+ Should the filter consider word boundaries?
+
+ Sample: true
+ in: formData
+ name: whole_word
+ type: boolean
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Updated filter keyword.
+ schema:
+ $ref: '#/definitions/filterKeyword'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "409":
+ description: conflict (duplicate keyword)
+ "422":
+ description: unprocessable content
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:filters
+ summary: Update a single filter keyword with the given ID.
+ tags:
+ - filters
+ /api/v2/filters/statuses/{id}:
+ get:
+ operationId: filterStatusGet
+ parameters:
+ - description: ID of the filter status
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Requested filter status.
+ schema:
+ $ref: '#/definitions/filterStatus'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:filters
+ summary: Get a single filter status with the given ID.
+ tags:
+ - filters
/api/v2/instance:
get:
operationId: instanceGetV2
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 @@
"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 @@
// -
// 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 @@
// The text to be filtered.
//
// Sample: fnord
+// minLength: 1
// maxLength: 40
// type: string
// -
@@ -120,6 +121,8 @@
// description: not found
// '406':
// description: not acceptable
+// '409':
+// description: conflict (duplicate keyword)
// '422':
// description: unprocessable content
// '500':
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 @@
// The text to be filtered.
//
// Sample: fnord
+// minLength: 1
// maxLength: 40
// type: string
// -
@@ -126,6 +127,8 @@
// description: not found
// '406':
// description: not acceptable
+// '409':
+// description: conflict (duplicate keyword)
// '422':
// description: unprocessable content
// '500':
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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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"`
+}
diff --git a/internal/db/bundb/filter.go b/internal/db/bundb/filter.go
index d09a5067d..30a8494a7 100644
--- a/internal/db/bundb/filter.go
+++ b/internal/db/bundb/filter.go
@@ -19,6 +19,7 @@
import (
"context"
+ "errors"
"slices"
"time"
@@ -197,10 +198,14 @@ func (f *filterDB) UpdateFilter(
ctx context.Context,
filter *gtsmodel.Filter,
filterColumns []string,
- filterKeywordColumns []string,
+ filterKeywordColumns [][]string,
deleteFilterKeywordIDs []string,
deleteFilterStatusIDs []string,
) error {
+ if len(filter.Keywords) != len(filterKeywordColumns) {
+ return errors.New("number of filter keywords must match number of lists of filter keyword columns")
+ }
+
updatedAt := time.Now()
filter.UpdatedAt = updatedAt
for _, filterKeyword := range filter.Keywords {
@@ -214,8 +219,10 @@ func (f *filterDB) UpdateFilter(
if len(filterColumns) > 0 {
filterColumns = append(filterColumns, "updated_at")
}
- if len(filterKeywordColumns) > 0 {
- filterKeywordColumns = append(filterKeywordColumns, "updated_at")
+ for i := range filterKeywordColumns {
+ if len(filterKeywordColumns[i]) > 0 {
+ filterKeywordColumns[i] = append(filterKeywordColumns[i], "updated_at")
+ }
}
// Update database.
@@ -229,11 +236,11 @@ func (f *filterDB) UpdateFilter(
return err
}
- if len(filter.Keywords) > 0 {
+ for i, filterKeyword := range filter.Keywords {
if _, err := NewUpsert(tx).
- Model(&filter.Keywords).
+ Model(filterKeyword).
Constraint("id").
- Column(filterKeywordColumns...).
+ Column(filterKeywordColumns[i]...).
Exec(ctx); err != nil {
return err
}
diff --git a/internal/db/bundb/filter_test.go b/internal/db/bundb/filter_test.go
index 7940b6651..d1249d16b 100644
--- a/internal/db/bundb/filter_test.go
+++ b/internal/db/bundb/filter_test.go
@@ -127,7 +127,7 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
}
check.Statuses = append(check.Statuses, newStatus)
- if err := suite.db.UpdateFilter(ctx, check, nil, nil, nil, nil); err != nil {
+ if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{nil, nil}, nil, nil); err != nil {
t.Fatalf("error updating filter: %v", err)
}
// Now fetch newly updated filter.
@@ -175,7 +175,7 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
check.Keywords = []*gtsmodel.FilterKeyword{filterKeyword}
check.Statuses = nil
- if err := suite.db.UpdateFilter(ctx, check, nil, nil, []string{newKeyword.ID}, nil); err != nil {
+ if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{{"whole_word"}}, []string{newKeyword.ID}, nil); err != nil {
t.Fatalf("error updating filter: %v", err)
}
check, err = suite.db.GetFilterByID(ctx, filter.ID)
@@ -222,7 +222,7 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
StatusID: newStatus.StatusID,
}
check.Statuses = []*gtsmodel.FilterStatus{redundantStatus}
- if err := suite.db.UpdateFilter(ctx, check, nil, nil, nil, nil); err != nil {
+ if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{nil}, nil, nil); err != nil {
t.Fatalf("error updating filter: %v", err)
}
check, err = suite.db.GetFilterByID(ctx, filter.ID)
diff --git a/internal/db/filter.go b/internal/db/filter.go
index 18943b4f9..eee61a99d 100644
--- a/internal/db/filter.go
+++ b/internal/db/filter.go
@@ -42,11 +42,13 @@ type Filter interface {
// and deletes indicated filter keywords and statuses by ID.
// It uses a transaction to ensure no partial updates.
// The column lists are optional; if not specified, all columns will be updated.
+ // The filter keyword columns list is *per keyword*.
+ // To update all keyword columns, provide a list where every element is an empty list.
UpdateFilter(
ctx context.Context,
filter *gtsmodel.Filter,
filterColumns []string,
- filterKeywordColumns []string,
+ filterKeywordColumns [][]string,
deleteFilterKeywordIDs []string,
deleteFilterStatusIDs []string,
) error
diff --git a/internal/gtsmodel/filter.go b/internal/gtsmodel/filter.go
index c3feec00f..b1e8f5cac 100644
--- a/internal/gtsmodel/filter.go
+++ b/internal/gtsmodel/filter.go
@@ -81,6 +81,8 @@ type FilterStatus struct {
type FilterAction string
const (
+ // FilterActionNone filters should not exist, except internally, for partially constructed or invalid filters.
+ FilterActionNone FilterAction = ""
// FilterActionWarn means that the status should be shown behind a warning.
FilterActionWarn FilterAction = "warn"
// FilterActionHide means that the status should be removed from timeline results entirely.
diff --git a/internal/processing/filters/v1/get.go b/internal/processing/filters/v1/get.go
index 39575dd94..3ead09b20 100644
--- a/internal/processing/filters/v1/get.go
+++ b/internal/processing/filters/v1/get.go
@@ -59,8 +59,8 @@ func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*a
}
apiFilters := make([]*apimodel.FilterV1, 0, len(filters))
- for _, list := range filters {
- apiFilter, errWithCode := p.apiFilter(ctx, list)
+ for _, filter := range filters {
+ apiFilter, errWithCode := p.apiFilter(ctx, filter)
if errWithCode != nil {
return nil, errWithCode
}
diff --git a/internal/processing/filters/v1/update.go b/internal/processing/filters/v1/update.go
index 1fe49721b..0421dc786 100644
--- a/internal/processing/filters/v1/update.go
+++ b/internal/processing/filters/v1/update.go
@@ -149,9 +149,11 @@ func (p *Processor) Update(
"context_thread",
"context_account",
}
- filterKeywordColumns := []string{
- "keyword",
- "whole_word",
+ filterKeywordColumns := [][]string{
+ {
+ "keyword",
+ "whole_word",
+ },
}
if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, nil, nil); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
diff --git a/internal/processing/filters/v2/convert.go b/internal/processing/filters/v2/convert.go
new file mode 100644
index 000000000..1e544e6e4
--- /dev/null
+++ b/internal/processing/filters/v2/convert.go
@@ -0,0 +1,38 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "fmt"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// apiFilter is a shortcut to return the API v2 filter version of the given
+// filter, or return an appropriate error if conversion fails.
+func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.Filter) (*apimodel.FilterV2, gtserror.WithCode) {
+ apiFilter, err := p.converter.FilterToAPIFilterV2(ctx, filterKeyword)
+ if err != nil {
+ return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter to API v2 filter: %w", err))
+ }
+
+ return apiFilter, nil
+}
diff --git a/internal/processing/filters/v2/create.go b/internal/processing/filters/v2/create.go
new file mode 100644
index 000000000..c7b500e9e
--- /dev/null
+++ b/internal/processing/filters/v2/create.go
@@ -0,0 +1,75 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// Create a new filter for the given account, using the provided parameters.
+// These params should have already been validated by the time they reach this function.
+func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateRequestV2) (*apimodel.FilterV2, gtserror.WithCode) {
+ filter := >smodel.Filter{
+ ID: id.NewULID(),
+ AccountID: account.ID,
+ Title: form.Title,
+ Action: typeutils.APIFilterActionToFilterAction(*form.FilterAction),
+ }
+ if form.ExpiresIn != nil {
+ filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
+ }
+ for _, context := range form.Context {
+ switch context {
+ case apimodel.FilterContextHome:
+ filter.ContextHome = util.Ptr(true)
+ case apimodel.FilterContextNotifications:
+ filter.ContextNotifications = util.Ptr(true)
+ case apimodel.FilterContextPublic:
+ filter.ContextPublic = util.Ptr(true)
+ case apimodel.FilterContextThread:
+ filter.ContextThread = util.Ptr(true)
+ case apimodel.FilterContextAccount:
+ filter.ContextAccount = util.Ptr(true)
+ default:
+ return nil, gtserror.NewErrorUnprocessableEntity(
+ fmt.Errorf("unsupported filter context '%s'", context),
+ )
+ }
+ }
+
+ if err := p.state.DB.PutFilter(ctx, filter); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ err = errors.New("duplicate title, keyword, or status")
+ return nil, gtserror.NewErrorConflict(err, err.Error())
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.apiFilter(ctx, filter)
+}
diff --git a/internal/processing/filters/v2/delete.go b/internal/processing/filters/v2/delete.go
new file mode 100644
index 000000000..b1bebdcb6
--- /dev/null
+++ b/internal/processing/filters/v2/delete.go
@@ -0,0 +1,53 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// Delete an existing filter and all its attached keywords and statuses for the given account.
+func (p *Processor) Delete(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ filterID string,
+) gtserror.WithCode {
+ // Get the filter for this keyword.
+ filter, err := p.state.DB.GetFilterByID(ctx, filterID)
+ if err != nil {
+ return gtserror.NewErrorNotFound(err)
+ }
+
+ // Check that the account owns it.
+ if filter.AccountID != account.ID {
+ return gtserror.NewErrorNotFound(
+ fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
+ )
+ }
+
+ // Delete the entire filter.
+ if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil {
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ return nil
+}
diff --git a/internal/processing/filters/v2/filters.go b/internal/processing/filters/v2/filters.go
new file mode 100644
index 000000000..dfb6a8992
--- /dev/null
+++ b/internal/processing/filters/v2/filters.go
@@ -0,0 +1,35 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+type Processor struct {
+ state *state.State
+ converter *typeutils.Converter
+}
+
+func New(state *state.State, converter *typeutils.Converter) Processor {
+ return Processor{
+ state: state,
+ converter: converter,
+ }
+}
diff --git a/internal/processing/filters/v2/get.go b/internal/processing/filters/v2/get.go
new file mode 100644
index 000000000..39b937eb2
--- /dev/null
+++ b/internal/processing/filters/v2/get.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 .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// Get looks up a filter by ID and returns it with keywords and statuses.
+func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterID string) (*apimodel.FilterV2, gtserror.WithCode) {
+ filter, err := p.state.DB.GetFilterByID(ctx, filterID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filter.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(
+ fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
+ )
+ }
+
+ return p.apiFilter(ctx, filter)
+}
+
+// GetAll looks up all filters for the current account and returns them with keywords and statuses.
+func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.FilterV2, gtserror.WithCode) {
+ filters, err := p.state.DB.GetFiltersForAccountID(
+ ctx,
+ account.ID,
+ )
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, nil
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ apiFilters := make([]*apimodel.FilterV2, 0, len(filters))
+ for _, filter := range filters {
+ apiFilter, errWithCode := p.apiFilter(ctx, filter)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ apiFilters = append(apiFilters, apiFilter)
+ }
+
+ // Sort them by ID so that they're in a stable order.
+ // Clients may opt to sort them lexically in a locale-aware manner.
+ slices.SortFunc(apiFilters, func(lhs *apimodel.FilterV2, rhs *apimodel.FilterV2) int {
+ return strings.Compare(lhs.ID, rhs.ID)
+ })
+
+ return apiFilters, nil
+}
diff --git a/internal/processing/filters/v2/keywordcreate.go b/internal/processing/filters/v2/keywordcreate.go
new file mode 100644
index 000000000..711b855fa
--- /dev/null
+++ b/internal/processing/filters/v2/keywordcreate.go
@@ -0,0 +1,67 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+)
+
+// KeywordCreate adds a filter keyword to an existing filter for the given account, using the provided parameters.
+// These params should have already been normalized and validated by the time they reach this function.
+func (p *Processor) KeywordCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterKeywordCreateUpdateRequest) (*apimodel.FilterKeyword, gtserror.WithCode) {
+ // Check that the filter is owned by the given account.
+ filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filter.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(
+ fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
+ )
+ }
+
+ filterKeyword := >smodel.FilterKeyword{
+ ID: id.NewULID(),
+ AccountID: account.ID,
+ FilterID: filter.ID,
+ Keyword: form.Keyword,
+ WholeWord: form.WholeWord,
+ }
+
+ if err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ err = errors.New("duplicate keyword")
+ return nil, gtserror.NewErrorConflict(err, err.Error())
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil
+}
diff --git a/internal/processing/filters/v2/keyworddelete.go b/internal/processing/filters/v2/keyworddelete.go
new file mode 100644
index 000000000..edf57167d
--- /dev/null
+++ b/internal/processing/filters/v2/keyworddelete.go
@@ -0,0 +1,53 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// KeywordDelete deletes an existing filter keyword from a filter.
+func (p *Processor) KeywordDelete(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ filterID string,
+) gtserror.WithCode {
+ // Get the filter keyword.
+ filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterID)
+ if err != nil {
+ return gtserror.NewErrorNotFound(err)
+ }
+
+ // Check that the account owns it.
+ if filterKeyword.AccountID != account.ID {
+ return gtserror.NewErrorNotFound(
+ fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID),
+ )
+ }
+
+ // Delete the filter keyword.
+ if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil {
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ return nil
+}
diff --git a/internal/processing/filters/v2/keywordget.go b/internal/processing/filters/v2/keywordget.go
new file mode 100644
index 000000000..5f5a63b26
--- /dev/null
+++ b/internal/processing/filters/v2/keywordget.go
@@ -0,0 +1,89 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// KeywordGet looks up a filter keyword by ID.
+func (p *Processor) KeywordGet(ctx context.Context, account *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterKeyword, gtserror.WithCode) {
+ filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterKeywordID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filterKeyword.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(
+ fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID),
+ )
+ }
+
+ return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil
+}
+
+// KeywordsGetForFilterID looks up all filter keywords for the given filter.
+func (p *Processor) KeywordsGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterKeyword, gtserror.WithCode) {
+ // Check that the filter is owned by the given account.
+ filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filter.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(nil)
+ }
+
+ filterKeywords, err := p.state.DB.GetFilterKeywordsForFilterID(
+ ctx,
+ filter.ID,
+ )
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, nil
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ apiFilterKeywords := make([]*apimodel.FilterKeyword, 0, len(filterKeywords))
+ for _, filterKeyword := range filterKeywords {
+ apiFilterKeywords = append(apiFilterKeywords, p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword))
+ }
+
+ // Sort them by ID so that they're in a stable order.
+ // Clients may opt to sort them lexically in a locale-aware manner.
+ slices.SortFunc(apiFilterKeywords, func(lhs *apimodel.FilterKeyword, rhs *apimodel.FilterKeyword) int {
+ return strings.Compare(lhs.ID, rhs.ID)
+ })
+
+ return apiFilterKeywords, nil
+}
diff --git a/internal/processing/filters/v2/keywordupdate.go b/internal/processing/filters/v2/keywordupdate.go
new file mode 100644
index 000000000..9a4058c23
--- /dev/null
+++ b/internal/processing/filters/v2/keywordupdate.go
@@ -0,0 +1,66 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// KeywordUpdate updates an existing filter keyword for the given account, using the provided parameters.
+// These params should have already been validated by the time they reach this function.
+func (p *Processor) KeywordUpdate(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ filterKeywordID string,
+ form *apimodel.FilterKeywordCreateUpdateRequest,
+) (*apimodel.FilterKeyword, gtserror.WithCode) {
+ // Get the filter keyword by ID.
+ filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filterKeyword.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(
+ fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID),
+ )
+ }
+
+ filterKeyword.Keyword = form.Keyword
+ filterKeyword.WholeWord = form.WholeWord
+
+ if err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, "keyword", "whole_word"); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ err = errors.New("duplicate keyword")
+ return nil, gtserror.NewErrorConflict(err, err.Error())
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil
+}
diff --git a/internal/processing/filters/v2/statuscreate.go b/internal/processing/filters/v2/statuscreate.go
new file mode 100644
index 000000000..a211dec2e
--- /dev/null
+++ b/internal/processing/filters/v2/statuscreate.go
@@ -0,0 +1,66 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+)
+
+// StatusCreate adds a filter status to an existing filter for the given account, using the provided parameters.
+// These params should have already been validated by the time they reach this function.
+func (p *Processor) StatusCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterStatusCreateRequest) (*apimodel.FilterStatus, gtserror.WithCode) {
+ // Check that the filter is owned by the given account.
+ filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filter.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(
+ fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
+ )
+ }
+
+ filterStatus := >smodel.FilterStatus{
+ ID: id.NewULID(),
+ AccountID: account.ID,
+ FilterID: filter.ID,
+ StatusID: form.StatusID,
+ }
+
+ if err := p.state.DB.PutFilterStatus(ctx, filterStatus); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ err = errors.New("duplicate status")
+ return nil, gtserror.NewErrorConflict(err, err.Error())
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil
+}
diff --git a/internal/processing/filters/v2/statusdelete.go b/internal/processing/filters/v2/statusdelete.go
new file mode 100644
index 000000000..a428e7409
--- /dev/null
+++ b/internal/processing/filters/v2/statusdelete.go
@@ -0,0 +1,53 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// StatusDelete deletes an existing filter status from a filter.
+func (p *Processor) StatusDelete(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ filterID string,
+) gtserror.WithCode {
+ // Get the filter status.
+ filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterID)
+ if err != nil {
+ return gtserror.NewErrorNotFound(err)
+ }
+
+ // Check that the account owns it.
+ if filterStatus.AccountID != account.ID {
+ return gtserror.NewErrorNotFound(
+ fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID),
+ )
+ }
+
+ // Delete the filter status.
+ if err := p.state.DB.DeleteFilterStatusByID(ctx, filterStatus.ID); err != nil {
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ return nil
+}
diff --git a/internal/processing/filters/v2/statusget.go b/internal/processing/filters/v2/statusget.go
new file mode 100644
index 000000000..197a3872e
--- /dev/null
+++ b/internal/processing/filters/v2/statusget.go
@@ -0,0 +1,89 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// StatusGet looks up a filter status by ID.
+func (p *Processor) StatusGet(ctx context.Context, account *gtsmodel.Account, filterStatusID string) (*apimodel.FilterStatus, gtserror.WithCode) {
+ filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterStatusID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filterStatus.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(
+ fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID),
+ )
+ }
+
+ return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil
+}
+
+// StatusesGetForFilterID looks up all filter statuses for the given filter.
+func (p *Processor) StatusesGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterStatus, gtserror.WithCode) {
+ // Check that the filter is owned by the given account.
+ filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filter.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(nil)
+ }
+
+ filterStatuses, err := p.state.DB.GetFilterStatusesForFilterID(
+ ctx,
+ filter.ID,
+ )
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, nil
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ apiFilterStatuses := make([]*apimodel.FilterStatus, 0, len(filterStatuses))
+ for _, filterStatus := range filterStatuses {
+ apiFilterStatuses = append(apiFilterStatuses, p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus))
+ }
+
+ // Sort them by ID so that they're in a stable order.
+ // Clients may opt to sort them by status ID instead.
+ slices.SortFunc(apiFilterStatuses, func(lhs *apimodel.FilterStatus, rhs *apimodel.FilterStatus) int {
+ return strings.Compare(lhs.ID, rhs.ID)
+ })
+
+ return apiFilterStatuses, nil
+}
diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go
new file mode 100644
index 000000000..aecb53337
--- /dev/null
+++ b/internal/processing/filters/v2/update.go
@@ -0,0 +1,125 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package v2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// Update an existing filter for the given account, using the provided parameters.
+// These params should have already been validated by the time they reach this function.
+func (p *Processor) Update(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ filterID string,
+ form *apimodel.FilterUpdateRequestV2,
+) (*apimodel.FilterV2, gtserror.WithCode) {
+ // Get the filter by ID, with existing keywords and statuses.
+ filter, err := p.state.DB.GetFilterByID(ctx, filterID)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ if filter.AccountID != account.ID {
+ return nil, gtserror.NewErrorNotFound(
+ fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID),
+ )
+ }
+
+ // Filter columns that we're going to update.
+ filterColumns := []string{}
+
+ // Apply filter changes.
+ if form.Title != nil {
+ filterColumns = append(filterColumns, "title")
+ filter.Title = *form.Title
+ }
+ if form.FilterAction != nil {
+ filterColumns = append(filterColumns, "action")
+ filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction)
+ }
+ // TODO: (Vyr) is it possible to unset a filter expiration with this API?
+ if form.ExpiresIn != nil {
+ filterColumns = append(filterColumns, "expires_at")
+ filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
+ }
+ if form.Context != nil {
+ filterColumns = append(filterColumns,
+ "context_home",
+ "context_notifications",
+ "context_public",
+ "context_thread",
+ "context_account",
+ )
+ filter.ContextHome = util.Ptr(false)
+ filter.ContextNotifications = util.Ptr(false)
+ filter.ContextPublic = util.Ptr(false)
+ filter.ContextThread = util.Ptr(false)
+ filter.ContextAccount = util.Ptr(false)
+ for _, context := range *form.Context {
+ switch context {
+ case apimodel.FilterContextHome:
+ filter.ContextHome = util.Ptr(true)
+ case apimodel.FilterContextNotifications:
+ filter.ContextNotifications = util.Ptr(true)
+ case apimodel.FilterContextPublic:
+ filter.ContextPublic = util.Ptr(true)
+ case apimodel.FilterContextThread:
+ filter.ContextThread = util.Ptr(true)
+ case apimodel.FilterContextAccount:
+ filter.ContextAccount = util.Ptr(true)
+ default:
+ return nil, gtserror.NewErrorUnprocessableEntity(
+ fmt.Errorf("unsupported filter context '%s'", context),
+ )
+ }
+ }
+ }
+
+ // Temporarily detach keywords and statuses from filter, since we're not updating them below.
+ filterKeywords := filter.Keywords
+ filterStatuses := filter.Statuses
+ filter.Keywords = nil
+ filter.Statuses = nil
+
+ if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, nil, nil, nil); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ err = errors.New("you already have a filter with this title")
+ return nil, gtserror.NewErrorConflict(err, err.Error())
+ }
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Re-attach keywords and statuses before returning.
+ filter.Keywords = filterKeywords
+ filter.Statuses = filterStatuses
+
+ return p.apiFilter(ctx, filter)
+}
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 4aaa94fb7..8a18bc45e 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -30,6 +30,7 @@
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/processing/fedi"
filtersv1 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v1"
+ filtersv2 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v2"
"github.com/superseriousbusiness/gotosocial/internal/processing/list"
"github.com/superseriousbusiness/gotosocial/internal/processing/markers"
"github.com/superseriousbusiness/gotosocial/internal/processing/media"
@@ -73,6 +74,7 @@ type Processor struct {
admin admin.Processor
fedi fedi.Processor
filtersv1 filtersv1.Processor
+ filtersv2 filtersv2.Processor
list list.Processor
markers markers.Processor
media media.Processor
@@ -102,6 +104,10 @@ func (p *Processor) FiltersV1() *filtersv1.Processor {
return &p.filtersv1
}
+func (p *Processor) FiltersV2() *filtersv2.Processor {
+ return &p.filtersv2
+}
+
func (p *Processor) List() *list.Processor {
return &p.list
}
@@ -184,6 +190,7 @@ func NewProcessor(
processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender)
processor.fedi = fedi.New(state, &common, converter, federator, filter)
processor.filtersv1 = filtersv1.New(state, converter)
+ processor.filtersv2 = filtersv2.New(state, converter)
processor.list = list.New(state, converter)
processor.markers = markers.New(state, converter)
processor.polls = polls.New(&common, state, converter)
diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go
index 3bb0933f3..f194770df 100644
--- a/internal/typeutils/frontendtointernal.go
+++ b/internal/typeutils/frontendtointernal.go
@@ -47,3 +47,13 @@ func APIMarkerNameToMarkerName(m apimodel.MarkerName) gtsmodel.MarkerName {
}
return ""
}
+
+func APIFilterActionToFilterAction(m apimodel.FilterAction) gtsmodel.FilterAction {
+ switch m {
+ case apimodel.FilterActionWarn:
+ return gtsmodel.FilterActionWarn
+ case apimodel.FilterActionHide:
+ return gtsmodel.FilterActionHide
+ }
+ return gtsmodel.FilterActionNone
+}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index d7f1fac6c..2b340a191 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -1852,19 +1852,12 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
func (c *Converter) FilterToAPIFilterV2(ctx context.Context, filter *gtsmodel.Filter) (*apimodel.FilterV2, error) {
apiFilterKeywords := make([]apimodel.FilterKeyword, 0, len(filter.Keywords))
for _, filterKeyword := range filter.Keywords {
- apiFilterKeywords = append(apiFilterKeywords, apimodel.FilterKeyword{
- ID: filterKeyword.ID,
- Keyword: filterKeyword.Keyword,
- WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
- })
+ apiFilterKeywords = append(apiFilterKeywords, *c.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword))
}
apiFilterStatuses := make([]apimodel.FilterStatus, 0, len(filter.Keywords))
for _, filterStatus := range filter.Statuses {
- apiFilterStatuses = append(apiFilterStatuses, apimodel.FilterStatus{
- ID: filterStatus.ID,
- StatusID: filterStatus.StatusID,
- })
+ apiFilterStatuses = append(apiFilterStatuses, *c.FilterStatusToAPIFilterStatus(ctx, filterStatus))
}
return &apimodel.FilterV2{
@@ -1915,6 +1908,23 @@ func filterActionToAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterActio
return apimodel.FilterActionNone
}
+// FilterKeywordToAPIFilterKeyword converts a GTS model filter status into an API filter status.
+func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) *apimodel.FilterKeyword {
+ return &apimodel.FilterKeyword{
+ ID: filterKeyword.ID,
+ Keyword: filterKeyword.Keyword,
+ WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
+ }
+}
+
+// FilterStatusToAPIFilterStatus converts a GTS model filter status into an API filter status.
+func (c *Converter) FilterStatusToAPIFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) *apimodel.FilterStatus {
+ return &apimodel.FilterStatus{
+ ID: filterStatus.ID,
+ StatusID: filterStatus.StatusID,
+ }
+}
+
// convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied.
func (c *Converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]apimodel.Emoji, error) {
var errs gtserror.MultiError
diff --git a/internal/validate/formvalidation.go b/internal/validate/formvalidation.go
index 3c16dd86e..e8ec3380b 100644
--- a/internal/validate/formvalidation.go
+++ b/internal/validate/formvalidation.go
@@ -45,6 +45,7 @@
maximumProfileFields = 6
maximumListTitleLength = 200
maximumFilterKeywordLength = 40
+ maximumFilterTitleLength = 200
)
// Password returns a helpful error if the given password
@@ -242,9 +243,16 @@ func SiteTerms(t string) error {
return nil
}
-// ULID returns true if the passed string is a valid ULID.
-func ULID(i string) bool {
- return regexes.ULID.MatchString(i)
+// ULID returns an error if the passed string is not a valid ULID.
+// The name param is used to form error messages.
+func ULID(i string, name string) error {
+ if i == "" {
+ return fmt.Errorf("%s must be provided", name)
+ }
+ if !regexes.ULID.MatchString(i) {
+ return fmt.Errorf("%s didn't match the expected ULID format for an ID (26 characters from the set 0123456789ABCDEFGHJKMNPQRSTVWXYZ)", name)
+ }
+ return nil
}
// ProfileFields validates the length of provided fields slice,
@@ -308,7 +316,7 @@ func MarkerName(name string) error {
return fmt.Errorf("marker timeline name '%s' was not recognized, valid options are '%s', '%s'", name, apimodel.MarkerNameHome, apimodel.MarkerNameNotifications)
}
-// FilterKeyword validates the title of a new or updated List.
+// FilterKeyword validates a filter keyword.
func FilterKeyword(keyword string) error {
if keyword == "" {
return fmt.Errorf("filter keyword must be provided, and must be no more than %d chars", maximumFilterKeywordLength)
@@ -321,6 +329,19 @@ func FilterKeyword(keyword string) error {
return nil
}
+// FilterTitle validates the title of a new or updated filter.
+func FilterTitle(title string) error {
+ if title == "" {
+ return fmt.Errorf("filter title must be provided, and must be no more than %d chars", maximumFilterTitleLength)
+ }
+
+ if length := len([]rune(title)); length > maximumFilterTitleLength {
+ return fmt.Errorf("filter title length must be no more than %d chars, provided title was %d chars", maximumFilterTitleLength, length)
+ }
+
+ return nil
+}
+
// FilterContexts validates the context of a new or updated filter.
func FilterContexts(contexts []apimodel.FilterContext) error {
if len(contexts) == 0 {
@@ -349,6 +370,20 @@ func FilterContexts(contexts []apimodel.FilterContext) error {
return nil
}
+func FilterAction(action apimodel.FilterAction) error {
+ switch action {
+ case apimodel.FilterActionWarn,
+ apimodel.FilterActionHide:
+ return nil
+ }
+ return fmt.Errorf(
+ "filter action '%s' was not recognized, valid options are '%s', '%s'",
+ action,
+ apimodel.FilterActionWarn,
+ apimodel.FilterActionHide,
+ )
+}
+
// CreateAccount checks through all the prerequisites for
// creating a new account, according to the provided form.
// If the account isn't eligible, an error will be returned.
diff --git a/testrig/testmodels.go b/testrig/testmodels.go
index 9ebd400e4..956c898c0 100644
--- a/testrig/testmodels.go
+++ b/testrig/testmodels.go
@@ -3288,6 +3288,26 @@ func NewTestFilters() map[string]*gtsmodel.Filter {
ContextHome: util.Ptr(true),
ContextPublic: util.Ptr(true),
},
+ "local_account_1_filter_3": {
+ ID: "01HWXQDXE4QX4R9EGMG729Y76C",
+ CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
+ UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
+ AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
+ Title: "puppies",
+ Action: gtsmodel.FilterActionWarn,
+ ContextHome: util.Ptr(true),
+ ContextPublic: util.Ptr(true),
+ },
+ "local_account_1_filter_4": {
+ ID: "01HZ55WWWP82WYP2A1BKWK8Y9Q",
+ CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
+ UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
+ AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
+ Title: "empty filter with no keywords or statuses",
+ Action: gtsmodel.FilterActionWarn,
+ ContextHome: util.Ptr(true),
+ ContextPublic: util.Ptr(true),
+ },
"local_account_2_filter_1": {
ID: "01HNGFYJBED9FS0VWRVMY4TKXH",
CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
@@ -3330,6 +3350,15 @@ func NewTestFilterKeywords() map[string]*gtsmodel.FilterKeyword {
Keyword: "bar",
WholeWord: util.Ptr(true),
},
+ "local_account_1_filter_2_keyword_3": {
+ ID: "01HXATJTGYT4BTG2YASE5M7GSD",
+ CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
+ UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
+ AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
+ FilterID: "01HN277FSPQAWXZXK92QPPYF79",
+ Keyword: "quux",
+ WholeWord: util.Ptr(true),
+ },
"local_account_2_filter_1_keyword_1": {
ID: "01HNGG51HV2JT67XQ5MQ7RA1WE",
CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"),
@@ -3343,8 +3372,24 @@ func NewTestFilterKeywords() map[string]*gtsmodel.FilterKeyword {
}
func NewTestFilterStatuses() map[string]*gtsmodel.FilterStatus {
- // FUTURE: (filters v2) test filter statuses
- return map[string]*gtsmodel.FilterStatus{}
+ return map[string]*gtsmodel.FilterStatus{
+ "local_account_1_filter_3_status_1": {
+ ID: "01HWXQDY8EE182AWQKS45JV50W",
+ CreatedAt: time.Time{},
+ UpdatedAt: time.Time{},
+ AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
+ FilterID: "01HWXQDXE4QX4R9EGMG729Y76C",
+ StatusID: "01F8MHAAY43M6RJ473VQFCVH37",
+ },
+ "local_account_2_filter_1_status_1": {
+ ID: "01HX9WXVEH05E78ABR81FZFFFY",
+ CreatedAt: time.Time{},
+ UpdatedAt: time.Time{},
+ AccountID: "01F8MH1VYJAE00TVVGMM5JNJ8X",
+ FilterID: "01HNGFYJBED9FS0VWRVMY4TKXH",
+ StatusID: "01FVW7JHQFSFK166WWKR8CBA6M",
+ },
+ }
}
// GetSignatureForActivity prepares a mock HTTP request as if it were going to deliver activity to destination signed for privkey and pubKeyID, signs the request and returns the header values.