diff options
66 files changed, 5601 insertions, 55 deletions
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 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/api/client/favourites"  	"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"  	filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1" +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"  	"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"  	"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"  	"github.com/superseriousbusiness/gotosocial/internal/api/client/lists" @@ -67,6 +68,7 @@ type Client struct {  	favourites     *favourites.Module     // api/v1/favourites  	featuredTags   *featuredtags.Module   // api/v1/featured_tags  	filtersV1      *filtersV1.Module      // api/v1/filters +	filtersV2      *filtersV2.Module      // api/v2/filters  	followRequests *followrequests.Module // api/v1/follow_requests  	instance       *instance.Module       // api/v1/instance  	lists          *lists.Module          // api/v1/lists @@ -111,6 +113,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {  	c.favourites.Route(h)  	c.featuredTags.Route(h)  	c.filtersV1.Route(h) +	c.filtersV2.Route(h)  	c.followRequests.Route(h)  	c.instance.Route(h)  	c.lists.Route(h) @@ -143,6 +146,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {  		favourites:     favourites.New(p),  		featuredTags:   featuredtags.New(p),  		filtersV1:      filtersV1.New(p), +		filtersV2:      filtersV2.New(p),  		followRequests: followrequests.New(p),  		instance:       instance.New(p),  		lists:          lists.New(p), diff --git a/internal/api/client/filters/v1/filterdelete.go b/internal/api/client/filters/v1/filterdelete.go index d86b277a6..267dd16d0 100644 --- a/internal/api/client/filters/v1/filterdelete.go +++ b/internal/api/client/filters/v1/filterdelete.go @@ -41,7 +41,7 @@ import (  //	-  //		name: id  //		type: string -//		description: ID of the list +//		description: ID of the filter  //		in: path  //		required: true  // diff --git a/internal/api/client/filters/v1/filterdelete_test.go b/internal/api/client/filters/v1/filterdelete_test.go index 83155f08a..20fd4351b 100644 --- a/internal/api/client/filters/v1/filterdelete_test.go +++ b/internal/api/client/filters/v1/filterdelete_test.go @@ -66,6 +66,9 @@ func (suite *FiltersTestSuite) deleteFilter(  	// check code + body  	if resultCode := recorder.Code; expectedHTTPStatus != resultCode {  		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return errs.Combine() +		}  	}  	// if we got an expected body, return early diff --git a/internal/api/client/filters/v1/filterget_test.go b/internal/api/client/filters/v1/filterget_test.go index a9dbf6dbb..e8fdedfaa 100644 --- a/internal/api/client/filters/v1/filterget_test.go +++ b/internal/api/client/filters/v1/filterget_test.go @@ -68,6 +68,9 @@ func (suite *FiltersTestSuite) getFilter(  	// check code + body  	if resultCode := recorder.Code; expectedHTTPStatus != resultCode {  		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		}  	}  	// if we got an expected body, return early diff --git a/internal/api/client/filters/v1/filterpost.go b/internal/api/client/filters/v1/filterpost.go index 2d19f69cf..9d92b9187 100644 --- a/internal/api/client/filters/v1/filterpost.go +++ b/internal/api/client/filters/v1/filterpost.go @@ -52,6 +52,7 @@ import (  //			The text to be filtered.  //  //			Sample: fnord +//		minLength: 1  //		maxLength: 40  //		type: string  //	- @@ -120,6 +121,8 @@ import (  //			description: not found  //		'406':  //			description: not acceptable +//		'409': +//			description: conflict (duplicate keyword)  //		'422':  //			description: unprocessable content  //		'500': diff --git a/internal/api/client/filters/v1/filterpost_test.go b/internal/api/client/filters/v1/filterpost_test.go index 729b2bd72..893415d99 100644 --- a/internal/api/client/filters/v1/filterpost_test.go +++ b/internal/api/client/filters/v1/filterpost_test.go @@ -94,6 +94,9 @@ func (suite *FiltersTestSuite) postFilter(  	// check code + body  	if resultCode := recorder.Code; expectedHTTPStatus != resultCode {  		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		}  	}  	// if we got an expected body, return early @@ -226,14 +229,3 @@ func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {  		suite.FailNow(err.Error())  	}  } - -// FUTURE: this should be removed once we support server-side filters. -func (suite *FiltersTestSuite) TestPostFilterIrreversibleNotSupported() { -	phrase := "GNU/Linux" -	context := []string{"home"} -	irreversible := true -	_, err := suite.postFilter(&phrase, &context, &irreversible, nil, nil, nil, http.StatusUnprocessableEntity, "") -	if err != nil { -		suite.FailNow(err.Error()) -	} -} diff --git a/internal/api/client/filters/v1/filterput.go b/internal/api/client/filters/v1/filterput.go index bb9fa809f..2a81f89fc 100644 --- a/internal/api/client/filters/v1/filterput.go +++ b/internal/api/client/filters/v1/filterput.go @@ -58,6 +58,7 @@ import (  //			The text to be filtered.  //  //			Sample: fnord +//		minLength: 1  //		maxLength: 40  //		type: string  //	- @@ -126,6 +127,8 @@ import (  //			description: not found  //		'406':  //			description: not acceptable +//		'409': +//			description: conflict (duplicate keyword)  //		'422':  //			description: unprocessable content  //		'500': diff --git a/internal/api/client/filters/v1/filterput_test.go b/internal/api/client/filters/v1/filterput_test.go index 0308e53d9..d810930d6 100644 --- a/internal/api/client/filters/v1/filterput_test.go +++ b/internal/api/client/filters/v1/filterput_test.go @@ -97,6 +97,9 @@ func (suite *FiltersTestSuite) putFilter(  	// check code + body  	if resultCode := recorder.Code; expectedHTTPStatus != resultCode {  		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		}  	}  	// if we got an expected body, return early @@ -238,16 +241,6 @@ func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {  	}  } -// FUTURE: this should be removed once we support server-side filters. -func (suite *FiltersTestSuite) TestPutFilterIrreversibleNotSupported() { -	id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID -	irreversible := true -	_, err := suite.putFilter(id, nil, nil, &irreversible, nil, nil, nil, http.StatusUnprocessableEntity, "") -	if err != nil { -		suite.FailNow(err.Error()) -	} -} -  func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() {  	id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID  	phrase := "GNU/Linux" diff --git a/internal/api/client/filters/v1/filtersget_test.go b/internal/api/client/filters/v1/filtersget_test.go index a568239ef..281ee4f63 100644 --- a/internal/api/client/filters/v1/filtersget_test.go +++ b/internal/api/client/filters/v1/filtersget_test.go @@ -64,6 +64,9 @@ func (suite *FiltersTestSuite) getFilters(  	// check code + body  	if resultCode := recorder.Code; expectedHTTPStatus != resultCode {  		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		}  	}  	// if we got an expected body, return early diff --git a/internal/api/client/filters/v1/validate.go b/internal/api/client/filters/v1/validate.go index b539c9563..550df54fa 100644 --- a/internal/api/client/filters/v1/validate.go +++ b/internal/api/client/filters/v1/validate.go @@ -31,6 +31,10 @@ func validateNormalizeCreateUpdateFilter(form *model.FilterCreateUpdateRequestV1  	if err := validate.FilterKeyword(form.Phrase); err != nil {  		return err  	} +	// For filter v1 forwards compatibility, the phrase is used as the title of a v2 filter, so it must pass that as well. +	if err := validate.FilterTitle(form.Phrase); err != nil { +		return err +	}  	if err := validate.FilterContexts(form.Context); err != nil {  		return err  	} diff --git a/internal/api/client/filters/v2/filter.go b/internal/api/client/filters/v2/filter.go new file mode 100644 index 000000000..58e7905ae --- /dev/null +++ b/internal/api/client/filters/v2/filter.go @@ -0,0 +1,80 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( +	// BasePath is the base path for serving the filters API, minus the 'api' prefix +	BasePath = "/v2/filters" +	// BasePathWithID is the base path with the ID key in it, for operations on an existing filter. +	BasePathWithID = BasePath + "/:" + apiutil.IDKey +	// FilterKeywordsPathWithID is the path for operations on an existing filter's keywords. +	FilterKeywordsPathWithID = BasePathWithID + "/keywords" +	// FilterStatusesPathWithID is the path for operations on an existing filter's statuses. +	FilterStatusesPathWithID = BasePathWithID + "/statuses" + +	// KeywordPath is the base path for operations on filter keywords that don't require a filter ID. +	KeywordPath = BasePath + "/keywords" +	// KeywordPathWithKeywordID is the path for operations on an existing filter keyword. +	KeywordPathWithKeywordID = KeywordPath + "/:" + apiutil.IDKey + +	// StatusPath is the base path for operations on filter statuses that don't require a filter ID. +	StatusPath = BasePath + "/statuses" +	// StatusPathWithStatusID is the path for operations on an existing filter status. +	StatusPathWithStatusID = StatusPath + "/:" + apiutil.IDKey +) + +// Module implements APIs for client-side aka "v1" filtering. +type Module struct { +	processor *processing.Processor +} + +func New(processor *processing.Processor) *Module { +	return &Module{ +		processor: processor, +	} +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { +	attachHandler(http.MethodGet, BasePath, m.FiltersGETHandler) + +	attachHandler(http.MethodPost, BasePath, m.FilterPOSTHandler) +	attachHandler(http.MethodGet, BasePathWithID, m.FilterGETHandler) +	attachHandler(http.MethodPut, BasePathWithID, m.FilterPUTHandler) +	attachHandler(http.MethodDelete, BasePathWithID, m.FilterDELETEHandler) + +	attachHandler(http.MethodGet, FilterKeywordsPathWithID, m.FilterKeywordsGETHandler) +	attachHandler(http.MethodPost, FilterKeywordsPathWithID, m.FilterKeywordPOSTHandler) + +	attachHandler(http.MethodGet, KeywordPathWithKeywordID, m.FilterKeywordGETHandler) +	attachHandler(http.MethodPut, KeywordPathWithKeywordID, m.FilterKeywordPUTHandler) +	attachHandler(http.MethodDelete, KeywordPathWithKeywordID, m.FilterKeywordDELETEHandler) + +	attachHandler(http.MethodGet, FilterStatusesPathWithID, m.FilterStatusesGETHandler) +	attachHandler(http.MethodPost, FilterStatusesPathWithID, m.FilterStatusPOSTHandler) + +	attachHandler(http.MethodGet, StatusPathWithStatusID, m.FilterStatusGETHandler) +	attachHandler(http.MethodDelete, StatusPathWithStatusID, m.FilterStatusDELETEHandler) +} diff --git a/internal/api/client/filters/v2/filter_test.go b/internal/api/client/filters/v2/filter_test.go new file mode 100644 index 000000000..f85357482 --- /dev/null +++ b/internal/api/client/filters/v2/filter_test.go @@ -0,0 +1,118 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"testing" + +	"github.com/stretchr/testify/suite" +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/email" +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/processing" +	"github.com/superseriousbusiness/gotosocial/internal/state" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type FiltersTestSuite struct { +	suite.Suite +	db           db.DB +	storage      *storage.Driver +	mediaManager *media.Manager +	federator    *federation.Federator +	processor    *processing.Processor +	emailSender  email.Sender +	sentEmails   map[string]string +	state        state.State + +	// standard suite models +	testTokens         map[string]*gtsmodel.Token +	testClients        map[string]*gtsmodel.Client +	testApplications   map[string]*gtsmodel.Application +	testUsers          map[string]*gtsmodel.User +	testAccounts       map[string]*gtsmodel.Account +	testStatuses       map[string]*gtsmodel.Status +	testFilters        map[string]*gtsmodel.Filter +	testFilterKeywords map[string]*gtsmodel.FilterKeyword +	testFilterStatuses map[string]*gtsmodel.FilterStatus + +	// module being tested +	filtersModule *filtersV2.Module +} + +func (suite *FiltersTestSuite) SetupSuite() { +	suite.testTokens = testrig.NewTestTokens() +	suite.testClients = testrig.NewTestClients() +	suite.testApplications = testrig.NewTestApplications() +	suite.testUsers = testrig.NewTestUsers() +	suite.testAccounts = testrig.NewTestAccounts() +	suite.testStatuses = testrig.NewTestStatuses() +	suite.testFilters = testrig.NewTestFilters() +	suite.testFilterKeywords = testrig.NewTestFilterKeywords() +	suite.testFilterStatuses = testrig.NewTestFilterStatuses() +} + +func (suite *FiltersTestSuite) SetupTest() { +	suite.state.Caches.Init() +	testrig.StartNoopWorkers(&suite.state) + +	testrig.InitTestConfig() +	config.Config(func(cfg *config.Configuration) { +		cfg.WebAssetBaseDir = "../../../../../web/assets/" +		cfg.WebTemplateBaseDir = "../../../../../web/templates/" +	}) +	testrig.InitTestLog() + +	suite.db = testrig.NewTestDB(&suite.state) +	suite.state.DB = suite.db +	suite.storage = testrig.NewInMemoryStorage() +	suite.state.Storage = suite.storage + +	testrig.StartTimelines( +		&suite.state, +		visibility.NewFilter(&suite.state), +		typeutils.NewConverter(&suite.state), +	) + +	suite.mediaManager = testrig.NewTestMediaManager(&suite.state) +	suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager) +	suite.sentEmails = make(map[string]string) +	suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails) +	suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) +	suite.filtersModule = filtersV2.New(suite.processor) + +	testrig.StandardDBSetup(suite.db, nil) +	testrig.StandardStorageSetup(suite.storage, "../../../../../testrig/media") +} + +func (suite *FiltersTestSuite) TearDownTest() { +	testrig.StandardDBTeardown(suite.db) +	testrig.StandardStorageTeardown(suite.storage) +	testrig.StopWorkers(&suite.state) +} + +func TestFiltersTestSuite(t *testing.T) { +	suite.Run(t, new(FiltersTestSuite)) +} diff --git a/internal/api/client/filters/v2/filterdelete.go b/internal/api/client/filters/v2/filterdelete.go new file mode 100644 index 000000000..7292fd631 --- /dev/null +++ b/internal/api/client/filters/v2/filterdelete.go @@ -0,0 +1,90 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterDELETEHandler swagger:operation DELETE /api/v2/filters/{id} filterV2Delete +// +// Delete a single filter with the given ID. +// +//	--- +//	tags: +//	- filters +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		type: string +//		description: ID of the filter +//		in: path +//		required: true +// +//	security: +//	- OAuth2 Bearer: +//		- write:filters +// +//	responses: +//		'200': +//			description: filter deleted +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) FilterDELETEHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	errWithCode = m.processor.FiltersV2().Delete(c.Request.Context(), authed.Account, id) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/filters/v2/filterdelete_test.go b/internal/api/client/filters/v2/filterdelete_test.go new file mode 100644 index 000000000..ff9bf23f5 --- /dev/null +++ b/internal/api/client/filters/v2/filterdelete_test.go @@ -0,0 +1,115 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) deleteFilter( +	filterID string, +	expectedHTTPStatus int, +	expectedBody string, +) error { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID, nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterID) + +	// trigger the handler +	suite.filtersModule.FilterDELETEHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return errs.Combine() +	} + +	resp := &struct{}{} +	if err := json.Unmarshal(b, resp); err != nil { +		return err +	} + +	return nil +} + +func (suite *FiltersTestSuite) TestDeleteFilter() { +	id := suite.testFilters["local_account_1_filter_1"].ID + +	err := suite.deleteFilter(id, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() { +	id := suite.testFilters["local_account_2_filter_1"].ID + +	err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestDeleteNonexistentFilter() { +	id := "not_even_a_real_ULID" + +	err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterget.go b/internal/api/client/filters/v2/filterget.go new file mode 100644 index 000000000..a3481e0e0 --- /dev/null +++ b/internal/api/client/filters/v2/filterget.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterGETHandler swagger:operation GET /api/v2/filters/{id} filterV2Get +// +// Get a single filter with the given ID. +// +//	--- +//	tags: +//	- filters +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		type: string +//		description: ID of the filter +//		in: path +//		required: true +// +//	security: +//	- OAuth2 Bearer: +//		- read:filters +// +//	responses: +//		'200': +//			name: filter +//			description: Requested filter. +//			schema: +//				"$ref": "#/definitions/filterV2" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) FilterGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().Get(c.Request.Context(), authed.Account, id) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterget_test.go b/internal/api/client/filters/v2/filterget_test.go new file mode 100644 index 000000000..256732132 --- /dev/null +++ b/internal/api/client/filters/v2/filterget_test.go @@ -0,0 +1,133 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilter( +	filterID string, +	expectedHTTPStatus int, +	expectedBody string, +) (*apimodel.FilterV2, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID, nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterID) + +	// trigger the handler +	suite.filtersModule.FilterGETHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterV2{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilter() { +	expectedFilter := suite.testFilters["local_account_1_filter_1"] + +	filter, err := suite.getFilter(expectedFilter.ID, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.NotEmpty(filter) +	suite.Equal(expectedFilter.Action, typeutils.APIFilterActionToFilterAction(filter.FilterAction)) +	suite.Equal(expectedFilter.ID, filter.ID) +	suite.Equal(expectedFilter.Title, filter.Title) +} + +func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() { +	id := suite.testFilters["local_account_2_filter_1"].ID + +	_, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestGetNonexistentFilter() { +	id := "not_even_a_real_ULID" + +	_, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +// Test that an empty filter with no keywords or statuses serializes the keywords and statuses arrays as empty arrays, +// not as null values or entirely omitted fields. +func (suite *FiltersTestSuite) TestGetEmptyFilter() { +	id := suite.testFilters["local_account_1_filter_4"].ID + +	_, err := suite.getFilter(id, http.StatusOK, `{"id":"01HZ55WWWP82WYP2A1BKWK8Y9Q","title":"empty filter with no keywords or statuses","context":["home","public"],"expires_at":null,"filter_action":"warn","keywords":[],"statuses":[]}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterkeyworddelete.go b/internal/api/client/filters/v2/filterkeyworddelete.go new file mode 100644 index 000000000..41ef12bfb --- /dev/null +++ b/internal/api/client/filters/v2/filterkeyworddelete.go @@ -0,0 +1,54 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) FilterKeywordDELETEHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	errWithCode = m.processor.FiltersV2().KeywordDelete(c.Request.Context(), authed.Account, id) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/filters/v2/filterkeyworddelete_test.go b/internal/api/client/filters/v2/filterkeyworddelete_test.go new file mode 100644 index 000000000..fc949593d --- /dev/null +++ b/internal/api/client/filters/v2/filterkeyworddelete_test.go @@ -0,0 +1,115 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) deleteFilterKeyword( +	filterKeywordID string, +	expectedHTTPStatus int, +	expectedBody string, +) error { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterKeywordID) + +	// trigger the handler +	suite.filtersModule.FilterKeywordDELETEHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return errs.Combine() +	} + +	resp := &struct{}{} +	if err := json.Unmarshal(b, resp); err != nil { +		return err +	} + +	return nil +} + +func (suite *FiltersTestSuite) TestDeleteFilterKeyword() { +	id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID + +	err := suite.deleteFilterKeyword(id, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterKeyword() { +	id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID + +	err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestDeleteNonexistentFilterKeyword() { +	id := "not_even_a_real_ULID" + +	err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterkeywordget.go b/internal/api/client/filters/v2/filterkeywordget.go new file mode 100644 index 000000000..2df6fd10a --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordget.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterKeywordGETHandler swagger:operation GET /api/v2/filters/keywords/{id} filterKeywordGet +// +// Get a single filter keyword with the given ID. +// +//	--- +//	tags: +//	- filters +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		type: string +//		description: ID of the filter keyword +//		in: path +//		required: true +// +//	security: +//	- OAuth2 Bearer: +//		- read:filters +// +//	responses: +//		'200': +//			name: filterKeyword +//			description: Requested filter keyword. +//			schema: +//				"$ref": "#/definitions/filterKeyword" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) FilterKeywordGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().KeywordGet(c.Request.Context(), authed.Account, id) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterkeywordget_test.go b/internal/api/client/filters/v2/filterkeywordget_test.go new file mode 100644 index 000000000..a5d8754a6 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordget_test.go @@ -0,0 +1,122 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterKeyword( +	filterKeywordID string, +	expectedHTTPStatus int, +	expectedBody string, +) (*apimodel.FilterKeyword, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterKeywordID) + +	// trigger the handler +	suite.filtersModule.FilterKeywordGETHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterKeyword{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterKeyword() { +	expectedFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"] + +	filterKeyword, err := suite.getFilterKeyword(expectedFilterKeyword.ID, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.NotEmpty(filterKeyword) +	suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID) +	suite.Equal(expectedFilterKeyword.Keyword, filterKeyword.Keyword) +	suite.Equal(util.PtrValueOr(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() { +	id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID + +	_, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestGetNonexistentFilterKeyword() { +	id := "not_even_a_real_ULID" + +	_, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterkeywordpost.go b/internal/api/client/filters/v2/filterkeywordpost.go new file mode 100644 index 000000000..7ec595820 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordpost.go @@ -0,0 +1,151 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// FilterKeywordPOSTHandler swagger:operation POST /api/v2/filters/{id}/keywords filterKeywordPost +// +// Add a filter keyword to an existing filter. +// +//	--- +//	tags: +//	- filters +// +//	consumes: +//	- application/json +//	- application/xml +//	- application/x-www-form-urlencoded +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		in: path +//		type: string +//		required: true +//		description: ID of the filter to add the filtered status to. +//	- +//		name: keyword +//		in: formData +//		required: true +//		description: |- +//			The text to be filtered +// +//			Sample: fnord +//		type: string +//		minLength: 1 +//		maxLength: 40 +//	- +//		name: whole_word +//		in: formData +//		description: |- +//			Should the filter consider word boundaries? +// +//			Sample: true +//		type: boolean +//		default: false +// +//	security: +//	- OAuth2 Bearer: +//		- write:filters +// +//	responses: +//		'200': +//			name: filterKeyword +//			description: New filter keyword. +//			schema: +//				"$ref": "#/definitions/filterKeyword" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'409': +//			description: conflict (duplicate keyword) +//		'422': +//			description: unprocessable content +//		'500': +//			description: internal server error +func (m *Module) FilterKeywordPOSTHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if authed.Account.IsMoving() { +		apiutil.ForbiddenAfterMove(c) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	form := &apimodel.FilterKeywordCreateUpdateRequest{} +	if err := c.ShouldBind(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if err := validateNormalizeCreateUpdateFilterKeyword(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().KeywordCreate(c.Request.Context(), authed.Account, filterID, form) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiutil.JSON(c, http.StatusOK, apiFilter) +} + +func validateNormalizeCreateUpdateFilterKeyword(form *apimodel.FilterKeywordCreateUpdateRequest) error { +	if err := validate.FilterKeyword(form.Keyword); err != nil { +		return err +	} + +	form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) + +	return nil +} diff --git a/internal/api/client/filters/v2/filterkeywordpost_test.go b/internal/api/client/filters/v2/filterkeywordpost_test.go new file mode 100644 index 000000000..85cc72f05 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordpost_test.go @@ -0,0 +1,192 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" +	"net/url" +	"strconv" +	"strings" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) postFilterKeyword( +	filterID string, +	keyword *string, +	wholeWord *bool, +	requestJson *string, +	expectedHTTPStatus int, +	expectedBody string, +) (*apimodel.FilterKeyword, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/keywords", nil) +	ctx.Request.Header.Set("accept", "application/json") +	if requestJson != nil { +		ctx.Request.Header.Set("content-type", "application/json") +		ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) +	} else { +		ctx.Request.Form = make(url.Values) +		if keyword != nil { +			ctx.Request.Form["keyword"] = []string{*keyword} +		} +		if wholeWord != nil { +			ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)} +		} +	} + +	ctx.AddParam("id", filterID) + +	// trigger the handler +	suite.filtersModule.FilterKeywordPOSTHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterKeyword{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordFull() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	keyword := "fnords" +	wholeWord := true +	filterKeyword, err := suite.postFilterKeyword(filterID, &keyword, &wholeWord, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(keyword, filterKeyword.Keyword) +	suite.Equal(wholeWord, filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordFullJSON() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	requestJson := `{ +		"keyword": "fnords", +		"whole_word": true +	}` +	filterKeyword, err := suite.postFilterKeyword(filterID, nil, nil, &requestJson, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal("fnords", filterKeyword.Keyword) +	suite.True(filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordMinimal() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	keyword := "fnords" +	filterKeyword, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(keyword, filterKeyword.Keyword) +	suite.False(filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordEmptyKeyword() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	keyword := "" +	_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordMissingKeyword() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	_, err := suite.postFilterKeyword(filterID, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +// Creating another filter keyword in the same filter with the same keyword should fail. +func (suite *FiltersTestSuite) TestPostFilterKeywordKeywordConflict() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	keyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].Keyword +	_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusConflict, `{"error":"Conflict: duplicate keyword"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordAnotherAccountsFilter() { +	filterID := suite.testFilters["local_account_2_filter_1"].ID +	keyword := "fnords" +	_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordNonexistentFilter() { +	filterID := "not_even_a_real_ULID" +	keyword := "fnords" +	_, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterkeywordput.go b/internal/api/client/filters/v2/filterkeywordput.go new file mode 100644 index 000000000..5ef0fe976 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordput.go @@ -0,0 +1,138 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterKeywordPUTHandler swagger:operation PUT /api/v2/filters/keywords{id} filterKeywordPut +// +// Update a single filter keyword with the given ID. +// +//	--- +//	tags: +//	- filters +// +//	consumes: +//	- application/json +//	- application/xml +//	- application/x-www-form-urlencoded +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		in: path +//		type: string +//		required: true +//		description: ID of the filter keyword to update. +//	- +//		name: keyword +//		in: formData +//		required: true +//		description: |- +//			The text to be filtered +// +//			Sample: fnord +//		type: string +//		minLength: 1 +//		maxLength: 40 +//	- +//		name: whole_word +//		in: formData +//		description: |- +//			Should the filter consider word boundaries? +// +//			Sample: true +//		type: boolean +// +//	security: +//	- OAuth2 Bearer: +//		- write:filters +// +//	responses: +//		'200': +//			name: filterKeyword +//			description: Updated filter keyword. +//			schema: +//				"$ref": "#/definitions/filterKeyword" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'409': +//			description: conflict (duplicate keyword) +//		'422': +//			description: unprocessable content +//		'500': +//			description: internal server error +func (m *Module) FilterKeywordPUTHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if authed.Account.IsMoving() { +		apiutil.ForbiddenAfterMove(c) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	form := &apimodel.FilterKeywordCreateUpdateRequest{} +	if err := c.ShouldBind(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if err := validateNormalizeCreateUpdateFilterKeyword(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().KeywordUpdate(c.Request.Context(), authed.Account, id, form) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiutil.JSON(c, http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterkeywordput_test.go b/internal/api/client/filters/v2/filterkeywordput_test.go new file mode 100644 index 000000000..55253066d --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordput_test.go @@ -0,0 +1,192 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" +	"net/url" +	"strconv" +	"strings" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) putFilterKeyword( +	filterKeywordID string, +	keyword *string, +	wholeWord *bool, +	requestJson *string, +	expectedHTTPStatus int, +	expectedBody string, +) (*apimodel.FilterKeyword, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil) +	ctx.Request.Header.Set("accept", "application/json") +	if requestJson != nil { +		ctx.Request.Header.Set("content-type", "application/json") +		ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) +	} else { +		ctx.Request.Form = make(url.Values) +		if keyword != nil { +			ctx.Request.Form["keyword"] = []string{*keyword} +		} +		if wholeWord != nil { +			ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)} +		} +	} + +	ctx.AddParam("id", filterKeywordID) + +	// trigger the handler +	suite.filtersModule.FilterKeywordPUTHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterKeyword{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordFull() { +	filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID +	keyword := "fnords" +	wholeWord := true +	filterKeyword, err := suite.putFilterKeyword(filterKeywordID, &keyword, &wholeWord, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(keyword, filterKeyword.Keyword) +	suite.Equal(wholeWord, filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordFullJSON() { +	filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID +	requestJson := `{ +		"keyword": "fnords", +		"whole_word": true +	}` +	filterKeyword, err := suite.putFilterKeyword(filterKeywordID, nil, nil, &requestJson, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal("fnords", filterKeyword.Keyword) +	suite.True(filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordMinimal() { +	filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID +	keyword := "fnords" +	filterKeyword, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(keyword, filterKeyword.Keyword) +	suite.False(filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordEmptyKeyword() { +	filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID +	keyword := "" +	_, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordMissingKeyword() { +	filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID +	_, err := suite.putFilterKeyword(filterKeywordID, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +// Changing our filter keyword to the same keyword as another filter keyword in the same filter should fail. +func (suite *FiltersTestSuite) TestPutFilterKeywordKeywordConflict() { +	filterKeywordID := suite.testFilterKeywords["local_account_1_filter_2_keyword_1"].ID +	conflictingKeyword := suite.testFilterKeywords["local_account_1_filter_2_keyword_2"].Keyword +	_, err := suite.putFilterKeyword(filterKeywordID, &conflictingKeyword, nil, nil, http.StatusConflict, `{"error":"Conflict: duplicate keyword"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordAnotherAccountsFilterKeyword() { +	filterKeywordID := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID +	keyword := "fnord" +	_, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordNonexistentFilterKeyword() { +	filterKeywordID := "not_even_a_real_ULID" +	keyword := "fnord" +	_, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterkeywordsget.go b/internal/api/client/filters/v2/filterkeywordsget.go new file mode 100644 index 000000000..3414c5d8c --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordsget.go @@ -0,0 +1,95 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterKeywordsGETHandler swagger:operation GET /api/v2/filters/{id}/keywords filterKeywordsGet +// +// Get all filter keywords for a given filter. +// +//	--- +//	tags: +//	- filters +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		type: string +//		description: ID of the filter +//		in: path +//		required: true +// +//	security: +//	- OAuth2 Bearer: +//		- read:filters +// +//	responses: +//		'200': +//			name: filterKeywords +//			description: Requested filter keywords. +//			schema: +//				type: array +//				items: +//					"$ref": "#/definitions/filterKeyword" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) FilterKeywordsGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().KeywordsGetForFilterID(c.Request.Context(), authed.Account, filterID) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterkeywordsget_test.go b/internal/api/client/filters/v2/filterkeywordsget_test.go new file mode 100644 index 000000000..0b0b69e03 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordsget_test.go @@ -0,0 +1,117 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterKeywords( +	filterID string, +	expectedHTTPStatus int, +	expectedBody string, +) ([]*apimodel.FilterKeyword, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request + +	ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/keywords", nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterID) + +	// trigger the handler +	suite.filtersModule.FilterKeywordsGETHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := make([]*apimodel.FilterKeyword, 0) +	if err := json.Unmarshal(b, &resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterKeywords() { +	// Collect the sets of filter keyword IDs we expect to see. +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	expectedFilterKeywordIDs := []string{} +	for _, filterKeyword := range suite.testFilterKeywords { +		if filterKeyword.FilterID == filterID { +			expectedFilterKeywordIDs = append(expectedFilterKeywordIDs, filterKeyword.ID) +		} +	} +	suite.NotEmpty(expectedFilterKeywordIDs) + +	// Fetch all filter keywords for the test filter. +	filterKeywords, err := suite.getFilterKeywords(filterID, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.NotEmpty(filterKeywords) + +	// Check that we got the right ones. +	suite.Len(filterKeywords, len(expectedFilterKeywordIDs)) +	actualFilterKeywordIDs := []string{} +	for _, filterKeyword := range filterKeywords { +		actualFilterKeywordIDs = append(actualFilterKeywordIDs, filterKeyword.ID) +	} +	suite.ElementsMatch(expectedFilterKeywordIDs, actualFilterKeywordIDs) +} diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go new file mode 100644 index 000000000..cbe499fa6 --- /dev/null +++ b/internal/api/client/filters/v2/filterpost.go @@ -0,0 +1,202 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"fmt" +	"net/http" +	"strconv" + +	"github.com/gin-gonic/gin" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// FilterPOSTHandler swagger:operation POST /api/v2/filters filterV2Post +// +// Create a single filter. +// +//	--- +//	tags: +//	- filters +// +//	consumes: +//	- application/json +//	- application/xml +//	- application/x-www-form-urlencoded +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: title +//		in: formData +//		required: true +//		description: |- +//			The name of the filter. +// +//			Sample: illuminati nonsense +//		type: string +//		minLength: 1 +//		maxLength: 200 +//	- +//		name: context[] +//		in: formData +//		required: true +//		description: |- +//			The contexts in which the filter should be applied. +// +//			Sample: home, public +//		enum: +//			- home +//			- notifications +//			- public +//			- thread +//			- account +//		type: array +//		items: +//			type: +//				string +//		collectionFormat: multi +//		minItems: 1 +//		uniqueItems: true +//	- +//		name: expires_in +//		in: formData +//		description: |- +//			Number of seconds from now that the filter should expire. If omitted, filter never expires. +// +//			Sample: 86400 +//		type: number +//	- +//		name: filter_action +//		in: formData +//		description: |- +//			The action to be taken when a status matches this filter. +// +//			Sample: warn +//		type: string +//		enum: +//			- warn +//			- hide +//		default: warn +// +//	security: +//	- OAuth2 Bearer: +//		- write:filters +// +//	responses: +//		'200': +//			name: filter +//			description: New filter. +//			schema: +//				"$ref": "#/definitions/filterV2" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'409': +//			description: conflict (duplicate title, keyword, or status) +//		'422': +//			description: unprocessable content +//		'500': +//			description: internal server error +func (m *Module) FilterPOSTHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if authed.Account.IsMoving() { +		apiutil.ForbiddenAfterMove(c) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	form := &apimodel.FilterCreateRequestV2{} +	if err := c.ShouldBind(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if err := validateNormalizeCreateFilter(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().Create(c.Request.Context(), authed.Account, form) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiutil.JSON(c, http.StatusOK, apiFilter) +} + +func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error { +	if err := validate.FilterTitle(form.Title); err != nil { +		return err +	} +	action := util.PtrValueOr(form.FilterAction, apimodel.FilterActionWarn) +	if err := validate.FilterAction(action); err != nil { +		return err +	} +	if err := validate.FilterContexts(form.Context); err != nil { +		return err +	} + +	// Apply defaults for missing fields. +	form.FilterAction = util.Ptr(action) + +	// Normalize filter expiry if necessary. +	// If we parsed this as JSON, expires_in +	// may be either a float64 or a string. +	if ei := form.ExpiresInI; ei != nil { +		switch e := ei.(type) { +		case float64: +			form.ExpiresIn = util.Ptr(int(e)) + +		case string: +			expiresIn, err := strconv.Atoi(e) +			if err != nil { +				return fmt.Errorf("could not parse expires_in value %s as integer: %w", e, err) +			} + +			form.ExpiresIn = &expiresIn + +		default: +			return fmt.Errorf("could not parse expires_in type %T as integer", ei) +		} +	} + +	return nil +} diff --git a/internal/api/client/filters/v2/filterpost_test.go b/internal/api/client/filters/v2/filterpost_test.go new file mode 100644 index 000000000..cad803895 --- /dev/null +++ b/internal/api/client/filters/v2/filterpost_test.go @@ -0,0 +1,221 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" +	"net/url" +	"strconv" +	"strings" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath, nil) +	ctx.Request.Header.Set("accept", "application/json") +	if requestJson != nil { +		ctx.Request.Header.Set("content-type", "application/json") +		ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) +	} else { +		ctx.Request.Form = make(url.Values) +		if title != nil { +			ctx.Request.Form["title"] = []string{*title} +		} +		if context != nil { +			ctx.Request.Form["context[]"] = *context +		} +		if action != nil { +			ctx.Request.Form["filter_action"] = []string{*action} +		} +		if expiresIn != nil { +			ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)} +		} +	} + +	// trigger the handler +	suite.filtersModule.FilterPOSTHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterV2{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestPostFilterFull() { +	title := "GNU/Linux" +	context := []string{"home", "public"} +	action := "warn" +	expiresIn := 86400 +	filter, err := suite.postFilter(&title, &context, &action, &expiresIn, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(title, filter.Title) +	filterContext := make([]string, 0, len(filter.Context)) +	for _, c := range filter.Context { +		filterContext = append(filterContext, string(c)) +	} +	suite.ElementsMatch(context, filterContext) +	suite.Equal(apimodel.FilterActionWarn, filter.FilterAction) +	if suite.NotNil(filter.ExpiresAt) { +		suite.NotEmpty(*filter.ExpiresAt) +	} +	suite.Empty(filter.Keywords) +	suite.Empty(filter.Statuses) +} + +func (suite *FiltersTestSuite) TestPostFilterFullJSON() { +	// Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "expires_in". +	requestJson := `{ +		"title": "GNU/Linux", +		"context": ["home", "public"], +		"filter_action": "warn", +		"whole_word": true, +		"expires_in": 86400.1 +	}` +	filter, err := suite.postFilter(nil, nil, nil, nil, &requestJson, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal("GNU/Linux", filter.Title) +	suite.ElementsMatch( +		[]apimodel.FilterContext{ +			apimodel.FilterContextHome, +			apimodel.FilterContextPublic, +		}, +		filter.Context, +	) +	suite.Equal(apimodel.FilterActionWarn, filter.FilterAction) +	if suite.NotNil(filter.ExpiresAt) { +		suite.NotEmpty(*filter.ExpiresAt) +	} +	suite.Empty(filter.Keywords) +	suite.Empty(filter.Statuses) +} + +func (suite *FiltersTestSuite) TestPostFilterMinimal() { +	title := "GNU/Linux" +	context := []string{"home"} +	filter, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(title, filter.Title) +	filterContext := make([]string, 0, len(filter.Context)) +	for _, c := range filter.Context { +		filterContext = append(filterContext, string(c)) +	} +	suite.ElementsMatch(context, filterContext) +	suite.Equal(apimodel.FilterActionWarn, filter.FilterAction) +	suite.Nil(filter.ExpiresAt) +	suite.Empty(filter.Keywords) +	suite.Empty(filter.Statuses) +} + +func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() { +	title := "" +	context := []string{"home"} +	_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterMissingTitle() { +	context := []string{"home"} +	_, err := suite.postFilter(nil, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterEmptyContext() { +	title := "GNU/Linux" +	context := []string{} +	_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterMissingContext() { +	title := "GNU/Linux" +	_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +// Creating another filter with the same title should fail. +func (suite *FiltersTestSuite) TestPostFilterTitleConflict() { +	title := suite.testFilters["local_account_1_filter_1"].Title +	_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterput.go b/internal/api/client/filters/v2/filterput.go new file mode 100644 index 000000000..e24ec0b4d --- /dev/null +++ b/internal/api/client/filters/v2/filterput.go @@ -0,0 +1,206 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"fmt" +	"net/http" +	"strconv" + +	"github.com/gin-gonic/gin" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// FilterPUTHandler swagger:operation PUT /api/v2/filters/{id} filterV2Put +// +// Update a single filter with the given ID. +// Note that this is actually closer to a PATCH operation: +// only provided fields will be updated, and omitted fields will remain set to previous values. +// +//	--- +//	tags: +//	- filters +// +//	consumes: +//	- application/json +//	- application/xml +//	- application/x-www-form-urlencoded +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		in: path +//		type: string +//		required: true +//		description: ID of the filter. +//	- +//		name: title +//		in: formData +//		required: true +//		description: |- +//			The name of the filter. +// +//			Sample: illuminati nonsense +//		type: string +//		minLength: 1 +//		maxLength: 200 +//	- +//		name: context[] +//		in: formData +//		required: true +//		description: |- +//			The contexts in which the filter should be applied. +// +//			Sample: home, public +//		enum: +//			- home +//			- notifications +//			- public +//			- thread +//			- account +//		type: array +//		items: +//			type: +//				string +//		collectionFormat: multi +//		minItems: 1 +//		uniqueItems: true +//	- +//		name: expires_in +//		in: formData +//		description: |- +//			Number of seconds from now that the filter should expire. +// +//			Sample: 86400 +//		type: number +// +//	security: +//	- OAuth2 Bearer: +//		- write:filters +// +//	responses: +//		'200': +//			name: filter +//			description: Updated filter. +//			schema: +//				"$ref": "#/definitions/filterV2" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'409': +//			description: conflict (duplicate title, keyword, or status) +//		'422': +//			description: unprocessable content +//		'500': +//			description: internal server error +func (m *Module) FilterPUTHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if authed.Account.IsMoving() { +		apiutil.ForbiddenAfterMove(c) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	form := &apimodel.FilterUpdateRequestV2{} +	if err := c.ShouldBind(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if err := validateNormalizeUpdateFilter(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().Update(c.Request.Context(), authed.Account, id, form) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiutil.JSON(c, http.StatusOK, apiFilter) +} + +func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error { +	if form.Title != nil { +		if err := validate.FilterTitle(*form.Title); err != nil { +			return err +		} +	} +	if form.FilterAction != nil { +		if err := validate.FilterAction(*form.FilterAction); err != nil { +			return err +		} +	} +	if form.Context != nil { +		if err := validate.FilterContexts(*form.Context); err != nil { +			return err +		} +	} + +	// Normalize filter expiry if necessary. +	// If we parsed this as JSON, expires_in +	// may be either a float64 or a string. +	if ei := form.ExpiresInI; ei != nil { +		switch e := ei.(type) { +		case float64: +			form.ExpiresIn = util.Ptr(int(e)) + +		case string: +			expiresIn, err := strconv.Atoi(e) +			if err != nil { +				return fmt.Errorf("could not parse expires_in value %s as integer: %w", e, err) +			} + +			form.ExpiresIn = &expiresIn + +		default: +			return fmt.Errorf("could not parse expires_in type %T as integer", ei) +		} +	} + +	return nil +} diff --git a/internal/api/client/filters/v2/filterput_test.go b/internal/api/client/filters/v2/filterput_test.go new file mode 100644 index 000000000..8b4576abe --- /dev/null +++ b/internal/api/client/filters/v2/filterput_test.go @@ -0,0 +1,230 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" +	"net/url" +	"strconv" +	"strings" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID, nil) +	ctx.Request.Header.Set("accept", "application/json") +	if requestJson != nil { +		ctx.Request.Header.Set("content-type", "application/json") +		ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) +	} else { +		ctx.Request.Form = make(url.Values) +		if title != nil { +			ctx.Request.Form["title"] = []string{*title} +		} +		if context != nil { +			ctx.Request.Form["context[]"] = *context +		} +		if action != nil { +			ctx.Request.Form["filter_action"] = []string{*action} +		} +		if expiresIn != nil { +			ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)} +		} +	} + +	ctx.AddParam("id", filterID) + +	// trigger the handler +	suite.filtersModule.FilterPUTHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterV2{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestPutFilterFull() { +	id := suite.testFilters["local_account_1_filter_2"].ID +	title := "messy synoptic varblabbles" +	context := []string{"home", "public"} +	action := "hide" +	expiresIn := 86400 +	filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(title, filter.Title) +	filterContext := make([]string, 0, len(filter.Context)) +	for _, c := range filter.Context { +		filterContext = append(filterContext, string(c)) +	} +	suite.ElementsMatch(context, filterContext) +	suite.Equal(apimodel.FilterActionHide, filter.FilterAction) +	if suite.NotNil(filter.ExpiresAt) { +		suite.NotEmpty(*filter.ExpiresAt) +	} +	suite.Len(filter.Keywords, 3) +	suite.Len(filter.Statuses, 0) +} + +func (suite *FiltersTestSuite) TestPutFilterFullJSON() { +	id := suite.testFilters["local_account_1_filter_2"].ID +	// Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "expires_in". +	requestJson := `{ +		"title": "messy synoptic varblabbles", +		"context": ["home", "public"], +		"filter_action": "hide", +		"expires_in": 86400.1 +	}` +	filter, err := suite.putFilter(id, nil, nil, nil, nil, &requestJson, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal("messy synoptic varblabbles", filter.Title) +	suite.ElementsMatch( +		[]apimodel.FilterContext{ +			apimodel.FilterContextHome, +			apimodel.FilterContextPublic, +		}, +		filter.Context, +	) +	suite.Equal(apimodel.FilterActionHide, filter.FilterAction) +	if suite.NotNil(filter.ExpiresAt) { +		suite.NotEmpty(*filter.ExpiresAt) +	} +	suite.Len(filter.Keywords, 3) +	suite.Len(filter.Statuses, 0) +} + +func (suite *FiltersTestSuite) TestPutFilterMinimal() { +	id := suite.testFilters["local_account_1_filter_1"].ID +	title := "GNU/Linux" +	context := []string{"home"} +	filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(title, filter.Title) +	filterContext := make([]string, 0, len(filter.Context)) +	for _, c := range filter.Context { +		filterContext = append(filterContext, string(c)) +	} +	suite.ElementsMatch(context, filterContext) +	suite.Equal(apimodel.FilterActionWarn, filter.FilterAction) +	suite.Nil(filter.ExpiresAt) +} + +func (suite *FiltersTestSuite) TestPutFilterEmptyTitle() { +	id := suite.testFilters["local_account_1_filter_1"].ID +	title := "" +	context := []string{"home"} +	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPutFilterEmptyContext() { +	id := suite.testFilters["local_account_1_filter_1"].ID +	title := "GNU/Linux" +	context := []string{} +	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +// Changing our title to a title used by an existing filter should fail. +func (suite *FiltersTestSuite) TestPutFilterTitleConflict() { +	id := suite.testFilters["local_account_1_filter_1"].ID +	title := suite.testFilters["local_account_1_filter_2"].Title +	_, err := suite.putFilter(id, &title, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() { +	id := suite.testFilters["local_account_2_filter_1"].ID +	title := "GNU/Linux" +	context := []string{"home"} +	_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPutNonexistentFilter() { +	id := "not_even_a_real_ULID" +	phrase := "GNU/Linux" +	context := []string{"home"} +	_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filtersget.go b/internal/api/client/filters/v2/filtersget.go new file mode 100644 index 000000000..511a62d36 --- /dev/null +++ b/internal/api/client/filters/v2/filtersget.go @@ -0,0 +1,81 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FiltersGETHandler swagger:operation GET /api/v2/filters filtersV2Get +// +// Get all filters for the authenticated account. +// +//	--- +//	tags: +//	- filters +// +//	produces: +//	- application/json +// +//	security: +//	- OAuth2 Bearer: +//		- read:filters +// +//	responses: +//		'200': +//			name: filters +//			description: Requested filters. +//			schema: +//				type: array +//				items: +//					"$ref": "#/definitions/filterV2" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) FiltersGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	apiFilters, errWithCode := m.processor.FiltersV2().GetAll(c.Request.Context(), authed.Account) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiFilters) +} diff --git a/internal/api/client/filters/v2/filtersget_test.go b/internal/api/client/filters/v2/filtersget_test.go new file mode 100644 index 000000000..b77df42a6 --- /dev/null +++ b/internal/api/client/filters/v2/filtersget_test.go @@ -0,0 +1,154 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilters( +	expectedHTTPStatus int, +	expectedBody string, +) ([]*apimodel.FilterV2, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath, nil) +	ctx.Request.Header.Set("accept", "application/json") + +	// trigger the handler +	suite.filtersModule.FiltersGETHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := make([]*apimodel.FilterV2, 0) +	if err := json.Unmarshal(b, &resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilters() { +	// Set of filter IDs for the test user. +	expectedFilterIDs := []string{} +	// Map of filter IDs to filter keyword and status IDs. +	expectedFilters := map[string]struct { +		keywordIDs []string +		statusIDs  []string +	}{} + +	// Collect the sets of IDs we expect to see. +	accountID := suite.testAccounts["local_account_1"].ID +	for _, filter := range suite.testFilters { +		if filter.AccountID == accountID { +			expectedFilterIDs = append(expectedFilterIDs, filter.ID) +			expectedFilters[filter.ID] = struct { +				keywordIDs []string +				statusIDs  []string +			}{} +		} +	} +	for _, filterKeyword := range suite.testFilterKeywords { +		if filterKeyword.AccountID == accountID { +			expectedIDsForFilter := expectedFilters[filterKeyword.FilterID] +			expectedIDsForFilter.keywordIDs = append(expectedIDsForFilter.keywordIDs, filterKeyword.ID) +			expectedFilters[filterKeyword.FilterID] = expectedIDsForFilter +		} +	} +	for _, filterStatus := range suite.testFilterStatuses { +		if filterStatus.AccountID == accountID { +			expectedIDsForFilter := expectedFilters[filterStatus.FilterID] +			expectedIDsForFilter.statusIDs = append(expectedIDsForFilter.statusIDs, filterStatus.ID) +			expectedFilters[filterStatus.FilterID] = expectedIDsForFilter +		} +	} +	suite.NotEmpty(expectedFilterIDs) +	suite.NotEmpty(expectedFilters) + +	// Fetch all filters for the logged-in account. +	filters, err := suite.getFilters(http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.NotEmpty(filters) + +	// Check that we got the right ones. +	suite.Len(filters, len(expectedFilters)) + +	actualFilterIDs := []string{} +	for _, filter := range filters { +		actualFilterIDs = append(actualFilterIDs, filter.ID) + +		expectedIDsForFilter := expectedFilters[filter.ID] + +		actualFilterKeywordIDs := []string{} +		for _, filterKeyword := range filter.Keywords { +			actualFilterKeywordIDs = append(actualFilterKeywordIDs, filterKeyword.ID) +		} +		suite.ElementsMatch(actualFilterKeywordIDs, expectedIDsForFilter.keywordIDs) + +		actualFilterStatusIDs := []string{} +		for _, filterStatus := range filter.Statuses { +			actualFilterStatusIDs = append(actualFilterStatusIDs, filterStatus.ID) +		} +		suite.ElementsMatch(actualFilterStatusIDs, expectedIDsForFilter.statusIDs) +	} +	suite.ElementsMatch(expectedFilterIDs, actualFilterIDs) +} diff --git a/internal/api/client/filters/v2/filterstatusdelete.go b/internal/api/client/filters/v2/filterstatusdelete.go new file mode 100644 index 000000000..e10125a32 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusdelete.go @@ -0,0 +1,54 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) FilterStatusDELETEHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	errWithCode = m.processor.FiltersV2().StatusDelete(c.Request.Context(), authed.Account, id) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/filters/v2/filterstatusdelete_test.go b/internal/api/client/filters/v2/filterstatusdelete_test.go new file mode 100644 index 000000000..c6627b728 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusdelete_test.go @@ -0,0 +1,112 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) deleteFilterStatus( +	filterStatusID string, +	expectedHTTPStatus int, +	expectedBody string, +) error { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.StatusPath+"/"+filterStatusID, nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterStatusID) + +	// trigger the handler +	suite.filtersModule.FilterDELETEHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return errs.Combine() +	} + +	resp := &struct{}{} +	if err := json.Unmarshal(b, resp); err != nil { +		return err +	} + +	return nil +} + +func (suite *FiltersTestSuite) TestDeleteFilterStatus() { +	id := suite.testFilterStatuses["local_account_1_filter_3_status_1"].ID + +	err := suite.deleteFilterStatus(id, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterStatus() { +	id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID + +	err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestDeleteNonexistentFilterStatus() { +	id := "not_even_a_real_ULID" + +	err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterstatusesget.go b/internal/api/client/filters/v2/filterstatusesget.go new file mode 100644 index 000000000..3b05ca73d --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusesget.go @@ -0,0 +1,95 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterStatusesGETHandler swagger:operation GET /api/v2/filters/{id}/statuses filterStatusesGet +// +// Get all filter statuses for a given filter. +// +//	--- +//	tags: +//	- filters +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		type: string +//		description: ID of the filter +//		in: path +//		required: true +// +//	security: +//	- OAuth2 Bearer: +//		- read:filters +// +//	responses: +//		'200': +//			name: filterStatuses +//			description: Requested filter statuses. +//			schema: +//				type: array +//				items: +//					"$ref": "#/definitions/filterStatus" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) FilterStatusesGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().StatusesGetForFilterID(c.Request.Context(), authed.Account, filterID) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterstatusesget_test.go b/internal/api/client/filters/v2/filterstatusesget_test.go new file mode 100644 index 000000000..6b8262f26 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusesget_test.go @@ -0,0 +1,117 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterStatuses( +	filterID string, +	expectedHTTPStatus int, +	expectedBody string, +) ([]*apimodel.FilterStatus, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request + +	ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/statuses", nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterID) + +	// trigger the handler +	suite.filtersModule.FilterStatusesGETHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := make([]*apimodel.FilterStatus, 0) +	if err := json.Unmarshal(b, &resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterStatuses() { +	// Collect the sets of filter status IDs we expect to see. +	filterID := suite.testFilters["local_account_1_filter_3"].ID +	expectedFilterStatusIDs := []string{} +	for _, filterStatus := range suite.testFilterStatuses { +		if filterStatus.FilterID == filterID { +			expectedFilterStatusIDs = append(expectedFilterStatusIDs, filterStatus.ID) +		} +	} +	suite.NotEmpty(expectedFilterStatusIDs) + +	// Fetch all filter statuses for the test filter. +	filterStatuses, err := suite.getFilterStatuses(filterID, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.NotEmpty(filterStatuses) + +	// Check that we got the right ones. +	suite.Len(filterStatuses, len(expectedFilterStatusIDs)) +	actualFilterStatusIDs := []string{} +	for _, filterStatus := range filterStatuses { +		actualFilterStatusIDs = append(actualFilterStatusIDs, filterStatus.ID) +	} +	suite.ElementsMatch(expectedFilterStatusIDs, actualFilterStatusIDs) +} diff --git a/internal/api/client/filters/v2/filterstatusget.go b/internal/api/client/filters/v2/filterstatusget.go new file mode 100644 index 000000000..9e62e4466 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusget.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterStatusGETHandler swagger:operation GET /api/v2/filters/statuses/{id} filterStatusGet +// +// Get a single filter status with the given ID. +// +//	--- +//	tags: +//	- filters +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		type: string +//		description: ID of the filter status +//		in: path +//		required: true +// +//	security: +//	- OAuth2 Bearer: +//		- read:filters +// +//	responses: +//		'200': +//			name: filterStatus +//			description: Requested filter status. +//			schema: +//				"$ref": "#/definitions/filterStatus" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) FilterStatusGETHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().StatusGet(c.Request.Context(), authed.Account, id) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterstatusget_test.go b/internal/api/client/filters/v2/filterstatusget_test.go new file mode 100644 index 000000000..5df3971a8 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusget_test.go @@ -0,0 +1,120 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterStatus( +	filterStatusID string, +	expectedHTTPStatus int, +	expectedBody string, +) (*apimodel.FilterStatus, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.StatusPath+"/"+filterStatusID, nil) +	ctx.Request.Header.Set("accept", "application/json") + +	ctx.AddParam("id", filterStatusID) + +	// trigger the handler +	suite.filtersModule.FilterStatusGETHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterStatus{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterStatus() { +	expectedFilterStatus := suite.testFilterStatuses["local_account_1_filter_3_status_1"] + +	filterStatus, err := suite.getFilterStatus(expectedFilterStatus.ID, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.NotEmpty(filterStatus) +	suite.Equal(expectedFilterStatus.ID, filterStatus.ID) +	suite.Equal(expectedFilterStatus.StatusID, filterStatus.StatusID) +} + +func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterStatus() { +	id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID + +	_, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestGetNonexistentFilterStatus() { +	id := "not_even_a_real_ULID" + +	_, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/client/filters/v2/filterstatuspost.go b/internal/api/client/filters/v2/filterstatuspost.go new file mode 100644 index 000000000..2a763197d --- /dev/null +++ b/internal/api/client/filters/v2/filterstatuspost.go @@ -0,0 +1,133 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// FilterStatusPOSTHandler swagger:operation POST /api/v2/filters/{id}/statuses filterStatusPost +// +// Add a filter status to an existing filter. +// +//	--- +//	tags: +//	- filters +// +//	consumes: +//	- application/json +//	- application/xml +//	- application/x-www-form-urlencoded +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		in: path +//		type: string +//		required: true +//		description: ID of the filter to add the filtered status to. +//	- +//		name: status_id +//		in: formData +//		required: true +//		description: |- +//			The ID of the status to filter. +// +//			Sample: 01HXA2NE0K8T1C70K90E74GYD0 +//		type: string +// +//	security: +//	- OAuth2 Bearer: +//		- write:filters +// +//	responses: +//		'200': +//			name: filterStatus +//			description: New filter status. +//			schema: +//				"$ref": "#/definitions/filterStatus" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'409': +//			description: conflict (duplicate status) +//		'422': +//			description: unprocessable content +//		'500': +//			description: internal server error +func (m *Module) FilterStatusPOSTHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if authed.Account.IsMoving() { +		apiutil.ForbiddenAfterMove(c) +		return +	} + +	if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	form := &apimodel.FilterStatusCreateRequest{} +	if err := c.ShouldBind(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	if err := validateCreateFilterStatus(form); err != nil { +		apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) +		return +	} + +	apiFilter, errWithCode := m.processor.FiltersV2().StatusCreate(c.Request.Context(), authed.Account, filterID, form) +	if errWithCode != nil { +		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +		return +	} + +	apiutil.JSON(c, http.StatusOK, apiFilter) +} + +func validateCreateFilterStatus(form *apimodel.FilterStatusCreateRequest) error { +	return validate.ULID(form.StatusID, "status_id") +} diff --git a/internal/api/client/filters/v2/filterstatuspost_test.go b/internal/api/client/filters/v2/filterstatuspost_test.go new file mode 100644 index 000000000..924b8ecc2 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatuspost_test.go @@ -0,0 +1,180 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package v2_test + +import ( +	"encoding/json" +	"io" +	"net/http" +	"net/http/httptest" +	"net/url" +	"strings" + +	filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) postFilterStatus( +	filterID string, +	statusID *string, +	requestJson *string, +	expectedHTTPStatus int, +	expectedBody string, +) (*apimodel.FilterStatus, error) { +	// instantiate recorder + test context +	recorder := httptest.NewRecorder() +	ctx, _ := testrig.CreateGinTestContext(recorder, nil) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) +	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + +	// create the request +	ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/statuses", nil) +	ctx.Request.Header.Set("accept", "application/json") +	if requestJson != nil { +		ctx.Request.Header.Set("content-type", "application/json") +		ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) +	} else { +		ctx.Request.Form = make(url.Values) +		if statusID != nil { +			ctx.Request.Form["status_id"] = []string{*statusID} +		} +	} + +	ctx.AddParam("id", filterID) + +	// trigger the handler +	suite.filtersModule.FilterStatusPOSTHandler(ctx) + +	// read the response +	result := recorder.Result() +	defer result.Body.Close() + +	b, err := io.ReadAll(result.Body) +	if err != nil { +		return nil, err +	} + +	errs := gtserror.NewMultiError(2) + +	// check code + body +	if resultCode := recorder.Code; expectedHTTPStatus != resultCode { +		errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) +		if expectedBody == "" { +			return nil, errs.Combine() +		} +	} + +	// if we got an expected body, return early +	if expectedBody != "" { +		if string(b) != expectedBody { +			errs.Appendf("expected %s got %s", expectedBody, string(b)) +		} +		return nil, errs.Combine() +	} + +	resp := &apimodel.FilterStatus{} +	if err := json.Unmarshal(b, resp); err != nil { +		return nil, err +	} + +	return resp, nil +} + +func (suite *FiltersTestSuite) TestPostFilterStatus() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	statusID := suite.testStatuses["admin_account_status_1"].ID +	filterStatus, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(statusID, filterStatus.StatusID) +} + +func (suite *FiltersTestSuite) TestPostFilterStatusJSON() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	requestJson := `{ +		"status_id": "01F8MH75CBF9JFX4ZAD54N0W0R" +	}` +	filterStatus, err := suite.postFilterStatus(filterID, nil, &requestJson, http.StatusOK, "") +	if err != nil { +		suite.FailNow(err.Error()) +	} + +	suite.Equal(suite.testStatuses["admin_account_status_1"].ID, filterStatus.StatusID) +} + +func (suite *FiltersTestSuite) TestPostFilterStatusEmptyStatusID() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	statusID := "" +	_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id must be provided"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterStatusInvalidStatusID() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	statusID := "112401162517176488" // ma'am, that's clearly a Mastodon ID, this is a Wendy's +	_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id didn't match the expected ULID format for an ID (26 characters from the set 0123456789ABCDEFGHJKMNPQRSTVWXYZ)"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterStatusMissingStatusID() { +	filterID := suite.testFilters["local_account_1_filter_1"].ID +	_, err := suite.postFilterStatus(filterID, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id must be provided"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +// Creating another filter status in the same filter with the same status ID should fail. +func (suite *FiltersTestSuite) TestPostFilterStatusStatusIDConflict() { +	filterID := suite.testFilters["local_account_1_filter_3"].ID +	statusID := suite.testFilterStatuses["local_account_1_filter_3_status_1"].StatusID +	_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusConflict, `{"error":"Conflict: duplicate status"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterStatusAnotherAccountsFilter() { +	filterID := suite.testFilters["local_account_2_filter_1"].ID +	statusID := suite.testStatuses["admin_account_status_1"].ID +	_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} + +func (suite *FiltersTestSuite) TestPostFilterStatusNonexistentFilter() { +	filterID := "not_even_a_real_ULID" +	statusID := suite.testStatuses["admin_account_status_1"].ID +	_, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found"}`) +	if err != nil { +		suite.FailNow(err.Error()) +	} +} diff --git a/internal/api/model/filterv2.go b/internal/api/model/filterv2.go index 797c97213..51dabacb2 100644 --- a/internal/api/model/filterv2.go +++ b/internal/api/model/filterv2.go @@ -85,7 +85,7 @@ type FilterKeyword struct {  	//  	// Example: fnord  	Keyword string `json:"keyword"` -	// Should the filter consider word boundaries? +	// Should the filter keyword consider word boundaries?  	//  	// Example: true  	WholeWord bool `json:"whole_word"` @@ -104,3 +104,88 @@ type FilterStatus struct {  	// The status ID to be filtered.  	StatusID string `json:"phrase"`  } + +// FilterCreateRequestV2 captures params for creating a v2 filter. +// +// swagger:ignore +type FilterCreateRequestV2 struct { +	// The name of the filter. +	// +	// Required: true +	// Example: fnord +	Title string `form:"title" json:"title" xml:"title"` +	// The contexts in which the filter should be applied. +	// +	// Required: true +	// Minimum length: 1 +	// Unique: true +	// Enum: home,notifications,public,thread,account +	// Example: ["home", "public"] +	Context []FilterContext `form:"context[]" json:"context" xml:"context"` +	// The action to be taken when a status matches this filter. If omitted, defaults to warn. +	// Enum: +	//	- warn +	//	- hide +	// Example: warn +	FilterAction *FilterAction `form:"filter_action" json:"filter_action" xml:"filter_action"` + +	// Number of seconds from now that the filter should expire. If omitted, filter never expires. +	ExpiresIn *int `json:"-" form:"expires_in" xml:"expires_in"` +	// Number of seconds from now that the filter should expire. If omitted, filter never expires. +	// +	// Example: 86400 +	ExpiresInI interface{} `json:"expires_in"` +} + +// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword. +// +// swagger:ignore +type FilterKeywordCreateUpdateRequest struct { +	// The text to be filtered. +	// +	// Example: fnord +	// Maximum length: 40 +	Keyword string `form:"keyword" json:"keyword" xml:"keyword"` +	// Should the filter keyword consider word boundaries? +	// +	// Example: true +	WholeWord *bool `form:"whole_word" json:"whole_word" xml:"whole_word"` +} + +// FilterStatusCreateRequest captures params for creating a filter status. +// +// swagger:ignore +type FilterStatusCreateRequest struct { +	// The status ID to be filtered. +	StatusID string `form:"status_id" json:"status_id" xml:"status_id"` +} + +// FilterUpdateRequestV2 captures params for creating a v2 filter. +// +// swagger:ignore +type FilterUpdateRequestV2 struct { +	// The name of the filter. +	// +	// Example: illuminati nonsense +	Title *string `form:"title" json:"title" xml:"title"` +	// The contexts in which the filter should be applied. +	// +	// Minimum length: 1 +	// Unique: true +	// Enum: home,notifications,public,thread,account +	// Example: ["home", "public"] +	Context *[]FilterContext `form:"context[]" json:"context" xml:"context"` +	// The action to be taken when a status matches this filter. +	// Enum: +	//	- warn +	//	- hide +	// Example: warn +	FilterAction *FilterAction `form:"filter_action" json:"filter_action" xml:"filter_action"` + +	// Number of seconds from now that the filter should expire. If omitted, filter never expires. +	ExpiresIn *int `json:"-" form:"expires_in" xml:"expires_in"` +	// Number of seconds from now that the filter should expire. If omitted, filter never expires. +	// +	// Example: 86400 +	ExpiresInI interface{} `json:"expires_in"` +} 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 @@ package bundb  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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"fmt" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// apiFilter is a shortcut to return the API v2 filter version of the given +// filter, or return an appropriate error if conversion fails. +func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.Filter) (*apimodel.FilterV2, gtserror.WithCode) { +	apiFilter, err := p.converter.FilterToAPIFilterV2(ctx, filterKeyword) +	if err != nil { +		return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter to API v2 filter: %w", err)) +	} + +	return apiFilter, nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" +	"time" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/id" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Create a new filter for the given account, using the provided parameters. +// These params should have already been validated by the time they reach this function. +func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateRequestV2) (*apimodel.FilterV2, gtserror.WithCode) { +	filter := >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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"fmt" + +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Delete an existing filter and all its attached keywords and statuses for the given account. +func (p *Processor) Delete( +	ctx context.Context, +	account *gtsmodel.Account, +	filterID string, +) gtserror.WithCode { +	// Get the filter for this keyword. +	filter, err := p.state.DB.GetFilterByID(ctx, filterID) +	if err != nil { +		return gtserror.NewErrorNotFound(err) +	} + +	// Check that the account owns it. +	if filter.AccountID != account.ID { +		return gtserror.NewErrorNotFound( +			fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), +		) +	} + +	// Delete the entire filter. +	if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil { +		return gtserror.NewErrorInternalError(err) +	} + +	return nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"github.com/superseriousbusiness/gotosocial/internal/state" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type Processor struct { +	state     *state.State +	converter *typeutils.Converter +} + +func New(state *state.State, converter *typeutils.Converter) Processor { +	return Processor{ +		state:     state, +		converter: converter, +	} +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" +	"slices" +	"strings" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Get looks up a filter by ID and returns it with keywords and statuses. +func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterID string) (*apimodel.FilterV2, gtserror.WithCode) { +	filter, err := p.state.DB.GetFilterByID(ctx, filterID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filter.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound( +			fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), +		) +	} + +	return p.apiFilter(ctx, filter) +} + +// GetAll looks up all filters for the current account and returns them with keywords and statuses. +func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.FilterV2, gtserror.WithCode) { +	filters, err := p.state.DB.GetFiltersForAccountID( +		ctx, +		account.ID, +	) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, nil +		} +		return nil, gtserror.NewErrorInternalError(err) +	} + +	apiFilters := make([]*apimodel.FilterV2, 0, len(filters)) +	for _, filter := range filters { +		apiFilter, errWithCode := p.apiFilter(ctx, filter) +		if errWithCode != nil { +			return nil, errWithCode +		} + +		apiFilters = append(apiFilters, apiFilter) +	} + +	// Sort them by ID so that they're in a stable order. +	// Clients may opt to sort them lexically in a locale-aware manner. +	slices.SortFunc(apiFilters, func(lhs *apimodel.FilterV2, rhs *apimodel.FilterV2) int { +		return strings.Compare(lhs.ID, rhs.ID) +	}) + +	return apiFilters, nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/id" +) + +// KeywordCreate adds a filter keyword to an existing filter for the given account, using the provided parameters. +// These params should have already been normalized and validated by the time they reach this function. +func (p *Processor) KeywordCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterKeywordCreateUpdateRequest) (*apimodel.FilterKeyword, gtserror.WithCode) { +	// Check that the filter is owned by the given account. +	filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filter.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound( +			fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), +		) +	} + +	filterKeyword := >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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"fmt" + +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// KeywordDelete deletes an existing filter keyword from a filter. +func (p *Processor) KeywordDelete( +	ctx context.Context, +	account *gtsmodel.Account, +	filterID string, +) gtserror.WithCode { +	// Get the filter keyword. +	filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterID) +	if err != nil { +		return gtserror.NewErrorNotFound(err) +	} + +	// Check that the account owns it. +	if filterKeyword.AccountID != account.ID { +		return gtserror.NewErrorNotFound( +			fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), +		) +	} + +	// Delete the filter keyword. +	if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil { +		return gtserror.NewErrorInternalError(err) +	} + +	return nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" +	"slices" +	"strings" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// KeywordGet looks up a filter keyword by ID. +func (p *Processor) KeywordGet(ctx context.Context, account *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterKeyword, gtserror.WithCode) { +	filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterKeywordID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filterKeyword.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound( +			fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), +		) +	} + +	return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil +} + +// KeywordsGetForFilterID looks up all filter keywords for the given filter. +func (p *Processor) KeywordsGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterKeyword, gtserror.WithCode) { +	// Check that the filter is owned by the given account. +	filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filter.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound(nil) +	} + +	filterKeywords, err := p.state.DB.GetFilterKeywordsForFilterID( +		ctx, +		filter.ID, +	) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, nil +		} +		return nil, gtserror.NewErrorInternalError(err) +	} + +	apiFilterKeywords := make([]*apimodel.FilterKeyword, 0, len(filterKeywords)) +	for _, filterKeyword := range filterKeywords { +		apiFilterKeywords = append(apiFilterKeywords, p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword)) +	} + +	// Sort them by ID so that they're in a stable order. +	// Clients may opt to sort them lexically in a locale-aware manner. +	slices.SortFunc(apiFilterKeywords, func(lhs *apimodel.FilterKeyword, rhs *apimodel.FilterKeyword) int { +		return strings.Compare(lhs.ID, rhs.ID) +	}) + +	return apiFilterKeywords, nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// KeywordUpdate updates an existing filter keyword for the given account, using the provided parameters. +// These params should have already been validated by the time they reach this function. +func (p *Processor) KeywordUpdate( +	ctx context.Context, +	account *gtsmodel.Account, +	filterKeywordID string, +	form *apimodel.FilterKeywordCreateUpdateRequest, +) (*apimodel.FilterKeyword, gtserror.WithCode) { +	// Get the filter keyword by ID. +	filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filterKeyword.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound( +			fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), +		) +	} + +	filterKeyword.Keyword = form.Keyword +	filterKeyword.WholeWord = form.WholeWord + +	if err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, "keyword", "whole_word"); err != nil { +		if errors.Is(err, db.ErrAlreadyExists) { +			err = errors.New("duplicate keyword") +			return nil, gtserror.NewErrorConflict(err, err.Error()) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} + +	return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/id" +) + +// StatusCreate adds a filter status to an existing filter for the given account, using the provided parameters. +// These params should have already been validated by the time they reach this function. +func (p *Processor) StatusCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterStatusCreateRequest) (*apimodel.FilterStatus, gtserror.WithCode) { +	// Check that the filter is owned by the given account. +	filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filter.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound( +			fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), +		) +	} + +	filterStatus := >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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"fmt" + +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// StatusDelete deletes an existing filter status from a filter. +func (p *Processor) StatusDelete( +	ctx context.Context, +	account *gtsmodel.Account, +	filterID string, +) gtserror.WithCode { +	// Get the filter status. +	filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterID) +	if err != nil { +		return gtserror.NewErrorNotFound(err) +	} + +	// Check that the account owns it. +	if filterStatus.AccountID != account.ID { +		return gtserror.NewErrorNotFound( +			fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID), +		) +	} + +	// Delete the filter status. +	if err := p.state.DB.DeleteFilterStatusByID(ctx, filterStatus.ID); err != nil { +		return gtserror.NewErrorInternalError(err) +	} + +	return nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" +	"slices" +	"strings" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// StatusGet looks up a filter status by ID. +func (p *Processor) StatusGet(ctx context.Context, account *gtsmodel.Account, filterStatusID string) (*apimodel.FilterStatus, gtserror.WithCode) { +	filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterStatusID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filterStatus.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound( +			fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID), +		) +	} + +	return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil +} + +// StatusesGetForFilterID looks up all filter statuses for the given filter. +func (p *Processor) StatusesGetForFilterID(ctx context.Context, account *gtsmodel.Account, filterID string) ([]*apimodel.FilterStatus, gtserror.WithCode) { +	// Check that the filter is owned by the given account. +	filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filter.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound(nil) +	} + +	filterStatuses, err := p.state.DB.GetFilterStatusesForFilterID( +		ctx, +		filter.ID, +	) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, nil +		} +		return nil, gtserror.NewErrorInternalError(err) +	} + +	apiFilterStatuses := make([]*apimodel.FilterStatus, 0, len(filterStatuses)) +	for _, filterStatus := range filterStatuses { +		apiFilterStatuses = append(apiFilterStatuses, p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus)) +	} + +	// Sort them by ID so that they're in a stable order. +	// Clients may opt to sort them by status ID instead. +	slices.SortFunc(apiFilterStatuses, func(lhs *apimodel.FilterStatus, rhs *apimodel.FilterStatus) int { +		return strings.Compare(lhs.ID, rhs.ID) +	}) + +	return apiFilterStatuses, nil +} 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 <http://www.gnu.org/licenses/>. + +package v2 + +import ( +	"context" +	"errors" +	"fmt" +	"time" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Update an existing filter for the given account, using the provided parameters. +// These params should have already been validated by the time they reach this function. +func (p *Processor) Update( +	ctx context.Context, +	account *gtsmodel.Account, +	filterID string, +	form *apimodel.FilterUpdateRequestV2, +) (*apimodel.FilterV2, gtserror.WithCode) { +	// Get the filter by ID, with existing keywords and statuses. +	filter, err := p.state.DB.GetFilterByID(ctx, filterID) +	if err != nil { +		if errors.Is(err, db.ErrNoEntries) { +			return nil, gtserror.NewErrorNotFound(err) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} +	if filter.AccountID != account.ID { +		return nil, gtserror.NewErrorNotFound( +			fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), +		) +	} + +	// Filter columns that we're going to update. +	filterColumns := []string{} + +	// Apply filter changes. +	if form.Title != nil { +		filterColumns = append(filterColumns, "title") +		filter.Title = *form.Title +	} +	if form.FilterAction != nil { +		filterColumns = append(filterColumns, "action") +		filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction) +	} +	// TODO: (Vyr) is it possible to unset a filter expiration with this API? +	if form.ExpiresIn != nil { +		filterColumns = append(filterColumns, "expires_at") +		filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn)) +	} +	if form.Context != nil { +		filterColumns = append(filterColumns, +			"context_home", +			"context_notifications", +			"context_public", +			"context_thread", +			"context_account", +		) +		filter.ContextHome = util.Ptr(false) +		filter.ContextNotifications = util.Ptr(false) +		filter.ContextPublic = util.Ptr(false) +		filter.ContextThread = util.Ptr(false) +		filter.ContextAccount = util.Ptr(false) +		for _, context := range *form.Context { +			switch context { +			case apimodel.FilterContextHome: +				filter.ContextHome = util.Ptr(true) +			case apimodel.FilterContextNotifications: +				filter.ContextNotifications = util.Ptr(true) +			case apimodel.FilterContextPublic: +				filter.ContextPublic = util.Ptr(true) +			case apimodel.FilterContextThread: +				filter.ContextThread = util.Ptr(true) +			case apimodel.FilterContextAccount: +				filter.ContextAccount = util.Ptr(true) +			default: +				return nil, gtserror.NewErrorUnprocessableEntity( +					fmt.Errorf("unsupported filter context '%s'", context), +				) +			} +		} +	} + +	// Temporarily detach keywords and statuses from filter, since we're not updating them below. +	filterKeywords := filter.Keywords +	filterStatuses := filter.Statuses +	filter.Keywords = nil +	filter.Statuses = nil + +	if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, nil, nil, nil); err != nil { +		if errors.Is(err, db.ErrAlreadyExists) { +			err = errors.New("you already have a filter with this title") +			return nil, gtserror.NewErrorConflict(err, err.Error()) +		} +		return nil, gtserror.NewErrorInternalError(err) +	} + +	// Re-attach keywords and statuses before returning. +	filter.Keywords = filterKeywords +	filter.Statuses = filterStatuses + +	return p.apiFilter(ctx, filter) +} 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 @@ import (  	"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 @@ const (  	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.  | 
