From 754b9c186b35c05d4024c0c7ba246bf58b585ef9 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 2 May 2024 12:40:14 -0700 Subject: [PATCH 01/16] Use correct entity name --- internal/api/client/filters/v1/filterdelete.go | 2 +- internal/processing/filters/v1/get.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/client/filters/v1/filterdelete.go b/internal/api/client/filters/v1/filterdelete.go index d86b277a61..267dd16d00 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/processing/filters/v1/get.go b/internal/processing/filters/v1/get.go index 39575dd942..3ead09b202 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 } From e8b8f7f2caf5e6992b75f813a2036bcdc8924aa0 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 2 May 2024 15:46:58 -0700 Subject: [PATCH 02/16] We support server-side filters now --- internal/api/client/filters/v1/filterpost_test.go | 11 ----------- internal/api/client/filters/v1/filterput_test.go | 10 ---------- 2 files changed, 21 deletions(-) diff --git a/internal/api/client/filters/v1/filterpost_test.go b/internal/api/client/filters/v1/filterpost_test.go index 729b2bd729..c3682bcc53 100644 --- a/internal/api/client/filters/v1/filterpost_test.go +++ b/internal/api/client/filters/v1/filterpost_test.go @@ -226,14 +226,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_test.go b/internal/api/client/filters/v1/filterput_test.go index 0308e53d92..242efa367a 100644 --- a/internal/api/client/filters/v1/filterput_test.go +++ b/internal/api/client/filters/v1/filterput_test.go @@ -238,16 +238,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" From 7dc85782c0d7fed143e9e4ddf7a89191a436bc5c Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 7 May 2024 12:04:00 -0700 Subject: [PATCH 03/16] Document filter v1 methods that can throw a 409 --- internal/api/client/filters/v1/filterpost.go | 2 ++ internal/api/client/filters/v1/filterput.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/api/client/filters/v1/filterpost.go b/internal/api/client/filters/v1/filterpost.go index 2d19f69cf2..36814cb805 100644 --- a/internal/api/client/filters/v1/filterpost.go +++ b/internal/api/client/filters/v1/filterpost.go @@ -120,6 +120,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.go b/internal/api/client/filters/v1/filterput.go index bb9fa809fe..924eebaba8 100644 --- a/internal/api/client/filters/v1/filterput.go +++ b/internal/api/client/filters/v1/filterput.go @@ -126,6 +126,8 @@ import ( // description: not found // '406': // description: not acceptable +// '409': +// description: conflict (duplicate keyword) // '422': // description: unprocessable content // '500': From 2a3248214c029ac106397dd06d0c1e664b96065b Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 7 May 2024 12:19:24 -0700 Subject: [PATCH 04/16] Validate v1 filter phrase as filter title --- internal/api/client/filters/v1/validate.go | 4 ++++ internal/validate/formvalidation.go | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/api/client/filters/v1/validate.go b/internal/api/client/filters/v1/validate.go index b539c9563e..550df54fa8 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/validate/formvalidation.go b/internal/validate/formvalidation.go index 3c16dd86e7..3839173d18 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 @@ -308,7 +309,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 +322,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 { From df51cfcb0bfa769c749922dc88328a8d79a54d2c Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 7 May 2024 12:30:23 -0700 Subject: [PATCH 05/16] Always check v1 filter API status codes in tests --- internal/api/client/filters/v1/filterdelete_test.go | 3 +++ internal/api/client/filters/v1/filterget_test.go | 3 +++ internal/api/client/filters/v1/filterpost_test.go | 3 +++ internal/api/client/filters/v1/filterput_test.go | 3 +++ internal/api/client/filters/v1/filtersget_test.go | 3 +++ 5 files changed, 15 insertions(+) diff --git a/internal/api/client/filters/v1/filterdelete_test.go b/internal/api/client/filters/v1/filterdelete_test.go index 83155f08a8..20fd4351b9 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 a9dbf6dbb7..e8fdedfaa9 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_test.go b/internal/api/client/filters/v1/filterpost_test.go index c3682bcc53..893415d996 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 diff --git a/internal/api/client/filters/v1/filterput_test.go b/internal/api/client/filters/v1/filterput_test.go index 242efa367a..d810930d68 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 diff --git a/internal/api/client/filters/v1/filtersget_test.go b/internal/api/client/filters/v1/filtersget_test.go index a568239ef8..281ee4f634 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 From 204303b731269a7dbc67ff1c0848e8ce3a4c8ffa Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 7 May 2024 13:17:42 -0700 Subject: [PATCH 06/16] Document keyword minimum requirement on filter API v1 --- internal/api/client/filters/v1/filterpost.go | 1 + internal/api/client/filters/v1/filterput.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/api/client/filters/v1/filterpost.go b/internal/api/client/filters/v1/filterpost.go index 36814cb805..9d92b91877 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 // - diff --git a/internal/api/client/filters/v1/filterput.go b/internal/api/client/filters/v1/filterput.go index 924eebaba8..2a81f89fc3 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 // - From c2067c32474041458e3bd6f22d27347c39a21c00 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 7 May 2024 14:26:27 -0700 Subject: [PATCH 07/16] Make it possible to specify filter keyword update columns per filter keyword --- internal/db/bundb/filter.go | 19 +++++++++++++------ internal/db/filter.go | 4 +++- internal/processing/filters/v1/update.go | 8 +++++--- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/internal/db/bundb/filter.go b/internal/db/bundb/filter.go index d09a5067d7..30a8494a72 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/filter.go b/internal/db/filter.go index 18943b4f90..eee61a99d4 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/processing/filters/v1/update.go b/internal/processing/filters/v1/update.go index 1fe49721bf..0421dc7860 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) { From 2f8ee17c625d94ca24234b01655348db3f3876c5 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 7 May 2024 22:14:14 -0700 Subject: [PATCH 08/16] Implement v2 filter API --- internal/api/client.go | 4 + internal/api/client/filters/v2/filter.go | 80 +++++ internal/api/client/filters/v2/filter_test.go | 118 ++++++ .../api/client/filters/v2/filterdelete.go | 90 +++++ .../client/filters/v2/filterdelete_test.go | 115 ++++++ internal/api/client/filters/v2/filterget.go | 93 +++++ .../api/client/filters/v2/filterget_test.go | 122 +++++++ .../client/filters/v2/filterkeyworddelete.go | 54 +++ .../filters/v2/filterkeyworddelete_test.go | 115 ++++++ .../api/client/filters/v2/filterkeywordget.go | 93 +++++ .../filters/v2/filterkeywordget_test.go | 122 +++++++ .../client/filters/v2/filterkeywordpost.go | 151 ++++++++ .../filters/v2/filterkeywordpost_test.go | 192 ++++++++++ .../api/client/filters/v2/filterkeywordput.go | 138 +++++++ .../filters/v2/filterkeywordput_test.go | 192 ++++++++++ .../client/filters/v2/filterkeywordsget.go | 95 +++++ .../filters/v2/filterkeywordsget_test.go | 117 ++++++ internal/api/client/filters/v2/filterpost.go | 263 ++++++++++++++ .../api/client/filters/v2/filterpost_test.go | 293 +++++++++++++++ internal/api/client/filters/v2/filterput.go | 319 +++++++++++++++++ .../api/client/filters/v2/filterput_test.go | 336 ++++++++++++++++++ internal/api/client/filters/v2/filtersget.go | 81 +++++ .../api/client/filters/v2/filtersget_test.go | 154 ++++++++ .../client/filters/v2/filterstatusdelete.go | 54 +++ .../filters/v2/filterstatusdelete_test.go | 112 ++++++ .../client/filters/v2/filterstatusesget.go | 95 +++++ .../filters/v2/filterstatusesget_test.go | 117 ++++++ .../api/client/filters/v2/filterstatusget.go | 93 +++++ .../client/filters/v2/filterstatusget_test.go | 120 +++++++ .../api/client/filters/v2/filterstatuspost.go | 137 +++++++ .../filters/v2/filterstatuspost_test.go | 180 ++++++++++ internal/api/model/filterv2.go | 152 +++++++- internal/gtsmodel/filter.go | 2 + internal/processing/filters/v2/convert.go | 38 ++ internal/processing/filters/v2/create.go | 98 +++++ internal/processing/filters/v2/delete.go | 50 +++ internal/processing/filters/v2/filters.go | 35 ++ internal/processing/filters/v2/get.go | 78 ++++ .../processing/filters/v2/keywordcreate.go | 64 ++++ .../processing/filters/v2/keyworddelete.go | 50 +++ internal/processing/filters/v2/keywordget.go | 86 +++++ .../processing/filters/v2/keywordupdate.go | 63 ++++ .../processing/filters/v2/statuscreate.go | 63 ++++ .../processing/filters/v2/statusdelete.go | 50 +++ internal/processing/filters/v2/statusget.go | 86 +++++ internal/processing/filters/v2/update.go | 250 +++++++++++++ internal/processing/processor.go | 7 + internal/typeutils/frontendtointernal.go | 10 + internal/typeutils/internaltofrontend.go | 28 +- internal/validate/formvalidation.go | 27 +- testrig/testmodels.go | 39 +- 51 files changed, 5506 insertions(+), 15 deletions(-) create mode 100644 internal/api/client/filters/v2/filter.go create mode 100644 internal/api/client/filters/v2/filter_test.go create mode 100644 internal/api/client/filters/v2/filterdelete.go create mode 100644 internal/api/client/filters/v2/filterdelete_test.go create mode 100644 internal/api/client/filters/v2/filterget.go create mode 100644 internal/api/client/filters/v2/filterget_test.go create mode 100644 internal/api/client/filters/v2/filterkeyworddelete.go create mode 100644 internal/api/client/filters/v2/filterkeyworddelete_test.go create mode 100644 internal/api/client/filters/v2/filterkeywordget.go create mode 100644 internal/api/client/filters/v2/filterkeywordget_test.go create mode 100644 internal/api/client/filters/v2/filterkeywordpost.go create mode 100644 internal/api/client/filters/v2/filterkeywordpost_test.go create mode 100644 internal/api/client/filters/v2/filterkeywordput.go create mode 100644 internal/api/client/filters/v2/filterkeywordput_test.go create mode 100644 internal/api/client/filters/v2/filterkeywordsget.go create mode 100644 internal/api/client/filters/v2/filterkeywordsget_test.go create mode 100644 internal/api/client/filters/v2/filterpost.go create mode 100644 internal/api/client/filters/v2/filterpost_test.go create mode 100644 internal/api/client/filters/v2/filterput.go create mode 100644 internal/api/client/filters/v2/filterput_test.go create mode 100644 internal/api/client/filters/v2/filtersget.go create mode 100644 internal/api/client/filters/v2/filtersget_test.go create mode 100644 internal/api/client/filters/v2/filterstatusdelete.go create mode 100644 internal/api/client/filters/v2/filterstatusdelete_test.go create mode 100644 internal/api/client/filters/v2/filterstatusesget.go create mode 100644 internal/api/client/filters/v2/filterstatusesget_test.go create mode 100644 internal/api/client/filters/v2/filterstatusget.go create mode 100644 internal/api/client/filters/v2/filterstatusget_test.go create mode 100644 internal/api/client/filters/v2/filterstatuspost.go create mode 100644 internal/api/client/filters/v2/filterstatuspost_test.go create mode 100644 internal/processing/filters/v2/convert.go create mode 100644 internal/processing/filters/v2/create.go create mode 100644 internal/processing/filters/v2/delete.go create mode 100644 internal/processing/filters/v2/filters.go create mode 100644 internal/processing/filters/v2/get.go create mode 100644 internal/processing/filters/v2/keywordcreate.go create mode 100644 internal/processing/filters/v2/keyworddelete.go create mode 100644 internal/processing/filters/v2/keywordget.go create mode 100644 internal/processing/filters/v2/keywordupdate.go create mode 100644 internal/processing/filters/v2/statuscreate.go create mode 100644 internal/processing/filters/v2/statusdelete.go create mode 100644 internal/processing/filters/v2/statusget.go create mode 100644 internal/processing/filters/v2/update.go diff --git a/internal/api/client.go b/internal/api/client.go index d30f82e7b1..4ff967210e 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" @@ -66,6 +67,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 @@ -110,6 +112,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) @@ -142,6 +145,7 @@ func NewClient(db db.DB, 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/v2/filter.go b/internal/api/client/filters/v2/filter.go new file mode 100644 index 0000000000..58e7905ae8 --- /dev/null +++ b/internal/api/client/filters/v2/filter.go @@ -0,0 +1,80 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // BasePath is the base path for serving the filters API, minus the 'api' prefix + BasePath = "/v2/filters" + // BasePathWithID is the base path with the ID key in it, for operations on an existing filter. + BasePathWithID = BasePath + "/:" + apiutil.IDKey + // FilterKeywordsPathWithID is the path for operations on an existing filter's keywords. + FilterKeywordsPathWithID = BasePathWithID + "/keywords" + // FilterStatusesPathWithID is the path for operations on an existing filter's statuses. + FilterStatusesPathWithID = BasePathWithID + "/statuses" + + // KeywordPath is the base path for operations on filter keywords that don't require a filter ID. + KeywordPath = BasePath + "/keywords" + // KeywordPathWithKeywordID is the path for operations on an existing filter keyword. + KeywordPathWithKeywordID = KeywordPath + "/:" + apiutil.IDKey + + // StatusPath is the base path for operations on filter statuses that don't require a filter ID. + StatusPath = BasePath + "/statuses" + // StatusPathWithStatusID is the path for operations on an existing filter status. + StatusPathWithStatusID = StatusPath + "/:" + apiutil.IDKey +) + +// Module implements APIs for client-side aka "v1" filtering. +type Module struct { + processor *processing.Processor +} + +func New(processor *processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.FiltersGETHandler) + + attachHandler(http.MethodPost, BasePath, m.FilterPOSTHandler) + attachHandler(http.MethodGet, BasePathWithID, m.FilterGETHandler) + attachHandler(http.MethodPut, BasePathWithID, m.FilterPUTHandler) + attachHandler(http.MethodDelete, BasePathWithID, m.FilterDELETEHandler) + + attachHandler(http.MethodGet, FilterKeywordsPathWithID, m.FilterKeywordsGETHandler) + attachHandler(http.MethodPost, FilterKeywordsPathWithID, m.FilterKeywordPOSTHandler) + + attachHandler(http.MethodGet, KeywordPathWithKeywordID, m.FilterKeywordGETHandler) + attachHandler(http.MethodPut, KeywordPathWithKeywordID, m.FilterKeywordPUTHandler) + attachHandler(http.MethodDelete, KeywordPathWithKeywordID, m.FilterKeywordDELETEHandler) + + attachHandler(http.MethodGet, FilterStatusesPathWithID, m.FilterStatusesGETHandler) + attachHandler(http.MethodPost, FilterStatusesPathWithID, m.FilterStatusPOSTHandler) + + attachHandler(http.MethodGet, StatusPathWithStatusID, m.FilterStatusGETHandler) + attachHandler(http.MethodDelete, StatusPathWithStatusID, m.FilterStatusDELETEHandler) +} diff --git a/internal/api/client/filters/v2/filter_test.go b/internal/api/client/filters/v2/filter_test.go new file mode 100644 index 0000000000..f85357482e --- /dev/null +++ b/internal/api/client/filters/v2/filter_test.go @@ -0,0 +1,118 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FiltersTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + mediaManager *media.Manager + federator *federation.Federator + processor *processing.Processor + emailSender email.Sender + sentEmails map[string]string + state state.State + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testStatuses map[string]*gtsmodel.Status + testFilters map[string]*gtsmodel.Filter + testFilterKeywords map[string]*gtsmodel.FilterKeyword + testFilterStatuses map[string]*gtsmodel.FilterStatus + + // module being tested + filtersModule *filtersV2.Module +} + +func (suite *FiltersTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testStatuses = testrig.NewTestStatuses() + suite.testFilters = testrig.NewTestFilters() + suite.testFilterKeywords = testrig.NewTestFilterKeywords() + suite.testFilterStatuses = testrig.NewTestFilterStatuses() +} + +func (suite *FiltersTestSuite) SetupTest() { + suite.state.Caches.Init() + testrig.StartNoopWorkers(&suite.state) + + testrig.InitTestConfig() + config.Config(func(cfg *config.Configuration) { + cfg.WebAssetBaseDir = "../../../../../web/assets/" + cfg.WebTemplateBaseDir = "../../../../../web/templates/" + }) + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.state.DB = suite.db + suite.storage = testrig.NewInMemoryStorage() + suite.state.Storage = suite.storage + + testrig.StartTimelines( + &suite.state, + visibility.NewFilter(&suite.state), + typeutils.NewConverter(&suite.state), + ) + + suite.mediaManager = testrig.NewTestMediaManager(&suite.state) + suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.filtersModule = filtersV2.New(suite.processor) + + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../../testrig/media") +} + +func (suite *FiltersTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) + testrig.StopWorkers(&suite.state) +} + +func TestFiltersTestSuite(t *testing.T) { + suite.Run(t, new(FiltersTestSuite)) +} diff --git a/internal/api/client/filters/v2/filterdelete.go b/internal/api/client/filters/v2/filterdelete.go new file mode 100644 index 0000000000..7292fd6314 --- /dev/null +++ b/internal/api/client/filters/v2/filterdelete.go @@ -0,0 +1,90 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterDELETEHandler swagger:operation DELETE /api/v2/filters/{id} filterV2Delete +// +// Delete a single filter with the given ID. +// +// --- +// tags: +// - filters +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the filter +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:filters +// +// responses: +// '200': +// description: filter deleted +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FilterDELETEHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + errWithCode = m.processor.FiltersV2().Delete(c.Request.Context(), authed.Account, id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/filters/v2/filterdelete_test.go b/internal/api/client/filters/v2/filterdelete_test.go new file mode 100644 index 0000000000..ff9bf23f5c --- /dev/null +++ b/internal/api/client/filters/v2/filterdelete_test.go @@ -0,0 +1,115 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) deleteFilter( + filterID string, + expectedHTTPStatus int, + expectedBody string, +) error { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID, nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", filterID) + + // trigger the handler + suite.filtersModule.FilterDELETEHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return errs.Combine() + } + + resp := &struct{}{} + if err := json.Unmarshal(b, resp); err != nil { + return err + } + + return nil +} + +func (suite *FiltersTestSuite) TestDeleteFilter() { + id := suite.testFilters["local_account_1_filter_1"].ID + + err := suite.deleteFilter(id, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() { + id := suite.testFilters["local_account_2_filter_1"].ID + + err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestDeleteNonexistentFilter() { + id := "not_even_a_real_ULID" + + err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/filters/v2/filterget.go b/internal/api/client/filters/v2/filterget.go new file mode 100644 index 0000000000..a3481e0e00 --- /dev/null +++ b/internal/api/client/filters/v2/filterget.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterGETHandler swagger:operation GET /api/v2/filters/{id} filterV2Get +// +// Get a single filter with the given ID. +// +// --- +// tags: +// - filters +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the filter +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:filters +// +// responses: +// '200': +// name: filter +// description: Requested filter. +// schema: +// "$ref": "#/definitions/filterV2" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FilterGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().Get(c.Request.Context(), authed.Account, id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterget_test.go b/internal/api/client/filters/v2/filterget_test.go new file mode 100644 index 0000000000..57618f5910 --- /dev/null +++ b/internal/api/client/filters/v2/filterget_test.go @@ -0,0 +1,122 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/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()) + } +} diff --git a/internal/api/client/filters/v2/filterkeyworddelete.go b/internal/api/client/filters/v2/filterkeyworddelete.go new file mode 100644 index 0000000000..41ef12bfba --- /dev/null +++ b/internal/api/client/filters/v2/filterkeyworddelete.go @@ -0,0 +1,54 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) FilterKeywordDELETEHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + errWithCode = m.processor.FiltersV2().KeywordDelete(c.Request.Context(), authed.Account, id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/filters/v2/filterkeyworddelete_test.go b/internal/api/client/filters/v2/filterkeyworddelete_test.go new file mode 100644 index 0000000000..fc949593da --- /dev/null +++ b/internal/api/client/filters/v2/filterkeyworddelete_test.go @@ -0,0 +1,115 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) deleteFilterKeyword( + filterKeywordID string, + expectedHTTPStatus int, + expectedBody string, +) error { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", filterKeywordID) + + // trigger the handler + suite.filtersModule.FilterKeywordDELETEHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return errs.Combine() + } + + resp := &struct{}{} + if err := json.Unmarshal(b, resp); err != nil { + return err + } + + return nil +} + +func (suite *FiltersTestSuite) TestDeleteFilterKeyword() { + id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID + + err := suite.deleteFilterKeyword(id, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterKeyword() { + id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID + + err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestDeleteNonexistentFilterKeyword() { + id := "not_even_a_real_ULID" + + err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/filters/v2/filterkeywordget.go b/internal/api/client/filters/v2/filterkeywordget.go new file mode 100644 index 0000000000..2df6fd10ac --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordget.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterKeywordGETHandler swagger:operation GET /api/v2/filters/keywords/{id} filterKeywordGet +// +// Get a single filter keyword with the given ID. +// +// --- +// tags: +// - filters +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the filter keyword +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:filters +// +// responses: +// '200': +// name: filterKeyword +// description: Requested filter keyword. +// schema: +// "$ref": "#/definitions/filterKeyword" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FilterKeywordGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().KeywordGet(c.Request.Context(), authed.Account, id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterkeywordget_test.go b/internal/api/client/filters/v2/filterkeywordget_test.go new file mode 100644 index 0000000000..a5d8754a6e --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordget_test.go @@ -0,0 +1,122 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterKeyword( + filterKeywordID string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.FilterKeyword, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", filterKeywordID) + + // trigger the handler + suite.filtersModule.FilterKeywordGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.FilterKeyword{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterKeyword() { + expectedFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"] + + filterKeyword, err := suite.getFilterKeyword(expectedFilterKeyword.ID, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.NotEmpty(filterKeyword) + suite.Equal(expectedFilterKeyword.ID, filterKeyword.ID) + suite.Equal(expectedFilterKeyword.Keyword, filterKeyword.Keyword) + suite.Equal(util.PtrValueOr(expectedFilterKeyword.WholeWord, false), filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() { + id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID + + _, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestGetNonexistentFilterKeyword() { + id := "not_even_a_real_ULID" + + _, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/filters/v2/filterkeywordpost.go b/internal/api/client/filters/v2/filterkeywordpost.go new file mode 100644 index 0000000000..7ec5958200 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordpost.go @@ -0,0 +1,151 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// FilterKeywordPOSTHandler swagger:operation POST /api/v2/filters/{id}/keywords filterKeywordPost +// +// Add a filter keyword to an existing filter. +// +// --- +// tags: +// - filters +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// in: path +// type: string +// required: true +// description: ID of the filter to add the filtered status to. +// - +// name: keyword +// in: formData +// required: true +// description: |- +// The text to be filtered +// +// Sample: fnord +// type: string +// minLength: 1 +// maxLength: 40 +// - +// name: whole_word +// in: formData +// description: |- +// Should the filter consider word boundaries? +// +// Sample: true +// type: boolean +// default: false +// +// security: +// - OAuth2 Bearer: +// - write:filters +// +// responses: +// '200': +// name: filterKeyword +// description: New filter keyword. +// schema: +// "$ref": "#/definitions/filterKeyword" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '409': +// description: conflict (duplicate keyword) +// '422': +// description: unprocessable content +// '500': +// description: internal server error +func (m *Module) FilterKeywordPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + form := &apimodel.FilterKeywordCreateUpdateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := validateNormalizeCreateUpdateFilterKeyword(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().KeywordCreate(c.Request.Context(), authed.Account, filterID, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiFilter) +} + +func validateNormalizeCreateUpdateFilterKeyword(form *apimodel.FilterKeywordCreateUpdateRequest) error { + if err := validate.FilterKeyword(form.Keyword); err != nil { + return err + } + + form.WholeWord = util.Ptr(util.PtrValueOr(form.WholeWord, false)) + + return nil +} diff --git a/internal/api/client/filters/v2/filterkeywordpost_test.go b/internal/api/client/filters/v2/filterkeywordpost_test.go new file mode 100644 index 0000000000..85cc72f054 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordpost_test.go @@ -0,0 +1,192 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) postFilterKeyword( + filterID string, + keyword *string, + wholeWord *bool, + requestJson *string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.FilterKeyword, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/keywords", nil) + ctx.Request.Header.Set("accept", "application/json") + if requestJson != nil { + ctx.Request.Header.Set("content-type", "application/json") + ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) + } else { + ctx.Request.Form = make(url.Values) + if keyword != nil { + ctx.Request.Form["keyword"] = []string{*keyword} + } + if wholeWord != nil { + ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)} + } + } + + ctx.AddParam("id", filterID) + + // trigger the handler + suite.filtersModule.FilterKeywordPOSTHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.FilterKeyword{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordFull() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + keyword := "fnords" + wholeWord := true + filterKeyword, err := suite.postFilterKeyword(filterID, &keyword, &wholeWord, nil, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(keyword, filterKeyword.Keyword) + suite.Equal(wholeWord, filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordFullJSON() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + requestJson := `{ + "keyword": "fnords", + "whole_word": true + }` + filterKeyword, err := suite.postFilterKeyword(filterID, nil, nil, &requestJson, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal("fnords", filterKeyword.Keyword) + suite.True(filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordMinimal() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + keyword := "fnords" + filterKeyword, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(keyword, filterKeyword.Keyword) + suite.False(filterKeyword.WholeWord) +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordEmptyKeyword() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + keyword := "" + _, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordMissingKeyword() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + _, err := suite.postFilterKeyword(filterID, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter keyword must be provided, and must be no more than 40 chars"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +// Creating another filter keyword in the same filter with the same keyword should fail. +func (suite *FiltersTestSuite) TestPostFilterKeywordKeywordConflict() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + keyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].Keyword + _, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusConflict, `{"error":"Conflict: duplicate keyword"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordAnotherAccountsFilter() { + filterID := suite.testFilters["local_account_2_filter_1"].ID + keyword := "fnords" + _, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPostFilterKeywordNonexistentFilter() { + filterID := "not_even_a_real_ULID" + keyword := "fnords" + _, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/filters/v2/filterkeywordput.go b/internal/api/client/filters/v2/filterkeywordput.go new file mode 100644 index 0000000000..5ef0fe9761 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordput.go @@ -0,0 +1,138 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterKeywordPUTHandler swagger:operation PUT /api/v2/filters/keywords{id} filterKeywordPut +// +// Update a single filter keyword with the given ID. +// +// --- +// tags: +// - filters +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// in: path +// type: string +// required: true +// description: ID of the filter keyword to update. +// - +// name: keyword +// in: formData +// required: true +// description: |- +// The text to be filtered +// +// Sample: fnord +// type: string +// minLength: 1 +// maxLength: 40 +// - +// name: whole_word +// in: formData +// description: |- +// Should the filter consider word boundaries? +// +// Sample: true +// type: boolean +// +// security: +// - OAuth2 Bearer: +// - write:filters +// +// responses: +// '200': +// name: filterKeyword +// description: Updated filter keyword. +// schema: +// "$ref": "#/definitions/filterKeyword" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '409': +// description: conflict (duplicate keyword) +// '422': +// description: unprocessable content +// '500': +// description: internal server error +func (m *Module) FilterKeywordPUTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + form := &apimodel.FilterKeywordCreateUpdateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := validateNormalizeCreateUpdateFilterKeyword(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().KeywordUpdate(c.Request.Context(), authed.Account, id, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterkeywordput_test.go b/internal/api/client/filters/v2/filterkeywordput_test.go new file mode 100644 index 0000000000..0f397b7534 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordput_test.go @@ -0,0 +1,192 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) putFilterKeyword( + filterKeywordID string, + keyword *string, + wholeWord *bool, + requestJson *string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.FilterKeyword, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.KeywordPath+"/"+filterKeywordID, nil) + ctx.Request.Header.Set("accept", "application/json") + if requestJson != nil { + ctx.Request.Header.Set("content-type", "application/json") + ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) + } else { + ctx.Request.Form = make(url.Values) + if keyword != nil { + ctx.Request.Form["keyword"] = []string{*keyword} + } + if wholeWord != nil { + ctx.Request.Form["whole_word"] = []string{strconv.FormatBool(*wholeWord)} + } + } + + ctx.AddParam("id", filterKeywordID) + + // trigger the handler + suite.filtersModule.FilterKeywordPUTHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.FilterKeyword{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestPutFilterKeywordFull() { + filterKeywordID := suite.testFilters["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.testFilters["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.testFilters["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.testFilters["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.testFilters["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.testFilters["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 0000000000..3414c5d8c2 --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordsget.go @@ -0,0 +1,95 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterKeywordsGETHandler swagger:operation GET /api/v2/filters/{id}/keywords filterKeywordsGet +// +// Get all filter keywords for a given filter. +// +// --- +// tags: +// - filters +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the filter +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:filters +// +// responses: +// '200': +// name: filterKeywords +// description: Requested filter keywords. +// schema: +// type: array +// items: +// "$ref": "#/definitions/filterKeyword" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FilterKeywordsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().KeywordsGetForFilterID(c.Request.Context(), authed.Account, filterID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterkeywordsget_test.go b/internal/api/client/filters/v2/filterkeywordsget_test.go new file mode 100644 index 0000000000..0b0b69e03e --- /dev/null +++ b/internal/api/client/filters/v2/filterkeywordsget_test.go @@ -0,0 +1,117 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterKeywords( + filterID string, + expectedHTTPStatus int, + expectedBody string, +) ([]*apimodel.FilterKeyword, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/keywords", nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", filterID) + + // trigger the handler + suite.filtersModule.FilterKeywordsGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := make([]*apimodel.FilterKeyword, 0) + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterKeywords() { + // Collect the sets of filter keyword IDs we expect to see. + filterID := suite.testFilters["local_account_1_filter_1"].ID + expectedFilterKeywordIDs := []string{} + for _, filterKeyword := range suite.testFilterKeywords { + if filterKeyword.FilterID == filterID { + expectedFilterKeywordIDs = append(expectedFilterKeywordIDs, filterKeyword.ID) + } + } + suite.NotEmpty(expectedFilterKeywordIDs) + + // Fetch all filter keywords for the test filter. + filterKeywords, err := suite.getFilterKeywords(filterID, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotEmpty(filterKeywords) + + // Check that we got the right ones. + suite.Len(filterKeywords, len(expectedFilterKeywordIDs)) + actualFilterKeywordIDs := []string{} + for _, filterKeyword := range filterKeywords { + actualFilterKeywordIDs = append(actualFilterKeywordIDs, filterKeyword.ID) + } + suite.ElementsMatch(expectedFilterKeywordIDs, actualFilterKeywordIDs) +} diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go new file mode 100644 index 0000000000..e87c2ae7f0 --- /dev/null +++ b/internal/api/client/filters/v2/filterpost.go @@ -0,0 +1,263 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// FilterPOSTHandler swagger:operation POST /api/v2/filters filterV2Post +// +// Create a single filter. +// +// --- +// tags: +// - filters +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// parameters: +// - +// name: title +// in: formData +// required: true +// description: |- +// The name of the filter. +// +// Sample: illuminati nonsense +// type: string +// minLength: 1 +// maxLength: 200 +// - +// name: context[] +// in: formData +// required: true +// description: |- +// The contexts in which the filter should be applied. +// +// Sample: home, public +// enum: +// - home +// - notifications +// - public +// - thread +// - account +// type: array +// items: +// type: +// string +// collectionFormat: multi +// minItems: 1 +// uniqueItems: true +// - +// name: expires_in +// in: formData +// description: |- +// Number of seconds from now that the filter should expire. If omitted, filter never expires. +// +// Sample: 86400 +// type: number +// - +// name: filter_action +// in: formData +// description: |- +// The action to be taken when a status matches this filter. +// +// Sample: warn +// type: string +// enum: +// - warn +// - hide +// default: warn +// - +// name: keywords_attributes[][keyword] +// type: array +// items: +// type: string +// description: Keywords to be added (if not using id param) or updated (if using id param). +// in: query +// collectionFormat: multi +// - +// name: keywords_attributes[][whole_word] +// type: array +// items: +// type: bool +// description: Should each keyword consider word boundaries? +// in: query +// collectionFormat: multi +// - +// name: statuses_attributes[][status_id] +// type: array +// items: +// type: string +// description: Statuses to be added to the filter. +// in: query +// collectionFormat: multi +// +// 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 + } + + // Parse form variant of normal filter keyword creation structs. + if len(form.KeywordsAttributesKeyword) > 0 { + form.Keywords = make([]apimodel.FilterKeywordCreateUpdateRequest, 0, len(form.KeywordsAttributesKeyword)) + for i, keyword := range form.KeywordsAttributesKeyword { + formKeyword := apimodel.FilterKeywordCreateUpdateRequest{ + Keyword: keyword, + } + if i < len(form.KeywordsAttributesWholeWord) { + formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i] + } + form.Keywords = append(form.Keywords, formKeyword) + } + } + + // Parse form variant of normal filter status creation structs. + if len(form.StatusesAttributesStatusID) > 0 { + form.Statuses = make([]apimodel.FilterStatusCreateRequest, 0, len(form.StatusesAttributesStatusID)) + for _, statusID := range form.StatusesAttributesStatusID { + form.Statuses = append(form.Statuses, apimodel.FilterStatusCreateRequest{ + StatusID: statusID, + }) + } + } + + // 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) + } + } + + // Normalize and validate new keywords and statuses. + for i, formKeyword := range form.Keywords { + if err := validate.FilterKeyword(formKeyword.Keyword); err != nil { + return err + } + form.Keywords[i].WholeWord = util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)) + } + for _, formStatus := range form.Statuses { + if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil { + return err + } + } + + 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 0000000000..c7787173c6 --- /dev/null +++ b/internal/api/client/filters/v2/filterpost_test.go @@ -0,0 +1,293 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "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, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, statusesAttributesStatusID *[]string, 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)} + } + if keywordsAttributesKeyword != nil { + ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword + } + if keywordsAttributesWholeWord != nil { + formatted := []string{} + for _, value := range *keywordsAttributesWholeWord { + formatted = append(formatted, strconv.FormatBool(value)) + } + ctx.Request.Form["keywords_attributes[][whole_word]"] = formatted + } + if statusesAttributesStatusID != nil { + ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID + } + } + + // 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 + // Checked in lexical order by keyword, so keep this sorted. + keywordsAttributesKeyword := []string{"GNU", "Linux"} + keywordsAttributesWholeWord := []bool{true, false} + // Checked in lexical order by status ID, so keep this sorted. + statusAttributesStatusID := []string{"01HEN2QRFA8H3C6QPN7RD4KSR6", "01HEWV37MHV8BAC8ANFGVRRM5D"} + filter, err := suite.postFilter(&title, &context, &action, &expiresIn, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &statusAttributesStatusID, 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) + } + + if suite.Len(filter.Keywords, len(keywordsAttributesKeyword)) { + slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { + return strings.Compare(lhs.Keyword, rhs.Keyword) + }) + for i, filterKeyword := range filter.Keywords { + suite.Equal(keywordsAttributesKeyword[i], filterKeyword.Keyword) + suite.Equal(keywordsAttributesWholeWord[i], filterKeyword.WholeWord) + } + } + + if suite.Len(filter.Statuses, len(statusAttributesStatusID)) { + slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { + return strings.Compare(lhs.StatusID, rhs.StatusID) + }) + for i, filterStatus := range filter.Statuses { + suite.Equal(statusAttributesStatusID[i], filterStatus.StatusID) + } + } +} + +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, + "keywords_attributes": [ + { + "keyword": "GNU", + "whole_word": true + }, + { + "keyword": "Linux", + "whole_word": false + } + ], + "statuses_attributes": [ + { + "status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6" + }, + { + "status_id": "01HEWV37MHV8BAC8ANFGVRRM5D" + } + ] + }` + filter, err := suite.postFilter(nil, nil, nil, 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) + } + + if suite.Len(filter.Keywords, 2) { + slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { + return strings.Compare(lhs.Keyword, rhs.Keyword) + }) + + suite.Equal("GNU", filter.Keywords[0].Keyword) + suite.True(filter.Keywords[0].WholeWord) + + suite.Equal("Linux", filter.Keywords[1].Keyword) + suite.False(filter.Keywords[1].WholeWord) + } + + if suite.Len(filter.Statuses, 2) { + slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { + return strings.Compare(lhs.StatusID, rhs.StatusID) + }) + + suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID) + + suite.Equal("01HEWV37MHV8BAC8ANFGVRRM5D", filter.Statuses[1].StatusID) + } +} + +func (suite *FiltersTestSuite) TestPostFilterMinimal() { + title := "GNU/Linux" + context := []string{"home"} + filter, err := suite.postFilter(&title, &context, nil, nil, nil, 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) TestPostFilterEmptyTitle() { + title := "" + context := []string{"home"} + _, err := suite.postFilter(&title, &context, nil, nil, nil, 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, 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, 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, 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, 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 0000000000..b8a4da07d7 --- /dev/null +++ b/internal/api/client/filters/v2/filterput.go @@ -0,0 +1,319 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "errors" + "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: keywords_attributes[][keyword] +// type: array +// items: +// type: string +// description: Keywords to be added to the created filter. +// in: query +// collectionFormat: multi +// - +// name: keywords_attributes[][whole_word] +// type: array +// items: +// type: bool +// description: Should each keyword consider word boundaries? +// in: query +// collectionFormat: multi +// - +// name: statuses_attributes[][status_id] +// type: array +// items: +// type: string +// description: Statuses to be added to the newly created filter. +// in: query +// collectionFormat: multi +// - +// 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 + } + } + + // Parse form variant of normal filter keyword update structs. + // All filter keyword update struct fields are optional. + numFormKeywords := max( + len(form.KeywordsAttributesID), + len(form.KeywordsAttributesKeyword), + len(form.KeywordsAttributesWholeWord), + len(form.KeywordsAttributesDestroy), + ) + if numFormKeywords > 0 { + form.Keywords = make([]apimodel.FilterKeywordCreateUpdateDeleteRequest, 0, numFormKeywords) + for i := 0; i < numFormKeywords; i++ { + formKeyword := apimodel.FilterKeywordCreateUpdateDeleteRequest{} + if i < len(form.KeywordsAttributesID) && form.KeywordsAttributesID[i] != "" { + formKeyword.ID = &form.KeywordsAttributesID[i] + } + if i < len(form.KeywordsAttributesKeyword) && form.KeywordsAttributesKeyword[i] != "" { + formKeyword.Keyword = &form.KeywordsAttributesKeyword[i] + } + if i < len(form.KeywordsAttributesWholeWord) { + formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i] + } + if i < len(form.KeywordsAttributesDestroy) { + formKeyword.Destroy = &form.KeywordsAttributesDestroy[i] + } + form.Keywords = append(form.Keywords, formKeyword) + } + } + + // Parse form variant of normal filter status update structs. + // All filter status update struct fields are optional. + numFormStatuses := max( + len(form.StatusesAttributesID), + len(form.StatusesAttributesStatusID), + len(form.StatusesAttributesDestroy), + ) + if numFormStatuses > 0 { + form.Statuses = make([]apimodel.FilterStatusCreateDeleteRequest, 0, numFormStatuses) + for i := 0; i < numFormStatuses; i++ { + formStatus := apimodel.FilterStatusCreateDeleteRequest{} + if i < len(form.StatusesAttributesID) && form.StatusesAttributesID[i] != "" { + formStatus.ID = &form.StatusesAttributesID[i] + } + if i < len(form.StatusesAttributesStatusID) && form.StatusesAttributesStatusID[i] != "" { + formStatus.StatusID = &form.StatusesAttributesStatusID[i] + } + if i < len(form.StatusesAttributesDestroy) { + formStatus.Destroy = &form.StatusesAttributesDestroy[i] + } + form.Statuses = append(form.Statuses, formStatus) + } + } + + // 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) + } + } + + // Normalize and validate updates. + for i, formKeyword := range form.Keywords { + if formKeyword.Keyword != nil { + if err := validate.FilterKeyword(*formKeyword.Keyword); err != nil { + return err + } + } + + destroy := util.PtrValueOr(formKeyword.Destroy, false) + form.Keywords[i].Destroy = &destroy + + if destroy && formKeyword.ID == nil { + return errors.New("can't delete a filter keyword without an ID") + } else if formKeyword.ID == nil && formKeyword.Keyword == nil { + return errors.New("can't create a filter keyword without a keyword") + } + } + for i, formStatus := range form.Statuses { + if formStatus.StatusID != nil { + if err := validate.ULID(*formStatus.StatusID, "status_id"); err != nil { + return err + } + } + + destroy := util.PtrValueOr(formStatus.Destroy, false) + form.Statuses[i].Destroy = &destroy + + if destroy && formStatus.ID == nil { + return errors.New("can't delete a filter status without an ID") + } else if formStatus.ID != nil { + return errors.New("filter status IDs here can only be used to delete them") + } else if formStatus.StatusID == nil { + return errors.New("can't create a filter keyword without a status ID") + } + } + + 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 0000000000..b159860db5 --- /dev/null +++ b/internal/api/client/filters/v2/filterput_test.go @@ -0,0 +1,336 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "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, keywordsAttributesID *[]string, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, keywordsAttributesDestroy *[]bool, statusesAttributesID *[]string, statusesAttributesStatusID *[]string, statusesAttributesDestroy *[]bool, 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)} + } + if keywordsAttributesID != nil { + ctx.Request.Form["keywords_attributes[][id]"] = *keywordsAttributesID + } + if keywordsAttributesKeyword != nil { + ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword + } + if keywordsAttributesWholeWord != nil { + formatted := []string{} + for _, value := range *keywordsAttributesWholeWord { + formatted = append(formatted, strconv.FormatBool(value)) + } + ctx.Request.Form["keywords_attributes[][whole_word]"] = formatted + } + if keywordsAttributesWholeWord != nil { + formatted := []string{} + for _, value := range *keywordsAttributesDestroy { + formatted = append(formatted, strconv.FormatBool(value)) + } + ctx.Request.Form["keywords_attributes[][_destroy]"] = formatted + } + if statusesAttributesID != nil { + ctx.Request.Form["statuses_attributes[][id]"] = *statusesAttributesID + } + if statusesAttributesStatusID != nil { + ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID + } + if statusesAttributesDestroy != nil { + formatted := []string{} + for _, value := range *statusesAttributesDestroy { + formatted = append(formatted, strconv.FormatBool(value)) + } + ctx.Request.Form["statuses_attributes[][_destroy]"] = formatted + } + } + + 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 + // Tests attributes arrays that aren't the same length, just in case. + keywordsAttributesID := []string{ + suite.testFilterKeywords["local_account_1_filter_2_keyword_1"].ID, + suite.testFilterKeywords["local_account_1_filter_2_keyword_2"].ID, + } + keywordsAttributesKeyword := []string{"fū", "", "blah"} + // If using the form version of this API, you have to always set whole_word to the previous value for that keyword; + // there's no way to represent a nullable boolean in it. + keywordsAttributesWholeWord := []bool{true, false, true} + keywordsAttributesDestroy := []bool{false, true} + statusesAttributesStatusID := []string{suite.testStatuses["remote_account_1_status_2"].ID} + filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, &keywordsAttributesID, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &keywordsAttributesDestroy, nil, &statusesAttributesStatusID, 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.FilterActionHide, filter.FilterAction) + if suite.NotNil(filter.ExpiresAt) { + suite.NotEmpty(*filter.ExpiresAt) + } + + if suite.Len(filter.Keywords, 3) { + slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { + return strings.Compare(lhs.ID, rhs.ID) + }) + + suite.Equal("fū", filter.Keywords[0].Keyword) + suite.True(filter.Keywords[0].WholeWord) + + suite.Equal("quux", filter.Keywords[1].Keyword) + suite.True(filter.Keywords[1].WholeWord) + + suite.Equal("blah", filter.Keywords[2].Keyword) + suite.True(filter.Keywords[1].WholeWord) + } + + if suite.Len(filter.Statuses, 1) { + slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { + return strings.Compare(lhs.ID, rhs.ID) + }) + + suite.Equal(suite.testStatuses["remote_account_1_status_2"].ID, filter.Statuses[0].StatusID) + } +} + +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, + "keywords_attributes": [ + { + "id": "01HN277Y11ENG4EC1ERMAC9FH4", + "keyword": "fū" + }, + { + "id": "01HN278494N88BA2FY4DZ5JTNS", + "_destroy": true + }, + { + "keyword": "blah", + "whole_word": true + } + ], + "statuses_attributes": [ + { + "status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6" + } + ] + }` + filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, nil, nil, 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) + } + + if suite.Len(filter.Keywords, 3) { + slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { + return strings.Compare(lhs.ID, rhs.ID) + }) + + suite.Equal("fū", filter.Keywords[0].Keyword) + suite.True(filter.Keywords[0].WholeWord) + + suite.Equal("quux", filter.Keywords[1].Keyword) + suite.True(filter.Keywords[1].WholeWord) + + suite.Equal("blah", filter.Keywords[2].Keyword) + suite.True(filter.Keywords[1].WholeWord) + } + + if suite.Len(filter.Statuses, 1) { + slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { + return strings.Compare(lhs.ID, rhs.ID) + }) + + suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID) + } +} + +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, nil, nil, nil, nil, 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, nil, nil, nil, nil, 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, nil, nil, nil, nil, 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, nil, nil, nil, 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, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPutNonexistentFilter() { + id := "not_even_a_real_ULID" + phrase := "GNU/Linux" + context := []string{"home"} + _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, nil, nil, 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 0000000000..511a62d365 --- /dev/null +++ b/internal/api/client/filters/v2/filtersget.go @@ -0,0 +1,81 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FiltersGETHandler swagger:operation GET /api/v2/filters filtersV2Get +// +// Get all filters for the authenticated account. +// +// --- +// tags: +// - filters +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:filters +// +// responses: +// '200': +// name: filters +// description: Requested filters. +// schema: +// type: array +// items: +// "$ref": "#/definitions/filterV2" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FiltersGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + apiFilters, errWithCode := m.processor.FiltersV2().GetAll(c.Request.Context(), authed.Account) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiFilters) +} diff --git a/internal/api/client/filters/v2/filtersget_test.go b/internal/api/client/filters/v2/filtersget_test.go new file mode 100644 index 0000000000..b77df42a69 --- /dev/null +++ b/internal/api/client/filters/v2/filtersget_test.go @@ -0,0 +1,154 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilters( + expectedHTTPStatus int, + expectedBody string, +) ([]*apimodel.FilterV2, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath, nil) + ctx.Request.Header.Set("accept", "application/json") + + // trigger the handler + suite.filtersModule.FiltersGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := make([]*apimodel.FilterV2, 0) + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilters() { + // Set of filter IDs for the test user. + expectedFilterIDs := []string{} + // Map of filter IDs to filter keyword and status IDs. + expectedFilters := map[string]struct { + keywordIDs []string + statusIDs []string + }{} + + // Collect the sets of IDs we expect to see. + accountID := suite.testAccounts["local_account_1"].ID + for _, filter := range suite.testFilters { + if filter.AccountID == accountID { + expectedFilterIDs = append(expectedFilterIDs, filter.ID) + expectedFilters[filter.ID] = struct { + keywordIDs []string + statusIDs []string + }{} + } + } + for _, filterKeyword := range suite.testFilterKeywords { + if filterKeyword.AccountID == accountID { + expectedIDsForFilter := expectedFilters[filterKeyword.FilterID] + expectedIDsForFilter.keywordIDs = append(expectedIDsForFilter.keywordIDs, filterKeyword.ID) + expectedFilters[filterKeyword.FilterID] = expectedIDsForFilter + } + } + for _, filterStatus := range suite.testFilterStatuses { + if filterStatus.AccountID == accountID { + expectedIDsForFilter := expectedFilters[filterStatus.FilterID] + expectedIDsForFilter.statusIDs = append(expectedIDsForFilter.statusIDs, filterStatus.ID) + expectedFilters[filterStatus.FilterID] = expectedIDsForFilter + } + } + suite.NotEmpty(expectedFilterIDs) + suite.NotEmpty(expectedFilters) + + // Fetch all filters for the logged-in account. + filters, err := suite.getFilters(http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotEmpty(filters) + + // Check that we got the right ones. + suite.Len(filters, len(expectedFilters)) + + actualFilterIDs := []string{} + for _, filter := range filters { + actualFilterIDs = append(actualFilterIDs, filter.ID) + + expectedIDsForFilter := expectedFilters[filter.ID] + + actualFilterKeywordIDs := []string{} + for _, filterKeyword := range filter.Keywords { + actualFilterKeywordIDs = append(actualFilterKeywordIDs, filterKeyword.ID) + } + suite.ElementsMatch(actualFilterKeywordIDs, expectedIDsForFilter.keywordIDs) + + actualFilterStatusIDs := []string{} + for _, filterStatus := range filter.Statuses { + actualFilterStatusIDs = append(actualFilterStatusIDs, filterStatus.ID) + } + suite.ElementsMatch(actualFilterStatusIDs, expectedIDsForFilter.statusIDs) + } + suite.ElementsMatch(expectedFilterIDs, actualFilterIDs) +} diff --git a/internal/api/client/filters/v2/filterstatusdelete.go b/internal/api/client/filters/v2/filterstatusdelete.go new file mode 100644 index 0000000000..e10125a329 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusdelete.go @@ -0,0 +1,54 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) FilterStatusDELETEHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + errWithCode = m.processor.FiltersV2().StatusDelete(c.Request.Context(), authed.Account, id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/filters/v2/filterstatusdelete_test.go b/internal/api/client/filters/v2/filterstatusdelete_test.go new file mode 100644 index 0000000000..c6627b728d --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusdelete_test.go @@ -0,0 +1,112 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) deleteFilterStatus( + filterStatusID string, + expectedHTTPStatus int, + expectedBody string, +) error { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodDelete, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.StatusPath+"/"+filterStatusID, nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", filterStatusID) + + // trigger the handler + suite.filtersModule.FilterDELETEHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return errs.Combine() + } + + resp := &struct{}{} + if err := json.Unmarshal(b, resp); err != nil { + return err + } + + return nil +} + +func (suite *FiltersTestSuite) TestDeleteFilterStatus() { + id := suite.testFilterStatuses["local_account_1_filter_3_status_1"].ID + + err := suite.deleteFilterStatus(id, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterStatus() { + id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID + + err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestDeleteNonexistentFilterStatus() { + id := "not_even_a_real_ULID" + + err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/filters/v2/filterstatusesget.go b/internal/api/client/filters/v2/filterstatusesget.go new file mode 100644 index 0000000000..3b05ca73dd --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusesget.go @@ -0,0 +1,95 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterStatusesGETHandler swagger:operation GET /api/v2/filters/{id}/statuses filterStatusesGet +// +// Get all filter statuses for a given filter. +// +// --- +// tags: +// - filters +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the filter +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:filters +// +// responses: +// '200': +// name: filterStatuses +// description: Requested filter statuses. +// schema: +// type: array +// items: +// "$ref": "#/definitions/filterStatus" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FilterStatusesGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().StatusesGetForFilterID(c.Request.Context(), authed.Account, filterID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterstatusesget_test.go b/internal/api/client/filters/v2/filterstatusesget_test.go new file mode 100644 index 0000000000..6b8262f265 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusesget_test.go @@ -0,0 +1,117 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterStatuses( + filterID string, + expectedHTTPStatus int, + expectedBody string, +) ([]*apimodel.FilterStatus, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/statuses", nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", filterID) + + // trigger the handler + suite.filtersModule.FilterStatusesGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := make([]*apimodel.FilterStatus, 0) + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterStatuses() { + // Collect the sets of filter status IDs we expect to see. + filterID := suite.testFilters["local_account_1_filter_3"].ID + expectedFilterStatusIDs := []string{} + for _, filterStatus := range suite.testFilterStatuses { + if filterStatus.FilterID == filterID { + expectedFilterStatusIDs = append(expectedFilterStatusIDs, filterStatus.ID) + } + } + suite.NotEmpty(expectedFilterStatusIDs) + + // Fetch all filter statuses for the test filter. + filterStatuses, err := suite.getFilterStatuses(filterID, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotEmpty(filterStatuses) + + // Check that we got the right ones. + suite.Len(filterStatuses, len(expectedFilterStatusIDs)) + actualFilterStatusIDs := []string{} + for _, filterStatus := range filterStatuses { + actualFilterStatusIDs = append(actualFilterStatusIDs, filterStatus.ID) + } + suite.ElementsMatch(expectedFilterStatusIDs, actualFilterStatusIDs) +} diff --git a/internal/api/client/filters/v2/filterstatusget.go b/internal/api/client/filters/v2/filterstatusget.go new file mode 100644 index 0000000000..9e62e4466b --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusget.go @@ -0,0 +1,93 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FilterStatusGETHandler swagger:operation GET /api/v2/filters/statuses/{id} filterStatusGet +// +// Get a single filter status with the given ID. +// +// --- +// tags: +// - filters +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the filter status +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:filters +// +// responses: +// '200': +// name: filterStatus +// description: Requested filter status. +// schema: +// "$ref": "#/definitions/filterStatus" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FilterStatusGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().StatusGet(c.Request.Context(), authed.Account, id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiFilter) +} diff --git a/internal/api/client/filters/v2/filterstatusget_test.go b/internal/api/client/filters/v2/filterstatusget_test.go new file mode 100644 index 0000000000..5df3971a83 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatusget_test.go @@ -0,0 +1,120 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) getFilterStatus( + filterStatusID string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.FilterStatus, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.StatusPath+"/"+filterStatusID, nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", filterStatusID) + + // trigger the handler + suite.filtersModule.FilterStatusGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.FilterStatus{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestGetFilterStatus() { + expectedFilterStatus := suite.testFilterStatuses["local_account_1_filter_3_status_1"] + + filterStatus, err := suite.getFilterStatus(expectedFilterStatus.ID, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.NotEmpty(filterStatus) + suite.Equal(expectedFilterStatus.ID, filterStatus.ID) + suite.Equal(expectedFilterStatus.StatusID, filterStatus.StatusID) +} + +func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterStatus() { + id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID + + _, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestGetNonexistentFilterStatus() { + id := "not_even_a_real_ULID" + + _, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/filters/v2/filterstatuspost.go b/internal/api/client/filters/v2/filterstatuspost.go new file mode 100644 index 0000000000..8a0efee85d --- /dev/null +++ b/internal/api/client/filters/v2/filterstatuspost.go @@ -0,0 +1,137 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// FilterStatusPOSTHandler swagger:operation POST /api/v2/filters/{id}/statuses filterStatusPost +// +// Add a filter status to an existing filter. +// +// --- +// tags: +// - filters +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// in: path +// type: string +// required: true +// description: ID of the filter to add the filtered status to. +// - +// name: status_id +// in: formData +// required: true +// description: |- +// The ID of the status to filter. +// +// Sample: 01HXA2NE0K8T1C70K90E74GYD0 +// type: string +// +// security: +// - OAuth2 Bearer: +// - write:filters +// +// responses: +// '200': +// name: filterStatus +// description: New filter status. +// schema: +// "$ref": "#/definitions/filterStatus" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '409': +// description: conflict (duplicate status) +// '422': +// description: unprocessable content +// '500': +// description: internal server error +func (m *Module) FilterStatusPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + form := &apimodel.FilterStatusCreateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := validateCreateFilterStatus(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) + return + } + + apiFilter, errWithCode := m.processor.FiltersV2().StatusCreate(c.Request.Context(), authed.Account, filterID, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiFilter) +} + +func validateCreateFilterStatus(form *apimodel.FilterStatusCreateRequest) error { + if err := validate.ULID(form.StatusID, "status_id"); err != nil { + return err + } + + return nil +} 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 0000000000..924b8ecc26 --- /dev/null +++ b/internal/api/client/filters/v2/filterstatuspost_test.go @@ -0,0 +1,180 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + + filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *FiltersTestSuite) postFilterStatus( + filterID string, + statusID *string, + requestJson *string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.FilterStatus, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+filtersV2.BasePath+"/"+filterID+"/statuses", nil) + ctx.Request.Header.Set("accept", "application/json") + if requestJson != nil { + ctx.Request.Header.Set("content-type", "application/json") + ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) + } else { + ctx.Request.Form = make(url.Values) + if statusID != nil { + ctx.Request.Form["status_id"] = []string{*statusID} + } + } + + ctx.AddParam("id", filterID) + + // trigger the handler + suite.filtersModule.FilterStatusPOSTHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.FilterStatus{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *FiltersTestSuite) TestPostFilterStatus() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + statusID := suite.testStatuses["admin_account_status_1"].ID + filterStatus, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(statusID, filterStatus.StatusID) +} + +func (suite *FiltersTestSuite) TestPostFilterStatusJSON() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + requestJson := `{ + "status_id": "01F8MH75CBF9JFX4ZAD54N0W0R" + }` + filterStatus, err := suite.postFilterStatus(filterID, nil, &requestJson, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(suite.testStatuses["admin_account_status_1"].ID, filterStatus.StatusID) +} + +func (suite *FiltersTestSuite) TestPostFilterStatusEmptyStatusID() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + statusID := "" + _, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id must be provided"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPostFilterStatusInvalidStatusID() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + statusID := "112401162517176488" // ma'am, that's clearly a Mastodon ID, this is a Wendy's + _, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id didn't match the expected ULID format for an ID (26 characters from the set 0123456789ABCDEFGHJKMNPQRSTVWXYZ)"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPostFilterStatusMissingStatusID() { + filterID := suite.testFilters["local_account_1_filter_1"].ID + _, err := suite.postFilterStatus(filterID, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: status_id must be provided"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +// Creating another filter status in the same filter with the same status ID should fail. +func (suite *FiltersTestSuite) TestPostFilterStatusStatusIDConflict() { + filterID := suite.testFilters["local_account_1_filter_3"].ID + statusID := suite.testFilterStatuses["local_account_1_filter_3_status_1"].StatusID + _, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusConflict, `{"error":"Conflict: duplicate status"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPostFilterStatusAnotherAccountsFilter() { + filterID := suite.testFilters["local_account_2_filter_1"].ID + statusID := suite.testStatuses["admin_account_status_1"].ID + _, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *FiltersTestSuite) TestPostFilterStatusNonexistentFilter() { + filterID := "not_even_a_real_ULID" + statusID := suite.testStatuses["admin_account_status_1"].ID + _, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/model/filterv2.go b/internal/api/model/filterv2.go index 797c97213f..242c569dc5 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,153 @@ 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"` + + // Keywords to be added to the newly created filter. + Keywords []FilterKeywordCreateUpdateRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"` + // Form data version of Keywords[].Keyword. + KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"` + // Form data version of Keywords[].WholeWord. + KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"` + + // Statuses to be added to the newly created filter. + Statuses []FilterStatusCreateRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"` + // Form data version of Statuses[].StatusID. + StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"` +} + +// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword while creating a v2 filter or as a standalone operation. +// +// 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 a status while creating a v2 filter or 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"` + + // Keywords to be added to the filter, modified, or removed. + Keywords []FilterKeywordCreateUpdateDeleteRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"` + // Form data version of Keywords[].ID. + KeywordsAttributesID []string `form:"keywords_attributes[][id]" json:"-" xml:"-"` + // Form data version of Keywords[].Keyword. + KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"` + // Form data version of Keywords[].WholeWord. + KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"` + // Form data version of Keywords[].Destroy. + KeywordsAttributesDestroy []bool `form:"keywords_attributes[][_destroy]" json:"-" xml:"-"` + + // Statuses to be added to the filter, or removed. + Statuses []FilterStatusCreateDeleteRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"` + // Form data version of Statuses[].ID. + StatusesAttributesID []string `form:"statuses_attributes[][id]" json:"-" xml:"-"` + // Form data version of Statuses[].ID. + StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"` + // Form data version of Statuses[].Destroy. + StatusesAttributesDestroy []bool `form:"statuses_attributes[][_destroy]" json:"-" xml:"-"` +} + +// FilterKeywordCreateUpdateDeleteRequest captures params for creating, updating, or deleting a keyword while updating a v2 filter. +// +// swagger:ignore +type FilterKeywordCreateUpdateDeleteRequest struct { + // The ID of the filter keyword entry in the database. + // Optional: use to modify or delete an existing keyword instead of adding a new one. + ID *string `json:"id" xml:"id"` + // The text to be filtered. + // + // Example: fnord + // Maximum length: 40 + Keyword *string `json:"keyword" xml:"keyword"` + // Should the filter keyword consider word boundaries? + // + // Example: true + WholeWord *bool `json:"whole_word" xml:"whole_word"` + // Remove this filter keyword. Requires an ID. + Destroy *bool `json:"_destroy" xml:"_destroy"` +} + +// FilterStatusCreateDeleteRequest captures params for creating or deleting a status while updating a v2 filter. +// +// swagger:ignore +type FilterStatusCreateDeleteRequest struct { + // The ID of the filter status entry in the database. + // Optional: use to delete an existing status instead of adding a new one. + ID *string `json:"id" xml:"id"` + // The status ID to be filtered. + StatusID *string `json:"status_id" xml:"status_id"` + // Remove this filter status. Requires an ID. + Destroy *bool `json:"_destroy" xml:"_destroy"` +} diff --git a/internal/gtsmodel/filter.go b/internal/gtsmodel/filter.go index db0a15dfda..a9844963d5 100644 --- a/internal/gtsmodel/filter.go +++ b/internal/gtsmodel/filter.go @@ -64,6 +64,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/v2/convert.go b/internal/processing/filters/v2/convert.go new file mode 100644 index 0000000000..1e544e6e4f --- /dev/null +++ b/internal/processing/filters/v2/convert.go @@ -0,0 +1,38 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// apiFilter is a shortcut to return the API v2 filter version of the given +// filter, or return an appropriate error if conversion fails. +func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.Filter) (*apimodel.FilterV2, gtserror.WithCode) { + apiFilter, err := p.converter.FilterToAPIFilterV2(ctx, filterKeyword) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter to API v2 filter: %w", err)) + } + + return apiFilter, nil +} diff --git a/internal/processing/filters/v2/create.go b/internal/processing/filters/v2/create.go new file mode 100644 index 0000000000..dd06002f45 --- /dev/null +++ b/internal/processing/filters/v2/create.go @@ -0,0 +1,98 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + "fmt" + "time" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Create a new filter for the given account, using the provided parameters. +// These params should have already been validated by the time they reach this function. +func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateRequestV2) (*apimodel.FilterV2, gtserror.WithCode) { + filter := >smodel.Filter{ + ID: id.NewULID(), + AccountID: account.ID, + Title: form.Title, + Action: typeutils.APIFilterActionToFilterAction(*form.FilterAction), + } + if form.ExpiresIn != nil { + filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn)) + } + for _, context := range form.Context { + switch context { + case apimodel.FilterContextHome: + filter.ContextHome = util.Ptr(true) + case apimodel.FilterContextNotifications: + filter.ContextNotifications = util.Ptr(true) + case apimodel.FilterContextPublic: + filter.ContextPublic = util.Ptr(true) + case apimodel.FilterContextThread: + filter.ContextThread = util.Ptr(true) + case apimodel.FilterContextAccount: + filter.ContextAccount = util.Ptr(true) + default: + return nil, gtserror.NewErrorUnprocessableEntity( + fmt.Errorf("unsupported filter context '%s'", context), + ) + } + } + + for _, formKeyword := range form.Keywords { + filterKeyword := >smodel.FilterKeyword{ + ID: id.NewULID(), + AccountID: account.ID, + FilterID: filter.ID, + Filter: filter, + Keyword: formKeyword.Keyword, + WholeWord: formKeyword.WholeWord, + } + filter.Keywords = append(filter.Keywords, filterKeyword) + } + + for _, formStatus := range form.Statuses { + filterStatus := >smodel.FilterStatus{ + ID: id.NewULID(), + AccountID: account.ID, + FilterID: filter.ID, + Filter: filter, + StatusID: formStatus.StatusID, + } + filter.Statuses = append(filter.Statuses, filterStatus) + } + + 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 0000000000..719734cdb4 --- /dev/null +++ b/internal/processing/filters/v2/delete.go @@ -0,0 +1,50 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + + "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(nil) + } + + // 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 0000000000..dfb6a89926 --- /dev/null +++ b/internal/processing/filters/v2/filters.go @@ -0,0 +1,35 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type Processor struct { + state *state.State + converter *typeutils.Converter +} + +func New(state *state.State, converter *typeutils.Converter) Processor { + return Processor{ + state: state, + converter: converter, + } +} diff --git a/internal/processing/filters/v2/get.go b/internal/processing/filters/v2/get.go new file mode 100644 index 0000000000..36392aa549 --- /dev/null +++ b/internal/processing/filters/v2/get.go @@ -0,0 +1,78 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + "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(nil) + } + + 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 0000000000..2fd8bfdfbe --- /dev/null +++ b/internal/processing/filters/v2/keywordcreate.go @@ -0,0 +1,64 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + + 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(nil) + } + + 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 0000000000..fce157b68a --- /dev/null +++ b/internal/processing/filters/v2/keyworddelete.go @@ -0,0 +1,50 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + + "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(nil) + } + + // 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 0000000000..7b90acbe34 --- /dev/null +++ b/internal/processing/filters/v2/keywordget.go @@ -0,0 +1,86 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + "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(nil) + } + + 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 0000000000..90c834105c --- /dev/null +++ b/internal/processing/filters/v2/keywordupdate.go @@ -0,0 +1,63 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + + 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(nil) + } + + 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 0000000000..cbda35008f --- /dev/null +++ b/internal/processing/filters/v2/statuscreate.go @@ -0,0 +1,63 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + + 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(nil) + } + + 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 0000000000..42aa1a2cb0 --- /dev/null +++ b/internal/processing/filters/v2/statusdelete.go @@ -0,0 +1,50 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + + "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(nil) + } + + // 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 0000000000..3c2d21e103 --- /dev/null +++ b/internal/processing/filters/v2/statusget.go @@ -0,0 +1,86 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + "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(nil) + } + + 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 0000000000..603f2a1615 --- /dev/null +++ b/internal/processing/filters/v2/update.go @@ -0,0 +1,250 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package v2 + +import ( + "context" + "errors" + "fmt" + "time" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// 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) { + var errWithCode 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(nil) + } + + // 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), + ) + } + } + } + + filterKeywordColumns, deleteFilterKeywordIDs, errWithCode := applyKeywordChanges(filter, form.Keywords) + if err != nil { + return nil, errWithCode + } + + deleteFilterStatusIDs, errWithCode := applyStatusChanges(filter, form.Statuses) + if err != nil { + return nil, errWithCode + } + + if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, deleteFilterKeywordIDs, deleteFilterStatusIDs); 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) + } + + return p.apiFilter(ctx, filter) +} + +// applyKeywordChanges applies the provided changes to the filter's keywords in place, +// and returns a list of lists of filter columns to update, and a list of filter keyword IDs to delete. +func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.FilterKeywordCreateUpdateDeleteRequest) ([][]string, []string, gtserror.WithCode) { + if len(formKeywords) == 0 { + // Detach currently existing keywords from the filter so we don't change them. + filter.Keywords = nil + return nil, nil, nil + } + + filterKeywordColumns := [][]string{} + deleteFilterKeywordIDs := []string{} + filterKeywordsByID := map[string]*gtsmodel.FilterKeyword{} + for _, filterKeyword := range filter.Keywords { + filterKeywordsByID[filterKeyword.ID] = filterKeyword + } + + for i, formKeyword := range formKeywords { + filterKeywordColumns = append(filterKeywordColumns, nil) + + if formKeyword.ID != nil { + id := *formKeyword.ID + filterKeyword, ok := filterKeywordsByID[id] + if !ok { + return nil, nil, gtserror.NewErrorNotFound( + fmt.Errorf("couldn't find filter keyword '%s' to update or delete", id), + ) + } + + // Process deletes. + if *formKeyword.Destroy { + delete(filterKeywordsByID, id) + deleteFilterKeywordIDs = append(deleteFilterKeywordIDs, id) + continue + } + + // Process updates. + if formKeyword.Keyword != nil { + filterKeywordColumns[i] = append(filterKeywordColumns[i], "keyword") + filterKeyword.Keyword = *formKeyword.Keyword + } + if formKeyword.WholeWord != nil { + filterKeywordColumns[i] = append(filterKeywordColumns[i], "whole_word") + filterKeyword.WholeWord = formKeyword.WholeWord + } + continue + } + + // Process creates. + filterKeyword := >smodel.FilterKeyword{ + ID: id.NewULID(), + AccountID: filter.AccountID, + FilterID: filter.ID, + Filter: filter, + Keyword: *formKeyword.Keyword, + WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)), + } + filterKeywordsByID[filterKeyword.ID] = filterKeyword + } + + // Replace the filter's keywords list with our updated version. + filter.Keywords = nil + for _, filterKeyword := range filterKeywordsByID { + filter.Keywords = append(filter.Keywords, filterKeyword) + } + + return filterKeywordColumns, deleteFilterKeywordIDs, nil +} + +// applyKeywordChanges applies the provided changes to the filter's keywords in place, +// and returns a list of filter status IDs to delete. +func applyStatusChanges(filter *gtsmodel.Filter, formStatuses []apimodel.FilterStatusCreateDeleteRequest) ([]string, gtserror.WithCode) { + if len(formStatuses) == 0 { + // Detach currently existing statuses from the filter so we don't change them. + filter.Statuses = nil + return nil, nil + } + + deleteFilterStatusIDs := []string{} + filterStatusesByID := map[string]*gtsmodel.FilterStatus{} + for _, filterStatus := range filter.Statuses { + filterStatusesByID[filterStatus.ID] = filterStatus + } + + for _, formStatus := range formStatuses { + if formStatus.ID != nil { + id := *formStatus.ID + _, ok := filterStatusesByID[id] + if !ok { + return nil, gtserror.NewErrorNotFound( + fmt.Errorf("couldn't find filter status '%s' to delete", id), + ) + } + + // Process deletes. + if *formStatus.Destroy { + delete(filterStatusesByID, id) + deleteFilterStatusIDs = append(deleteFilterStatusIDs, id) + continue + } + + // Filter statuses don't have updates. + continue + } + + // Process creates. + filterStatus := >smodel.FilterStatus{ + ID: id.NewULID(), + AccountID: filter.AccountID, + FilterID: filter.ID, + Filter: filter, + StatusID: *formStatus.StatusID, + } + filterStatusesByID[filterStatus.ID] = filterStatus + } + + // Replace the filter's keywords list with our updated version. + filter.Statuses = nil + for _, filterStatus := range filterStatusesByID { + filter.Statuses = append(filter.Statuses, filterStatus) + } + + return deleteFilterStatusIDs, nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 4aaa94fb7f..8a18bc45e7 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 3bb0933f32..f194770df6 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 7a55722676..7bb9ba20c5 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1862,19 +1862,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{ @@ -1925,6 +1918,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 3839173d18..e8ec3380ba 100644 --- a/internal/validate/formvalidation.go +++ b/internal/validate/formvalidation.go @@ -243,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, @@ -363,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 9ebd400e40..f0d2a2f4ee 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -3288,6 +3288,16 @@ 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_2_filter_1": { ID: "01HNGFYJBED9FS0VWRVMY4TKXH", CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), @@ -3330,6 +3340,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 +3362,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. From 2edda5ce95d10fff032669b5f767b94c1779bdf7 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Wed, 22 May 2024 11:33:02 -0700 Subject: [PATCH 09/16] Fix lint and tests --- .../api/client/filters/v2/filterkeywordput_test.go | 12 ++++++------ internal/api/client/filters/v2/filterput.go | 9 +++++---- internal/api/client/filters/v2/filterstatuspost.go | 6 +----- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/internal/api/client/filters/v2/filterkeywordput_test.go b/internal/api/client/filters/v2/filterkeywordput_test.go index 0f397b7534..55253066d2 100644 --- a/internal/api/client/filters/v2/filterkeywordput_test.go +++ b/internal/api/client/filters/v2/filterkeywordput_test.go @@ -107,7 +107,7 @@ func (suite *FiltersTestSuite) putFilterKeyword( } func (suite *FiltersTestSuite) TestPutFilterKeywordFull() { - filterKeywordID := suite.testFilters["local_account_1_filter_1_keyword_1"].ID + 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, "") @@ -120,7 +120,7 @@ func (suite *FiltersTestSuite) TestPutFilterKeywordFull() { } func (suite *FiltersTestSuite) TestPutFilterKeywordFullJSON() { - filterKeywordID := suite.testFilters["local_account_1_filter_1_keyword_1"].ID + filterKeywordID := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID requestJson := `{ "keyword": "fnords", "whole_word": true @@ -135,7 +135,7 @@ func (suite *FiltersTestSuite) TestPutFilterKeywordFullJSON() { } func (suite *FiltersTestSuite) TestPutFilterKeywordMinimal() { - filterKeywordID := suite.testFilters["local_account_1_filter_1_keyword_1"].ID + 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 { @@ -147,7 +147,7 @@ func (suite *FiltersTestSuite) TestPutFilterKeywordMinimal() { } func (suite *FiltersTestSuite) TestPutFilterKeywordEmptyKeyword() { - filterKeywordID := suite.testFilters["local_account_1_filter_1_keyword_1"].ID + 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 { @@ -156,7 +156,7 @@ func (suite *FiltersTestSuite) TestPutFilterKeywordEmptyKeyword() { } func (suite *FiltersTestSuite) TestPutFilterKeywordMissingKeyword() { - filterKeywordID := suite.testFilters["local_account_1_filter_1_keyword_1"].ID + 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()) @@ -174,7 +174,7 @@ func (suite *FiltersTestSuite) TestPutFilterKeywordKeywordConflict() { } func (suite *FiltersTestSuite) TestPutFilterKeywordAnotherAccountsFilterKeyword() { - filterKeywordID := suite.testFilters["local_account_2_filter_1_keyword_1"].ID + 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 { diff --git a/internal/api/client/filters/v2/filterput.go b/internal/api/client/filters/v2/filterput.go index b8a4da07d7..29b55c2b0c 100644 --- a/internal/api/client/filters/v2/filterput.go +++ b/internal/api/client/filters/v2/filterput.go @@ -306,12 +306,13 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error { destroy := util.PtrValueOr(formStatus.Destroy, false) form.Statuses[i].Destroy = &destroy - if destroy && formStatus.ID == nil { + switch { + case destroy && formStatus.ID == nil: return errors.New("can't delete a filter status without an ID") - } else if formStatus.ID != nil { + case formStatus.ID != nil: return errors.New("filter status IDs here can only be used to delete them") - } else if formStatus.StatusID == nil { - return errors.New("can't create a filter keyword without a status ID") + case formStatus.StatusID == nil: + return errors.New("can't create a filter status without a status ID") } } diff --git a/internal/api/client/filters/v2/filterstatuspost.go b/internal/api/client/filters/v2/filterstatuspost.go index 8a0efee85d..2a763197d0 100644 --- a/internal/api/client/filters/v2/filterstatuspost.go +++ b/internal/api/client/filters/v2/filterstatuspost.go @@ -129,9 +129,5 @@ func (m *Module) FilterStatusPOSTHandler(c *gin.Context) { } func validateCreateFilterStatus(form *apimodel.FilterStatusCreateRequest) error { - if err := validate.ULID(form.StatusID, "status_id"); err != nil { - return err - } - - return nil + return validate.ULID(form.StatusID, "status_id") } From 745496cf52cb90b26dcf72d2bc8c6c04c90aee43 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Mon, 27 May 2024 15:27:22 -0700 Subject: [PATCH 10/16] Update Swagger spec --- docs/api/swagger.yaml | 593 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 591 insertions(+), 2 deletions(-) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 8bd43ae8e1..3f3a3fd08f 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 @@ -5941,6 +5941,7 @@ paths: Sample: fnord in: formData maxLength: 40 + minLength: 1 name: phrase required: true type: string @@ -6001,6 +6002,8 @@ paths: description: not found "406": description: not acceptable + "409": + description: conflict (duplicate keyword) "422": description: unprocessable content "500": @@ -6015,7 +6018,7 @@ paths: delete: operationId: filterV1Delete parameters: - - description: ID of the list + - description: ID of the filter in: path name: id required: true @@ -6090,6 +6093,7 @@ paths: Sample: fnord in: formData maxLength: 40 + minLength: 1 name: phrase required: true type: string @@ -6150,6 +6154,8 @@ paths: description: not found "406": description: not acceptable + "409": + description: conflict (duplicate keyword) "422": description: unprocessable content "500": @@ -8675,6 +8681,589 @@ 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 + - collectionFormat: multi + description: Keywords to be added (if not using id param) or updated (if using id param). + in: query + items: + type: string + name: keywords_attributes[][keyword] + type: array + - collectionFormat: multi + description: Should each keyword consider word boundaries? + in: query + items: + type: bool + name: keywords_attributes[][whole_word] + type: array + - collectionFormat: multi + description: Statuses to be added to the filter. + in: query + items: + type: string + name: statuses_attributes[][status_id] + type: array + 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: Keywords to be added to the created filter. + in: query + items: + type: string + name: keywords_attributes[][keyword] + type: array + - collectionFormat: multi + description: Should each keyword consider word boundaries? + in: query + items: + type: bool + name: keywords_attributes[][whole_word] + type: array + - collectionFormat: multi + description: Statuses to be added to the newly created filter. + in: query + items: + type: string + name: statuses_attributes[][status_id] + type: array + - 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 From a46ef79bb887f905b5f7348c33c9273f3474972b Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Mon, 27 May 2024 15:47:45 -0700 Subject: [PATCH 11/16] Fix filter update test --- internal/db/bundb/filter_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/db/bundb/filter_test.go b/internal/db/bundb/filter_test.go index 7940b6651d..d1249d16bb 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) From f91a7f29ad7af6f8b4256a7fb1d384203be788c9 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Mon, 27 May 2024 16:20:20 -0700 Subject: [PATCH 12/16] Update Swagger spec *correctly* --- docs/api/swagger.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 3f3a3fd08f..4fb260a00a 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -8765,21 +8765,21 @@ paths: type: string - collectionFormat: multi description: Keywords to be added (if not using id param) or updated (if using id param). - in: query + in: formData items: type: string name: keywords_attributes[][keyword] type: array - collectionFormat: multi description: Should each keyword consider word boundaries? - in: query + in: formData items: - type: bool + type: boolean name: keywords_attributes[][whole_word] type: array - collectionFormat: multi description: Statuses to be added to the filter. - in: query + in: formData items: type: string name: statuses_attributes[][status_id] @@ -8899,21 +8899,21 @@ paths: type: string - collectionFormat: multi description: Keywords to be added to the created filter. - in: query + in: formData items: type: string name: keywords_attributes[][keyword] type: array - collectionFormat: multi description: Should each keyword consider word boundaries? - in: query + in: formData items: - type: bool + type: boolean name: keywords_attributes[][whole_word] type: array - collectionFormat: multi description: Statuses to be added to the newly created filter. - in: query + in: formData items: type: string name: statuses_attributes[][status_id] From fd624856e54dc63494016c2e3c77fb2e8316c882 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Mon, 27 May 2024 16:55:57 -0700 Subject: [PATCH 13/16] Update actual files Swagger spec was generated from --- internal/api/client/filters/v2/filterpost.go | 8 ++++---- internal/api/client/filters/v2/filterput.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go index e87c2ae7f0..8536150f8d 100644 --- a/internal/api/client/filters/v2/filterpost.go +++ b/internal/api/client/filters/v2/filterpost.go @@ -102,27 +102,27 @@ import ( // default: warn // - // name: keywords_attributes[][keyword] +// in: formData // type: array // items: // type: string // description: Keywords to be added (if not using id param) or updated (if using id param). -// in: query // collectionFormat: multi // - // name: keywords_attributes[][whole_word] +// in: formData // type: array // items: -// type: bool +// type: boolean // description: Should each keyword consider word boundaries? -// in: query // collectionFormat: multi // - // name: statuses_attributes[][status_id] +// in: formData // type: array // items: // type: string // description: Statuses to be added to the filter. -// in: query // collectionFormat: multi // // security: diff --git a/internal/api/client/filters/v2/filterput.go b/internal/api/client/filters/v2/filterput.go index 29b55c2b0c..8c4e937a81 100644 --- a/internal/api/client/filters/v2/filterput.go +++ b/internal/api/client/filters/v2/filterput.go @@ -70,27 +70,27 @@ import ( // maxLength: 200 // - // name: keywords_attributes[][keyword] +// in: formData // type: array // items: // type: string // description: Keywords to be added to the created filter. -// in: query // collectionFormat: multi // - // name: keywords_attributes[][whole_word] +// in: formData // type: array // items: -// type: bool +// type: boolean // description: Should each keyword consider word boundaries? -// in: query // collectionFormat: multi // - // name: statuses_attributes[][status_id] +// in: formData // type: array // items: // type: string // description: Statuses to be added to the newly created filter. -// in: query // collectionFormat: multi // - // name: context[] From 6eaf3666b9d07c26cae045ed374580d564151bbd Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Tue, 28 May 2024 11:05:09 -0700 Subject: [PATCH 14/16] Remove keywords_attributes and statuses_attributes --- docs/api/swagger.yaml | 42 ----- internal/api/client/filters/v2/filterpost.go | 61 -------- .../api/client/filters/v2/filterpost_test.go | 104 ++----------- internal/api/client/filters/v2/filterput.go | 114 -------------- .../api/client/filters/v2/filterput_test.go | 134 ++-------------- internal/api/model/filterv2.go | 69 +------- internal/processing/filters/v2/create.go | 23 --- internal/processing/filters/v2/update.go | 147 ++---------------- 8 files changed, 42 insertions(+), 652 deletions(-) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 4fb260a00a..fc25442cf7 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -8763,27 +8763,6 @@ paths: in: formData name: filter_action type: string - - collectionFormat: multi - description: Keywords to be added (if not using id param) or updated (if using id param). - in: formData - items: - type: string - name: keywords_attributes[][keyword] - type: array - - collectionFormat: multi - description: Should each keyword consider word boundaries? - in: formData - items: - type: boolean - name: keywords_attributes[][whole_word] - type: array - - collectionFormat: multi - description: Statuses to be added to the filter. - in: formData - items: - type: string - name: statuses_attributes[][status_id] - type: array produces: - application/json responses: @@ -8897,27 +8876,6 @@ paths: name: title required: true type: string - - collectionFormat: multi - description: Keywords to be added to the created filter. - in: formData - items: - type: string - name: keywords_attributes[][keyword] - type: array - - collectionFormat: multi - description: Should each keyword consider word boundaries? - in: formData - items: - type: boolean - name: keywords_attributes[][whole_word] - type: array - - collectionFormat: multi - description: Statuses to be added to the newly created filter. - in: formData - items: - type: string - name: statuses_attributes[][status_id] - type: array - collectionFormat: multi description: |- The contexts in which the filter should be applied. diff --git a/internal/api/client/filters/v2/filterpost.go b/internal/api/client/filters/v2/filterpost.go index 8536150f8d..cbe499fa67 100644 --- a/internal/api/client/filters/v2/filterpost.go +++ b/internal/api/client/filters/v2/filterpost.go @@ -100,30 +100,6 @@ import ( // - warn // - hide // default: warn -// - -// name: keywords_attributes[][keyword] -// in: formData -// type: array -// items: -// type: string -// description: Keywords to be added (if not using id param) or updated (if using id param). -// collectionFormat: multi -// - -// name: keywords_attributes[][whole_word] -// in: formData -// type: array -// items: -// type: boolean -// description: Should each keyword consider word boundaries? -// collectionFormat: multi -// - -// name: statuses_attributes[][status_id] -// in: formData -// type: array -// items: -// type: string -// description: Statuses to be added to the filter. -// collectionFormat: multi // // security: // - OAuth2 Bearer: @@ -198,30 +174,6 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error { return err } - // Parse form variant of normal filter keyword creation structs. - if len(form.KeywordsAttributesKeyword) > 0 { - form.Keywords = make([]apimodel.FilterKeywordCreateUpdateRequest, 0, len(form.KeywordsAttributesKeyword)) - for i, keyword := range form.KeywordsAttributesKeyword { - formKeyword := apimodel.FilterKeywordCreateUpdateRequest{ - Keyword: keyword, - } - if i < len(form.KeywordsAttributesWholeWord) { - formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i] - } - form.Keywords = append(form.Keywords, formKeyword) - } - } - - // Parse form variant of normal filter status creation structs. - if len(form.StatusesAttributesStatusID) > 0 { - form.Statuses = make([]apimodel.FilterStatusCreateRequest, 0, len(form.StatusesAttributesStatusID)) - for _, statusID := range form.StatusesAttributesStatusID { - form.Statuses = append(form.Statuses, apimodel.FilterStatusCreateRequest{ - StatusID: statusID, - }) - } - } - // Apply defaults for missing fields. form.FilterAction = util.Ptr(action) @@ -246,18 +198,5 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error { } } - // Normalize and validate new keywords and statuses. - for i, formKeyword := range form.Keywords { - if err := validate.FilterKeyword(formKeyword.Keyword); err != nil { - return err - } - form.Keywords[i].WholeWord = util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)) - } - for _, formStatus := range form.Statuses { - if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil { - return err - } - } - return nil } diff --git a/internal/api/client/filters/v2/filterpost_test.go b/internal/api/client/filters/v2/filterpost_test.go index c7787173c6..cad803895d 100644 --- a/internal/api/client/filters/v2/filterpost_test.go +++ b/internal/api/client/filters/v2/filterpost_test.go @@ -23,7 +23,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "slices" "strconv" "strings" @@ -35,7 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/testrig" ) -func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, statusesAttributesStatusID *[]string, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) { +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) @@ -64,19 +63,6 @@ func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, acti if expiresIn != nil { ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)} } - if keywordsAttributesKeyword != nil { - ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword - } - if keywordsAttributesWholeWord != nil { - formatted := []string{} - for _, value := range *keywordsAttributesWholeWord { - formatted = append(formatted, strconv.FormatBool(value)) - } - ctx.Request.Form["keywords_attributes[][whole_word]"] = formatted - } - if statusesAttributesStatusID != nil { - ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID - } } // trigger the handler @@ -122,12 +108,7 @@ func (suite *FiltersTestSuite) TestPostFilterFull() { context := []string{"home", "public"} action := "warn" expiresIn := 86400 - // Checked in lexical order by keyword, so keep this sorted. - keywordsAttributesKeyword := []string{"GNU", "Linux"} - keywordsAttributesWholeWord := []bool{true, false} - // Checked in lexical order by status ID, so keep this sorted. - statusAttributesStatusID := []string{"01HEN2QRFA8H3C6QPN7RD4KSR6", "01HEWV37MHV8BAC8ANFGVRRM5D"} - filter, err := suite.postFilter(&title, &context, &action, &expiresIn, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &statusAttributesStatusID, nil, http.StatusOK, "") + filter, err := suite.postFilter(&title, &context, &action, &expiresIn, nil, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -142,25 +123,8 @@ func (suite *FiltersTestSuite) TestPostFilterFull() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - - if suite.Len(filter.Keywords, len(keywordsAttributesKeyword)) { - slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { - return strings.Compare(lhs.Keyword, rhs.Keyword) - }) - for i, filterKeyword := range filter.Keywords { - suite.Equal(keywordsAttributesKeyword[i], filterKeyword.Keyword) - suite.Equal(keywordsAttributesWholeWord[i], filterKeyword.WholeWord) - } - } - - if suite.Len(filter.Statuses, len(statusAttributesStatusID)) { - slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { - return strings.Compare(lhs.StatusID, rhs.StatusID) - }) - for i, filterStatus := range filter.Statuses { - suite.Equal(statusAttributesStatusID[i], filterStatus.StatusID) - } - } + suite.Empty(filter.Keywords) + suite.Empty(filter.Statuses) } func (suite *FiltersTestSuite) TestPostFilterFullJSON() { @@ -170,27 +134,9 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() { "context": ["home", "public"], "filter_action": "warn", "whole_word": true, - "expires_in": 86400.1, - "keywords_attributes": [ - { - "keyword": "GNU", - "whole_word": true - }, - { - "keyword": "Linux", - "whole_word": false - } - ], - "statuses_attributes": [ - { - "status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6" - }, - { - "status_id": "01HEWV37MHV8BAC8ANFGVRRM5D" - } - ] + "expires_in": 86400.1 }` - filter, err := suite.postFilter(nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "") + filter, err := suite.postFilter(nil, nil, nil, nil, &requestJson, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -207,34 +153,14 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - - if suite.Len(filter.Keywords, 2) { - slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { - return strings.Compare(lhs.Keyword, rhs.Keyword) - }) - - suite.Equal("GNU", filter.Keywords[0].Keyword) - suite.True(filter.Keywords[0].WholeWord) - - suite.Equal("Linux", filter.Keywords[1].Keyword) - suite.False(filter.Keywords[1].WholeWord) - } - - if suite.Len(filter.Statuses, 2) { - slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { - return strings.Compare(lhs.StatusID, rhs.StatusID) - }) - - suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID) - - suite.Equal("01HEWV37MHV8BAC8ANFGVRRM5D", filter.Statuses[1].StatusID) - } + 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, nil, nil, nil, http.StatusOK, "") + filter, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -247,12 +173,14 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() { 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, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -260,7 +188,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() { func (suite *FiltersTestSuite) TestPostFilterMissingTitle() { context := []string{"home"} - _, err := suite.postFilter(nil, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(nil, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -269,7 +197,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingTitle() { func (suite *FiltersTestSuite) TestPostFilterEmptyContext() { title := "GNU/Linux" context := []string{} - _, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -277,7 +205,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyContext() { func (suite *FiltersTestSuite) TestPostFilterMissingContext() { title := "GNU/Linux" - _, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -286,7 +214,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingContext() { // 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, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, 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 index 8c4e937a81..e24ec0b4dc 100644 --- a/internal/api/client/filters/v2/filterput.go +++ b/internal/api/client/filters/v2/filterput.go @@ -18,7 +18,6 @@ package v2 import ( - "errors" "fmt" "net/http" "strconv" @@ -69,30 +68,6 @@ import ( // minLength: 1 // maxLength: 200 // - -// name: keywords_attributes[][keyword] -// in: formData -// type: array -// items: -// type: string -// description: Keywords to be added to the created filter. -// collectionFormat: multi -// - -// name: keywords_attributes[][whole_word] -// in: formData -// type: array -// items: -// type: boolean -// description: Should each keyword consider word boundaries? -// collectionFormat: multi -// - -// name: statuses_attributes[][status_id] -// in: formData -// type: array -// items: -// type: string -// description: Statuses to be added to the newly created filter. -// collectionFormat: multi -// - // name: context[] // in: formData // required: true @@ -206,58 +181,6 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error { } } - // Parse form variant of normal filter keyword update structs. - // All filter keyword update struct fields are optional. - numFormKeywords := max( - len(form.KeywordsAttributesID), - len(form.KeywordsAttributesKeyword), - len(form.KeywordsAttributesWholeWord), - len(form.KeywordsAttributesDestroy), - ) - if numFormKeywords > 0 { - form.Keywords = make([]apimodel.FilterKeywordCreateUpdateDeleteRequest, 0, numFormKeywords) - for i := 0; i < numFormKeywords; i++ { - formKeyword := apimodel.FilterKeywordCreateUpdateDeleteRequest{} - if i < len(form.KeywordsAttributesID) && form.KeywordsAttributesID[i] != "" { - formKeyword.ID = &form.KeywordsAttributesID[i] - } - if i < len(form.KeywordsAttributesKeyword) && form.KeywordsAttributesKeyword[i] != "" { - formKeyword.Keyword = &form.KeywordsAttributesKeyword[i] - } - if i < len(form.KeywordsAttributesWholeWord) { - formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i] - } - if i < len(form.KeywordsAttributesDestroy) { - formKeyword.Destroy = &form.KeywordsAttributesDestroy[i] - } - form.Keywords = append(form.Keywords, formKeyword) - } - } - - // Parse form variant of normal filter status update structs. - // All filter status update struct fields are optional. - numFormStatuses := max( - len(form.StatusesAttributesID), - len(form.StatusesAttributesStatusID), - len(form.StatusesAttributesDestroy), - ) - if numFormStatuses > 0 { - form.Statuses = make([]apimodel.FilterStatusCreateDeleteRequest, 0, numFormStatuses) - for i := 0; i < numFormStatuses; i++ { - formStatus := apimodel.FilterStatusCreateDeleteRequest{} - if i < len(form.StatusesAttributesID) && form.StatusesAttributesID[i] != "" { - formStatus.ID = &form.StatusesAttributesID[i] - } - if i < len(form.StatusesAttributesStatusID) && form.StatusesAttributesStatusID[i] != "" { - formStatus.StatusID = &form.StatusesAttributesStatusID[i] - } - if i < len(form.StatusesAttributesDestroy) { - formStatus.Destroy = &form.StatusesAttributesDestroy[i] - } - form.Statuses = append(form.Statuses, formStatus) - } - } - // Normalize filter expiry if necessary. // If we parsed this as JSON, expires_in // may be either a float64 or a string. @@ -279,42 +202,5 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error { } } - // Normalize and validate updates. - for i, formKeyword := range form.Keywords { - if formKeyword.Keyword != nil { - if err := validate.FilterKeyword(*formKeyword.Keyword); err != nil { - return err - } - } - - destroy := util.PtrValueOr(formKeyword.Destroy, false) - form.Keywords[i].Destroy = &destroy - - if destroy && formKeyword.ID == nil { - return errors.New("can't delete a filter keyword without an ID") - } else if formKeyword.ID == nil && formKeyword.Keyword == nil { - return errors.New("can't create a filter keyword without a keyword") - } - } - for i, formStatus := range form.Statuses { - if formStatus.StatusID != nil { - if err := validate.ULID(*formStatus.StatusID, "status_id"); err != nil { - return err - } - } - - destroy := util.PtrValueOr(formStatus.Destroy, false) - form.Statuses[i].Destroy = &destroy - - switch { - case destroy && formStatus.ID == nil: - return errors.New("can't delete a filter status without an ID") - case formStatus.ID != nil: - return errors.New("filter status IDs here can only be used to delete them") - case formStatus.StatusID == nil: - return errors.New("can't create a filter status without a status ID") - } - } - return nil } diff --git a/internal/api/client/filters/v2/filterput_test.go b/internal/api/client/filters/v2/filterput_test.go index b159860db5..8b4576abe7 100644 --- a/internal/api/client/filters/v2/filterput_test.go +++ b/internal/api/client/filters/v2/filterput_test.go @@ -23,7 +23,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "slices" "strconv" "strings" @@ -35,7 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/testrig" ) -func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesID *[]string, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, keywordsAttributesDestroy *[]bool, statusesAttributesID *[]string, statusesAttributesStatusID *[]string, statusesAttributesDestroy *[]bool, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) { +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) @@ -64,39 +63,6 @@ func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context if expiresIn != nil { ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)} } - if keywordsAttributesID != nil { - ctx.Request.Form["keywords_attributes[][id]"] = *keywordsAttributesID - } - if keywordsAttributesKeyword != nil { - ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword - } - if keywordsAttributesWholeWord != nil { - formatted := []string{} - for _, value := range *keywordsAttributesWholeWord { - formatted = append(formatted, strconv.FormatBool(value)) - } - ctx.Request.Form["keywords_attributes[][whole_word]"] = formatted - } - if keywordsAttributesWholeWord != nil { - formatted := []string{} - for _, value := range *keywordsAttributesDestroy { - formatted = append(formatted, strconv.FormatBool(value)) - } - ctx.Request.Form["keywords_attributes[][_destroy]"] = formatted - } - if statusesAttributesID != nil { - ctx.Request.Form["statuses_attributes[][id]"] = *statusesAttributesID - } - if statusesAttributesStatusID != nil { - ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID - } - if statusesAttributesDestroy != nil { - formatted := []string{} - for _, value := range *statusesAttributesDestroy { - formatted = append(formatted, strconv.FormatBool(value)) - } - ctx.Request.Form["statuses_attributes[][_destroy]"] = formatted - } } ctx.AddParam("id", filterID) @@ -145,18 +111,7 @@ func (suite *FiltersTestSuite) TestPutFilterFull() { context := []string{"home", "public"} action := "hide" expiresIn := 86400 - // Tests attributes arrays that aren't the same length, just in case. - keywordsAttributesID := []string{ - suite.testFilterKeywords["local_account_1_filter_2_keyword_1"].ID, - suite.testFilterKeywords["local_account_1_filter_2_keyword_2"].ID, - } - keywordsAttributesKeyword := []string{"fū", "", "blah"} - // If using the form version of this API, you have to always set whole_word to the previous value for that keyword; - // there's no way to represent a nullable boolean in it. - keywordsAttributesWholeWord := []bool{true, false, true} - keywordsAttributesDestroy := []bool{false, true} - statusesAttributesStatusID := []string{suite.testStatuses["remote_account_1_status_2"].ID} - filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, &keywordsAttributesID, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &keywordsAttributesDestroy, nil, &statusesAttributesStatusID, nil, nil, http.StatusOK, "") + filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, nil, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -171,29 +126,8 @@ func (suite *FiltersTestSuite) TestPutFilterFull() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - - if suite.Len(filter.Keywords, 3) { - slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { - return strings.Compare(lhs.ID, rhs.ID) - }) - - suite.Equal("fū", filter.Keywords[0].Keyword) - suite.True(filter.Keywords[0].WholeWord) - - suite.Equal("quux", filter.Keywords[1].Keyword) - suite.True(filter.Keywords[1].WholeWord) - - suite.Equal("blah", filter.Keywords[2].Keyword) - suite.True(filter.Keywords[1].WholeWord) - } - - if suite.Len(filter.Statuses, 1) { - slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { - return strings.Compare(lhs.ID, rhs.ID) - }) - - suite.Equal(suite.testStatuses["remote_account_1_status_2"].ID, filter.Statuses[0].StatusID) - } + suite.Len(filter.Keywords, 3) + suite.Len(filter.Statuses, 0) } func (suite *FiltersTestSuite) TestPutFilterFullJSON() { @@ -203,28 +137,9 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() { "title": "messy synoptic varblabbles", "context": ["home", "public"], "filter_action": "hide", - "expires_in": 86400.1, - "keywords_attributes": [ - { - "id": "01HN277Y11ENG4EC1ERMAC9FH4", - "keyword": "fū" - }, - { - "id": "01HN278494N88BA2FY4DZ5JTNS", - "_destroy": true - }, - { - "keyword": "blah", - "whole_word": true - } - ], - "statuses_attributes": [ - { - "status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6" - } - ] + "expires_in": 86400.1 }` - filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "") + filter, err := suite.putFilter(id, nil, nil, nil, nil, &requestJson, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -241,36 +156,15 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - - if suite.Len(filter.Keywords, 3) { - slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int { - return strings.Compare(lhs.ID, rhs.ID) - }) - - suite.Equal("fū", filter.Keywords[0].Keyword) - suite.True(filter.Keywords[0].WholeWord) - - suite.Equal("quux", filter.Keywords[1].Keyword) - suite.True(filter.Keywords[1].WholeWord) - - suite.Equal("blah", filter.Keywords[2].Keyword) - suite.True(filter.Keywords[1].WholeWord) - } - - if suite.Len(filter.Statuses, 1) { - slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int { - return strings.Compare(lhs.ID, rhs.ID) - }) - - suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID) - } + 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, nil, nil, nil, nil, nil, nil, nil, http.StatusOK, "") + filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -289,7 +183,7 @@ 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, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`) + _, 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()) } @@ -299,7 +193,7 @@ 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, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`) + _, 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()) } @@ -309,7 +203,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() { 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, nil, nil, nil, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this 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()) } @@ -319,7 +213,7 @@ 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, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -329,7 +223,7 @@ func (suite *FiltersTestSuite) TestPutNonexistentFilter() { id := "not_even_a_real_ULID" phrase := "GNU/Linux" context := []string{"home"} - _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + _, 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/model/filterv2.go b/internal/api/model/filterv2.go index 242c569dc5..51dabacb27 100644 --- a/internal/api/model/filterv2.go +++ b/internal/api/model/filterv2.go @@ -135,21 +135,9 @@ type FilterCreateRequestV2 struct { // // Example: 86400 ExpiresInI interface{} `json:"expires_in"` - - // Keywords to be added to the newly created filter. - Keywords []FilterKeywordCreateUpdateRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"` - // Form data version of Keywords[].Keyword. - KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"` - // Form data version of Keywords[].WholeWord. - KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"` - - // Statuses to be added to the newly created filter. - Statuses []FilterStatusCreateRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"` - // Form data version of Statuses[].StatusID. - StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"` } -// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword while creating a v2 filter or as a standalone operation. +// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword. // // swagger:ignore type FilterKeywordCreateUpdateRequest struct { @@ -164,7 +152,7 @@ type FilterKeywordCreateUpdateRequest struct { WholeWord *bool `form:"whole_word" json:"whole_word" xml:"whole_word"` } -// FilterStatusCreateRequest captures params for a status while creating a v2 filter or filter status. +// FilterStatusCreateRequest captures params for creating a filter status. // // swagger:ignore type FilterStatusCreateRequest struct { @@ -200,57 +188,4 @@ type FilterUpdateRequestV2 struct { // // Example: 86400 ExpiresInI interface{} `json:"expires_in"` - - // Keywords to be added to the filter, modified, or removed. - Keywords []FilterKeywordCreateUpdateDeleteRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"` - // Form data version of Keywords[].ID. - KeywordsAttributesID []string `form:"keywords_attributes[][id]" json:"-" xml:"-"` - // Form data version of Keywords[].Keyword. - KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"` - // Form data version of Keywords[].WholeWord. - KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"` - // Form data version of Keywords[].Destroy. - KeywordsAttributesDestroy []bool `form:"keywords_attributes[][_destroy]" json:"-" xml:"-"` - - // Statuses to be added to the filter, or removed. - Statuses []FilterStatusCreateDeleteRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"` - // Form data version of Statuses[].ID. - StatusesAttributesID []string `form:"statuses_attributes[][id]" json:"-" xml:"-"` - // Form data version of Statuses[].ID. - StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"` - // Form data version of Statuses[].Destroy. - StatusesAttributesDestroy []bool `form:"statuses_attributes[][_destroy]" json:"-" xml:"-"` -} - -// FilterKeywordCreateUpdateDeleteRequest captures params for creating, updating, or deleting a keyword while updating a v2 filter. -// -// swagger:ignore -type FilterKeywordCreateUpdateDeleteRequest struct { - // The ID of the filter keyword entry in the database. - // Optional: use to modify or delete an existing keyword instead of adding a new one. - ID *string `json:"id" xml:"id"` - // The text to be filtered. - // - // Example: fnord - // Maximum length: 40 - Keyword *string `json:"keyword" xml:"keyword"` - // Should the filter keyword consider word boundaries? - // - // Example: true - WholeWord *bool `json:"whole_word" xml:"whole_word"` - // Remove this filter keyword. Requires an ID. - Destroy *bool `json:"_destroy" xml:"_destroy"` -} - -// FilterStatusCreateDeleteRequest captures params for creating or deleting a status while updating a v2 filter. -// -// swagger:ignore -type FilterStatusCreateDeleteRequest struct { - // The ID of the filter status entry in the database. - // Optional: use to delete an existing status instead of adding a new one. - ID *string `json:"id" xml:"id"` - // The status ID to be filtered. - StatusID *string `json:"status_id" xml:"status_id"` - // Remove this filter status. Requires an ID. - Destroy *bool `json:"_destroy" xml:"_destroy"` } diff --git a/internal/processing/filters/v2/create.go b/internal/processing/filters/v2/create.go index dd06002f45..c7b500e9eb 100644 --- a/internal/processing/filters/v2/create.go +++ b/internal/processing/filters/v2/create.go @@ -63,29 +63,6 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form } } - for _, formKeyword := range form.Keywords { - filterKeyword := >smodel.FilterKeyword{ - ID: id.NewULID(), - AccountID: account.ID, - FilterID: filter.ID, - Filter: filter, - Keyword: formKeyword.Keyword, - WholeWord: formKeyword.WholeWord, - } - filter.Keywords = append(filter.Keywords, filterKeyword) - } - - for _, formStatus := range form.Statuses { - filterStatus := >smodel.FilterStatus{ - ID: id.NewULID(), - AccountID: account.ID, - FilterID: filter.ID, - Filter: filter, - StatusID: formStatus.StatusID, - } - filter.Statuses = append(filter.Statuses, filterStatus) - } - if err := p.state.DB.PutFilter(ctx, filter); err != nil { if errors.Is(err, db.ErrAlreadyExists) { err = errors.New("duplicate title, keyword, or status") diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go index 603f2a1615..f06f2a8638 100644 --- a/internal/processing/filters/v2/update.go +++ b/internal/processing/filters/v2/update.go @@ -27,7 +27,6 @@ import ( "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" ) @@ -40,8 +39,6 @@ func (p *Processor) Update( filterID string, form *apimodel.FilterUpdateRequestV2, ) (*apimodel.FilterV2, gtserror.WithCode) { - var errWithCode gtserror.WithCode - // Get the filter by ID, with existing keywords and statuses. filter, err := p.state.DB.GetFilterByID(ctx, filterID) if err != nil { @@ -104,17 +101,13 @@ func (p *Processor) Update( } } - filterKeywordColumns, deleteFilterKeywordIDs, errWithCode := applyKeywordChanges(filter, form.Keywords) - if err != nil { - return nil, errWithCode - } - - deleteFilterStatusIDs, errWithCode := applyStatusChanges(filter, form.Statuses) - if err != nil { - return nil, errWithCode - } + // 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, filterKeywordColumns, deleteFilterKeywordIDs, deleteFilterStatusIDs); err != 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()) @@ -122,129 +115,9 @@ func (p *Processor) Update( return nil, gtserror.NewErrorInternalError(err) } - return p.apiFilter(ctx, filter) -} - -// applyKeywordChanges applies the provided changes to the filter's keywords in place, -// and returns a list of lists of filter columns to update, and a list of filter keyword IDs to delete. -func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.FilterKeywordCreateUpdateDeleteRequest) ([][]string, []string, gtserror.WithCode) { - if len(formKeywords) == 0 { - // Detach currently existing keywords from the filter so we don't change them. - filter.Keywords = nil - return nil, nil, nil - } - - filterKeywordColumns := [][]string{} - deleteFilterKeywordIDs := []string{} - filterKeywordsByID := map[string]*gtsmodel.FilterKeyword{} - for _, filterKeyword := range filter.Keywords { - filterKeywordsByID[filterKeyword.ID] = filterKeyword - } - - for i, formKeyword := range formKeywords { - filterKeywordColumns = append(filterKeywordColumns, nil) - - if formKeyword.ID != nil { - id := *formKeyword.ID - filterKeyword, ok := filterKeywordsByID[id] - if !ok { - return nil, nil, gtserror.NewErrorNotFound( - fmt.Errorf("couldn't find filter keyword '%s' to update or delete", id), - ) - } - - // Process deletes. - if *formKeyword.Destroy { - delete(filterKeywordsByID, id) - deleteFilterKeywordIDs = append(deleteFilterKeywordIDs, id) - continue - } - - // Process updates. - if formKeyword.Keyword != nil { - filterKeywordColumns[i] = append(filterKeywordColumns[i], "keyword") - filterKeyword.Keyword = *formKeyword.Keyword - } - if formKeyword.WholeWord != nil { - filterKeywordColumns[i] = append(filterKeywordColumns[i], "whole_word") - filterKeyword.WholeWord = formKeyword.WholeWord - } - continue - } - - // Process creates. - filterKeyword := >smodel.FilterKeyword{ - ID: id.NewULID(), - AccountID: filter.AccountID, - FilterID: filter.ID, - Filter: filter, - Keyword: *formKeyword.Keyword, - WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)), - } - filterKeywordsByID[filterKeyword.ID] = filterKeyword - } - - // Replace the filter's keywords list with our updated version. - filter.Keywords = nil - for _, filterKeyword := range filterKeywordsByID { - filter.Keywords = append(filter.Keywords, filterKeyword) - } - - return filterKeywordColumns, deleteFilterKeywordIDs, nil -} + // Re-attach keywords and statuses before returning. + filter.Keywords = filterKeywords + filter.Statuses = filterStatuses -// applyKeywordChanges applies the provided changes to the filter's keywords in place, -// and returns a list of filter status IDs to delete. -func applyStatusChanges(filter *gtsmodel.Filter, formStatuses []apimodel.FilterStatusCreateDeleteRequest) ([]string, gtserror.WithCode) { - if len(formStatuses) == 0 { - // Detach currently existing statuses from the filter so we don't change them. - filter.Statuses = nil - return nil, nil - } - - deleteFilterStatusIDs := []string{} - filterStatusesByID := map[string]*gtsmodel.FilterStatus{} - for _, filterStatus := range filter.Statuses { - filterStatusesByID[filterStatus.ID] = filterStatus - } - - for _, formStatus := range formStatuses { - if formStatus.ID != nil { - id := *formStatus.ID - _, ok := filterStatusesByID[id] - if !ok { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("couldn't find filter status '%s' to delete", id), - ) - } - - // Process deletes. - if *formStatus.Destroy { - delete(filterStatusesByID, id) - deleteFilterStatusIDs = append(deleteFilterStatusIDs, id) - continue - } - - // Filter statuses don't have updates. - continue - } - - // Process creates. - filterStatus := >smodel.FilterStatus{ - ID: id.NewULID(), - AccountID: filter.AccountID, - FilterID: filter.ID, - Filter: filter, - StatusID: *formStatus.StatusID, - } - filterStatusesByID[filterStatus.ID] = filterStatus - } - - // Replace the filter's keywords list with our updated version. - filter.Statuses = nil - for _, filterStatus := range filterStatusesByID { - filter.Statuses = append(filter.Statuses, filterStatus) - } - - return deleteFilterStatusIDs, nil + return p.apiFilter(ctx, filter) } From e65151ee6983bd198a0a4f4f23a6abb0dcde567d Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 30 May 2024 09:53:49 -0700 Subject: [PATCH 15/16] Add test for serialization of empty filter --- internal/api/client/filters/v2/filterget_test.go | 11 +++++++++++ testrig/testmodels.go | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/internal/api/client/filters/v2/filterget_test.go b/internal/api/client/filters/v2/filterget_test.go index 57618f5910..2567321321 100644 --- a/internal/api/client/filters/v2/filterget_test.go +++ b/internal/api/client/filters/v2/filterget_test.go @@ -120,3 +120,14 @@ func (suite *FiltersTestSuite) TestGetNonexistentFilter() { 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/testrig/testmodels.go b/testrig/testmodels.go index f0d2a2f4ee..956c898c09 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -3298,6 +3298,16 @@ func NewTestFilters() map[string]*gtsmodel.Filter { 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"), From 3d5e703e6581906a230d80a63966f145e4be43c7 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 30 May 2024 10:05:45 -0700 Subject: [PATCH 16/16] More helpful messages when object is owned by wrong account --- internal/processing/filters/v2/delete.go | 5 ++++- internal/processing/filters/v2/get.go | 5 ++++- internal/processing/filters/v2/keywordcreate.go | 5 ++++- internal/processing/filters/v2/keyworddelete.go | 5 ++++- internal/processing/filters/v2/keywordget.go | 5 ++++- internal/processing/filters/v2/keywordupdate.go | 5 ++++- internal/processing/filters/v2/statuscreate.go | 5 ++++- internal/processing/filters/v2/statusdelete.go | 5 ++++- internal/processing/filters/v2/statusget.go | 5 ++++- internal/processing/filters/v2/update.go | 4 +++- 10 files changed, 39 insertions(+), 10 deletions(-) diff --git a/internal/processing/filters/v2/delete.go b/internal/processing/filters/v2/delete.go index 719734cdb4..b1bebdcb60 100644 --- a/internal/processing/filters/v2/delete.go +++ b/internal/processing/filters/v2/delete.go @@ -19,6 +19,7 @@ package v2 import ( "context" + "fmt" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -38,7 +39,9 @@ func (p *Processor) Delete( // Check that the account owns it. if filter.AccountID != account.ID { - return gtserror.NewErrorNotFound(nil) + return gtserror.NewErrorNotFound( + fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), + ) } // Delete the entire filter. diff --git a/internal/processing/filters/v2/get.go b/internal/processing/filters/v2/get.go index 36392aa549..39b937eb29 100644 --- a/internal/processing/filters/v2/get.go +++ b/internal/processing/filters/v2/get.go @@ -20,6 +20,7 @@ package v2 import ( "context" "errors" + "fmt" "slices" "strings" @@ -39,7 +40,9 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterID return nil, gtserror.NewErrorInternalError(err) } if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + return nil, gtserror.NewErrorNotFound( + fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), + ) } return p.apiFilter(ctx, filter) diff --git a/internal/processing/filters/v2/keywordcreate.go b/internal/processing/filters/v2/keywordcreate.go index 2fd8bfdfbe..711b855faf 100644 --- a/internal/processing/filters/v2/keywordcreate.go +++ b/internal/processing/filters/v2/keywordcreate.go @@ -20,6 +20,7 @@ package v2 import ( "context" "errors" + "fmt" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -41,7 +42,9 @@ func (p *Processor) KeywordCreate(ctx context.Context, account *gtsmodel.Account return nil, gtserror.NewErrorInternalError(err) } if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + return nil, gtserror.NewErrorNotFound( + fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), + ) } filterKeyword := >smodel.FilterKeyword{ diff --git a/internal/processing/filters/v2/keyworddelete.go b/internal/processing/filters/v2/keyworddelete.go index fce157b68a..edf57167d4 100644 --- a/internal/processing/filters/v2/keyworddelete.go +++ b/internal/processing/filters/v2/keyworddelete.go @@ -19,6 +19,7 @@ package v2 import ( "context" + "fmt" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -38,7 +39,9 @@ func (p *Processor) KeywordDelete( // Check that the account owns it. if filterKeyword.AccountID != account.ID { - return gtserror.NewErrorNotFound(nil) + return gtserror.NewErrorNotFound( + fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), + ) } // Delete the filter keyword. diff --git a/internal/processing/filters/v2/keywordget.go b/internal/processing/filters/v2/keywordget.go index 7b90acbe34..5f5a63b26a 100644 --- a/internal/processing/filters/v2/keywordget.go +++ b/internal/processing/filters/v2/keywordget.go @@ -20,6 +20,7 @@ package v2 import ( "context" "errors" + "fmt" "slices" "strings" @@ -40,7 +41,9 @@ func (p *Processor) KeywordGet(ctx context.Context, account *gtsmodel.Account, f return nil, gtserror.NewErrorInternalError(err) } if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + 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 diff --git a/internal/processing/filters/v2/keywordupdate.go b/internal/processing/filters/v2/keywordupdate.go index 90c834105c..9a4058c23f 100644 --- a/internal/processing/filters/v2/keywordupdate.go +++ b/internal/processing/filters/v2/keywordupdate.go @@ -20,6 +20,7 @@ package v2 import ( "context" "errors" + "fmt" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -45,7 +46,9 @@ func (p *Processor) KeywordUpdate( return nil, gtserror.NewErrorInternalError(err) } if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + return nil, gtserror.NewErrorNotFound( + fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), + ) } filterKeyword.Keyword = form.Keyword diff --git a/internal/processing/filters/v2/statuscreate.go b/internal/processing/filters/v2/statuscreate.go index cbda35008f..a211dec2e6 100644 --- a/internal/processing/filters/v2/statuscreate.go +++ b/internal/processing/filters/v2/statuscreate.go @@ -20,6 +20,7 @@ package v2 import ( "context" "errors" + "fmt" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -41,7 +42,9 @@ func (p *Processor) StatusCreate(ctx context.Context, account *gtsmodel.Account, return nil, gtserror.NewErrorInternalError(err) } if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + return nil, gtserror.NewErrorNotFound( + fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), + ) } filterStatus := >smodel.FilterStatus{ diff --git a/internal/processing/filters/v2/statusdelete.go b/internal/processing/filters/v2/statusdelete.go index 42aa1a2cb0..a428e74093 100644 --- a/internal/processing/filters/v2/statusdelete.go +++ b/internal/processing/filters/v2/statusdelete.go @@ -19,6 +19,7 @@ package v2 import ( "context" + "fmt" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -38,7 +39,9 @@ func (p *Processor) StatusDelete( // Check that the account owns it. if filterStatus.AccountID != account.ID { - return gtserror.NewErrorNotFound(nil) + return gtserror.NewErrorNotFound( + fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID), + ) } // Delete the filter status. diff --git a/internal/processing/filters/v2/statusget.go b/internal/processing/filters/v2/statusget.go index 3c2d21e103..197a3872e8 100644 --- a/internal/processing/filters/v2/statusget.go +++ b/internal/processing/filters/v2/statusget.go @@ -20,6 +20,7 @@ package v2 import ( "context" "errors" + "fmt" "slices" "strings" @@ -40,7 +41,9 @@ func (p *Processor) StatusGet(ctx context.Context, account *gtsmodel.Account, fi return nil, gtserror.NewErrorInternalError(err) } if filterStatus.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + 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 diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go index f06f2a8638..aecb533374 100644 --- a/internal/processing/filters/v2/update.go +++ b/internal/processing/filters/v2/update.go @@ -48,7 +48,9 @@ func (p *Processor) Update( return nil, gtserror.NewErrorInternalError(err) } if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) + 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.