From b789fe2bc72a1b1bca50da498ae22c10a4e7acc2 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Fri, 14 Jun 2024 01:11:41 -0700 Subject: [PATCH] [feature] filter API v2: Restore keywords_attributes and statuses_attributes (#2995) These filter API v2 features were cut late in development because the form encoding version is hard to implement correctly and because I thought no clients actually used `keywords_attributes`. Unfortunately, Phanpy does use `keywords_attributes`. --- docs/api/swagger.yaml | 42 +++++ internal/api/client/filters/v2/filterpost.go | 61 +++++++ .../api/client/filters/v2/filterpost_test.go | 102 ++++++++++-- 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 | 151 ++++++++++++++++-- 8 files changed, 656 insertions(+), 40 deletions(-) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 3f14e41e5..46ed95c82 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -9245,6 +9245,27 @@ 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: @@ -9360,6 +9381,27 @@ 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 9e8f87fd0..732b81041 100644 --- a/internal/api/client/filters/v2/filterpost.go +++ b/internal/api/client/filters/v2/filterpost.go @@ -100,6 +100,30 @@ 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: @@ -176,6 +200,30 @@ 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) @@ -200,5 +248,18 @@ 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 6656c4b59..6e378874c 100644 --- a/internal/api/client/filters/v2/filterpost_test.go +++ b/internal/api/client/filters/v2/filterpost_test.go @@ -23,6 +23,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "strconv" "strings" @@ -35,7 +36,7 @@ import ( "github.com/superseriousbusiness/gotosocial/testrig" ) -func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) { +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) @@ -64,6 +65,19 @@ 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 @@ -111,7 +125,12 @@ func (suite *FiltersTestSuite) TestPostFilterFull() { context := []string{"home", "public"} action := "warn" expiresIn := 86400 - filter, err := suite.postFilter(&title, &context, &action, &expiresIn, nil, http.StatusOK, "") + // 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()) } @@ -126,8 +145,25 @@ func (suite *FiltersTestSuite) TestPostFilterFull() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - suite.Empty(filter.Keywords) - suite.Empty(filter.Statuses) + + 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.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged) } @@ -141,9 +177,27 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() { "context": ["home", "public"], "filter_action": "warn", "whole_word": true, - "expires_in": 86400.1 + "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, &requestJson, http.StatusOK, "") + filter, err := suite.postFilter(nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -160,8 +214,28 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - suite.Empty(filter.Keywords) - suite.Empty(filter.Statuses) + + 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.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged) } @@ -171,7 +245,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() { title := "GNU/Linux" context := []string{"home"} - filter, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusOK, "") + filter, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusOK, "") if err != nil { suite.FailNow(err.Error()) } @@ -193,7 +267,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() { func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() { title := "" context := []string{"home"} - _, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -201,7 +275,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() { func (suite *FiltersTestSuite) TestPostFilterMissingTitle() { context := []string{"home"} - _, err := suite.postFilter(nil, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(nil, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -210,7 +284,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingTitle() { func (suite *FiltersTestSuite) TestPostFilterEmptyContext() { title := "GNU/Linux" context := []string{} - _, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -218,7 +292,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyContext() { func (suite *FiltersTestSuite) TestPostFilterMissingContext() { title := "GNU/Linux" - _, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") + _, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "") if err != nil { suite.FailNow(err.Error()) } @@ -227,7 +301,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, http.StatusUnprocessableEntity, "") + _, 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 index 24071a150..cc3531838 100644 --- a/internal/api/client/filters/v2/filterput.go +++ b/internal/api/client/filters/v2/filterput.go @@ -18,6 +18,7 @@ package v2 import ( + "errors" "fmt" "net/http" "strconv" @@ -68,6 +69,30 @@ 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 @@ -183,6 +208,58 @@ 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. @@ -204,5 +281,42 @@ 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 6c1c315d1..d82d84b20 100644 --- a/internal/api/client/filters/v2/filterput_test.go +++ b/internal/api/client/filters/v2/filterput_test.go @@ -23,6 +23,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "strconv" "strings" @@ -35,7 +36,7 @@ import ( "github.com/superseriousbusiness/gotosocial/testrig" ) -func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) { +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) @@ -64,6 +65,39 @@ 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) @@ -114,7 +148,18 @@ func (suite *FiltersTestSuite) TestPutFilterFull() { context := []string{"home", "public"} action := "hide" expiresIn := 86400 - filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, nil, http.StatusOK, "") + // 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()) } @@ -129,8 +174,29 @@ func (suite *FiltersTestSuite) TestPutFilterFull() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - suite.Len(filter.Keywords, 3) - suite.Len(filter.Statuses, 0) + + 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.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged) } @@ -144,9 +210,28 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() { "title": "messy synoptic varblabbles", "context": ["home", "public"], "filter_action": "hide", - "expires_in": 86400.1 + "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, &requestJson, http.StatusOK, "") + 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()) } @@ -163,8 +248,29 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() { if suite.NotNil(filter.ExpiresAt) { suite.NotEmpty(*filter.ExpiresAt) } - suite.Len(filter.Keywords, 3) - suite.Len(filter.Statuses, 0) + + 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.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged) } @@ -175,7 +281,7 @@ func (suite *FiltersTestSuite) TestPutFilterMinimal() { id := suite.testFilters["local_account_1_filter_1"].ID title := "GNU/Linux" context := []string{"home"} - filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusOK, "") + 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()) } @@ -196,7 +302,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, 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, 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()) } @@ -206,7 +312,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, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`) + _, 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()) } @@ -216,7 +322,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, http.StatusConflict, `{"error":"Conflict: you already have a filter with this 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()) } @@ -226,7 +332,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, http.StatusNotFound, `{"error":"Not Found"}`) + _, 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()) } @@ -236,7 +342,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, http.StatusNotFound, `{"error":"Not Found"}`) + _, 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/model/filterv2.go b/internal/api/model/filterv2.go index 51dabacb2..242c569dc 100644 --- a/internal/api/model/filterv2.go +++ b/internal/api/model/filterv2.go @@ -135,9 +135,21 @@ 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. +// 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 { @@ -152,7 +164,7 @@ type FilterKeywordCreateUpdateRequest struct { WholeWord *bool `form:"whole_word" json:"whole_word" xml:"whole_word"` } -// FilterStatusCreateRequest captures params for creating a filter status. +// FilterStatusCreateRequest captures params for a status while creating a v2 filter or filter status. // // swagger:ignore type FilterStatusCreateRequest struct { @@ -188,4 +200,57 @@ 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 d429e1139..7095a643c 100644 --- a/internal/processing/filters/v2/create.go +++ b/internal/processing/filters/v2/create.go @@ -63,6 +63,29 @@ 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 5322f63d9..d8297de38 100644 --- a/internal/processing/filters/v2/update.go +++ b/internal/processing/filters/v2/update.go @@ -27,6 +27,7 @@ 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" ) @@ -39,6 +40,8 @@ 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 { @@ -103,13 +106,17 @@ func (p *Processor) Update( } } - // 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 + filterKeywordColumns, deleteFilterKeywordIDs, errWithCode := applyKeywordChanges(filter, form.Keywords) + if err != nil { + return nil, errWithCode + } - if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, nil, nil, nil); err != nil { + 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()) @@ -117,10 +124,6 @@ func (p *Processor) Update( return nil, gtserror.NewErrorInternalError(err) } - // Re-attach keywords and statuses before returning. - filter.Keywords = filterKeywords - filter.Statuses = filterStatuses - apiFilter, errWithCode := p.apiFilter(ctx, filter) if errWithCode != nil { return nil, errWithCode @@ -131,3 +134,131 @@ func (p *Processor) Update( return apiFilter, nil } + +// 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 + } + + deleteFilterKeywordIDs := []string{} + filterKeywordsByID := map[string]*gtsmodel.FilterKeyword{} + filterKeywordColumnsByID := map[string][]string{} + for _, filterKeyword := range filter.Keywords { + filterKeywordsByID[filterKeyword.ID] = filterKeyword + } + + for _, formKeyword := range formKeywords { + 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. + columns := make([]string, 0, 2) + if formKeyword.Keyword != nil { + columns = append(columns, "keyword") + filterKeyword.Keyword = *formKeyword.Keyword + } + if formKeyword.WholeWord != nil { + columns = append(columns, "whole_word") + filterKeyword.WholeWord = formKeyword.WholeWord + } + filterKeywordColumnsByID[id] = columns + 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 + // Don't need to set columns, as we're using all of them. + } + + // Replace the filter's keywords list with our updated version. + filterKeywordColumns := [][]string{} + filter.Keywords = nil + for id, filterKeyword := range filterKeywordsByID { + filter.Keywords = append(filter.Keywords, filterKeyword) + // Okay to use the nil slice zero value for entries being created instead of updated. + filterKeywordColumns = append(filterKeywordColumns, filterKeywordColumnsByID[id]) + } + + 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 +}