diff --git a/internal/api/client/filters/v1/filterdelete_test.go b/internal/api/client/filters/v1/filterdelete_test.go index 314da05bd..ee1ab581e 100644 --- a/internal/api/client/filters/v1/filterdelete_test.go +++ b/internal/api/client/filters/v1/filterdelete_test.go @@ -104,7 +104,7 @@ func (suite *FiltersTestSuite) TestDeleteFilter() { func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() { id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID - err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -113,7 +113,7 @@ func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() { func (suite *FiltersTestSuite) TestDeleteNonexistentFilter() { id := "not_even_a_real_ULID" - err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found: filter keyword not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v1/filterget_test.go b/internal/api/client/filters/v1/filterget_test.go index 93090c880..fcb6fd6c5 100644 --- a/internal/api/client/filters/v1/filterget_test.go +++ b/internal/api/client/filters/v1/filterget_test.go @@ -108,7 +108,7 @@ func (suite *FiltersTestSuite) TestGetFilter() { func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() { id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID - _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -117,7 +117,7 @@ func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() { func (suite *FiltersTestSuite) TestGetNonexistentFilter() { id := "not_even_a_real_ULID" - _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found: filter keyword not found"}`) 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 41804ad44..c6edbaf0b 100644 --- a/internal/api/client/filters/v1/filterput_test.go +++ b/internal/api/client/filters/v1/filterput_test.go @@ -261,7 +261,7 @@ func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() { id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID phrase := "GNU/Linux" context := []string{"home"} - _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -271,7 +271,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, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter keyword not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v1/filtersget_test.go b/internal/api/client/filters/v1/filtersget_test.go index bf8ed8a0b..c2d84cd43 100644 --- a/internal/api/client/filters/v1/filtersget_test.go +++ b/internal/api/client/filters/v1/filtersget_test.go @@ -87,10 +87,17 @@ func (suite *FiltersTestSuite) getFilters( func (suite *FiltersTestSuite) TestGetFilters() { // v1 filters map to individual filter keywords. + wrappingFilterIDs := make(map[string]struct{}, len(suite.testFilters)) expectedFilterIDs := make([]string, 0, len(suite.testFilterKeywords)) expectedFilterKeywords := make([]string, 0, len(suite.testFilterKeywords)) + testAccountID := suite.testAccounts["local_account_1"].ID + for _, filter := range suite.testFilters { + if filter.AccountID == testAccountID { + wrappingFilterIDs[filter.ID] = struct{}{} + } + } for _, filterKeyword := range suite.testFilterKeywords { - if filterKeyword.AccountID == suite.testAccounts["local_account_1"].ID { + if _, ok := wrappingFilterIDs[filterKeyword.FilterID]; ok { expectedFilterIDs = append(expectedFilterIDs, filterKeyword.ID) expectedFilterKeywords = append(expectedFilterKeywords, filterKeyword.Keyword) } diff --git a/internal/api/client/filters/v2/filterdelete_test.go b/internal/api/client/filters/v2/filterdelete_test.go index b2d6ae6de..3c997bfef 100644 --- a/internal/api/client/filters/v2/filterdelete_test.go +++ b/internal/api/client/filters/v2/filterdelete_test.go @@ -104,7 +104,7 @@ func (suite *FiltersTestSuite) TestDeleteFilter() { func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() { id := suite.testFilters["local_account_2_filter_1"].ID - err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -113,7 +113,7 @@ func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilter() { func (suite *FiltersTestSuite) TestDeleteNonexistentFilter() { id := "not_even_a_real_ULID" - err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilter(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterget_test.go b/internal/api/client/filters/v2/filterget_test.go index 48d90cae2..c58ed46db 100644 --- a/internal/api/client/filters/v2/filterget_test.go +++ b/internal/api/client/filters/v2/filterget_test.go @@ -106,7 +106,7 @@ func (suite *FiltersTestSuite) TestGetFilter() { func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() { id := suite.testFilters["local_account_2_filter_1"].ID - _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -115,7 +115,7 @@ func (suite *FiltersTestSuite) TestGetAnotherAccountsFilter() { func (suite *FiltersTestSuite) TestGetNonexistentFilter() { id := "not_even_a_real_ULID" - _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilter(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterkeyworddelete_test.go b/internal/api/client/filters/v2/filterkeyworddelete_test.go index 1b9325c6a..308e5e011 100644 --- a/internal/api/client/filters/v2/filterkeyworddelete_test.go +++ b/internal/api/client/filters/v2/filterkeyworddelete_test.go @@ -104,7 +104,7 @@ func (suite *FiltersTestSuite) TestDeleteFilterKeyword() { func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterKeyword() { id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID - err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -113,7 +113,7 @@ func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterKeyword() { func (suite *FiltersTestSuite) TestDeleteNonexistentFilterKeyword() { id := "not_even_a_real_ULID" - err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found: filter keyword not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterkeywordget_test.go b/internal/api/client/filters/v2/filterkeywordget_test.go index 25167700d..f04090d90 100644 --- a/internal/api/client/filters/v2/filterkeywordget_test.go +++ b/internal/api/client/filters/v2/filterkeywordget_test.go @@ -106,7 +106,7 @@ func (suite *FiltersTestSuite) TestGetFilterKeyword() { func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() { id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID - _, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -115,7 +115,7 @@ func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterKeyword() { func (suite *FiltersTestSuite) TestGetNonexistentFilterKeyword() { id := "not_even_a_real_ULID" - _, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilterKeyword(id, http.StatusNotFound, `{"error":"Not Found: filter keyword not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterkeywordpost_test.go b/internal/api/client/filters/v2/filterkeywordpost_test.go index ce7087b0b..8f37a3030 100644 --- a/internal/api/client/filters/v2/filterkeywordpost_test.go +++ b/internal/api/client/filters/v2/filterkeywordpost_test.go @@ -189,7 +189,7 @@ func (suite *FiltersTestSuite) TestPostFilterKeywordKeywordConflict() { 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"}`) + _, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -198,7 +198,7 @@ func (suite *FiltersTestSuite) TestPostFilterKeywordAnotherAccountsFilter() { func (suite *FiltersTestSuite) TestPostFilterKeywordNonexistentFilter() { filterID := "not_even_a_real_ULID" keyword := "fnords" - _, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.postFilterKeyword(filterID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterkeywordput_test.go b/internal/api/client/filters/v2/filterkeywordput_test.go index e98dd899d..e07def26e 100644 --- a/internal/api/client/filters/v2/filterkeywordput_test.go +++ b/internal/api/client/filters/v2/filterkeywordput_test.go @@ -189,7 +189,7 @@ func (suite *FiltersTestSuite) TestPutFilterKeywordKeywordConflict() { func (suite *FiltersTestSuite) TestPutFilterKeywordAnotherAccountsFilterKeyword() { filterKeywordID := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID keyword := "fnord" - _, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -198,7 +198,7 @@ func (suite *FiltersTestSuite) TestPutFilterKeywordAnotherAccountsFilterKeyword( func (suite *FiltersTestSuite) TestPutFilterKeywordNonexistentFilterKeyword() { filterKeywordID := "not_even_a_real_ULID" keyword := "fnord" - _, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.putFilterKeyword(filterKeywordID, &keyword, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter keyword not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterput_test.go b/internal/api/client/filters/v2/filterput_test.go index 4fd1d26ee..c8e8cb9c6 100644 --- a/internal/api/client/filters/v2/filterput_test.go +++ b/internal/api/client/filters/v2/filterput_test.go @@ -116,7 +116,7 @@ func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context return nil, err } - errs := gtserror.NewMultiError(2) + var errs gtserror.MultiError // check code + body if resultCode := recorder.Code; expectedHTTPStatus != resultCode { @@ -324,7 +324,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"}`, nil) + _, err := suite.putFilter(id, &title, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: duplicate title"}`, nil) if err != nil { suite.FailNow(err.Error()) } @@ -334,7 +334,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"}`, nil) + _, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`, nil) if err != nil { suite.FailNow(err.Error()) } @@ -344,7 +344,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"}`, nil) + _, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`, nil) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filtersget_test.go b/internal/api/client/filters/v2/filtersget_test.go index 76802d83a..a410bdc90 100644 --- a/internal/api/client/filters/v2/filtersget_test.go +++ b/internal/api/client/filters/v2/filtersget_test.go @@ -86,10 +86,8 @@ func (suite *FiltersTestSuite) getFilters( } 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 { + expectedFilters := map[string]*struct { keywordIDs []string statusIDs []string }{} @@ -98,28 +96,26 @@ func (suite *FiltersTestSuite) TestGetFilters() { 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 { + 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 + expected, ok := expectedFilters[filterKeyword.FilterID] + if !ok { + continue } + expected.keywordIDs = append(expected.keywordIDs, filterKeyword.ID) } for _, filterStatus := range suite.testFilterStatuses { - if filterStatus.AccountID == accountID { - expectedIDsForFilter := expectedFilters[filterStatus.FilterID] - expectedIDsForFilter.statusIDs = append(expectedIDsForFilter.statusIDs, filterStatus.ID) - expectedFilters[filterStatus.FilterID] = expectedIDsForFilter + expected, ok := expectedFilters[filterStatus.FilterID] + if !ok { + continue } + expected.statusIDs = append(expected.statusIDs, filterStatus.ID) } - suite.NotEmpty(expectedFilterIDs) suite.NotEmpty(expectedFilters) // Fetch all filters for the logged-in account. @@ -132,23 +128,19 @@ func (suite *FiltersTestSuite) TestGetFilters() { // 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{} + var actualFilterKeywordIDs []string for _, filterKeyword := range filter.Keywords { actualFilterKeywordIDs = append(actualFilterKeywordIDs, filterKeyword.ID) } suite.ElementsMatch(actualFilterKeywordIDs, expectedIDsForFilter.keywordIDs) - actualFilterStatusIDs := []string{} + var 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_test.go b/internal/api/client/filters/v2/filterstatusdelete_test.go index 017b2739b..bb918d76a 100644 --- a/internal/api/client/filters/v2/filterstatusdelete_test.go +++ b/internal/api/client/filters/v2/filterstatusdelete_test.go @@ -101,7 +101,7 @@ func (suite *FiltersTestSuite) TestDeleteFilterStatus() { func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterStatus() { id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID - err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -110,7 +110,7 @@ func (suite *FiltersTestSuite) TestDeleteAnotherAccountsFilterStatus() { func (suite *FiltersTestSuite) TestDeleteNonexistentFilterStatus() { id := "not_even_a_real_ULID" - err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + err := suite.deleteFilterStatus(id, http.StatusNotFound, `{"error":"Not Found: filter status not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterstatusget_test.go b/internal/api/client/filters/v2/filterstatusget_test.go index d7c27eed2..f5ea0a1ba 100644 --- a/internal/api/client/filters/v2/filterstatusget_test.go +++ b/internal/api/client/filters/v2/filterstatusget_test.go @@ -104,7 +104,7 @@ func (suite *FiltersTestSuite) TestGetFilterStatus() { func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterStatus() { id := suite.testFilterStatuses["local_account_2_filter_1_status_1"].ID - _, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -113,7 +113,7 @@ func (suite *FiltersTestSuite) TestGetAnotherAccountsFilterStatus() { func (suite *FiltersTestSuite) TestGetNonexistentFilterStatus() { id := "not_even_a_real_ULID" - _, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found"}`) + _, err := suite.getFilterStatus(id, http.StatusNotFound, `{"error":"Not Found: filter status not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/api/client/filters/v2/filterstatuspost_test.go b/internal/api/client/filters/v2/filterstatuspost_test.go index d6a960372..acfd73e02 100644 --- a/internal/api/client/filters/v2/filterstatuspost_test.go +++ b/internal/api/client/filters/v2/filterstatuspost_test.go @@ -173,7 +173,7 @@ func (suite *FiltersTestSuite) TestPostFilterStatusStatusIDConflict() { 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"}`) + _, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } @@ -182,7 +182,7 @@ func (suite *FiltersTestSuite) TestPostFilterStatusAnotherAccountsFilter() { 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"}`) + _, err := suite.postFilterStatus(filterID, &statusID, nil, http.StatusNotFound, `{"error":"Not Found: filter not found"}`) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 54777441f..d3d2d5f2b 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -86,6 +86,7 @@ func (c *Caches) Init() { c.initDomainPermissionExclude() c.initEmoji() c.initEmojiCategory() + c.initFilterIDs() c.initFilter() c.initFilterKeyword() c.initFilterStatus() diff --git a/internal/cache/db.go b/internal/cache/db.go index 5592ca493..6b482d5f8 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -82,6 +82,10 @@ type DBCaches struct { // Filter provides access to the gtsmodel Filter database cache. Filter StructCache[*gtsmodel.Filter] + // FilterIDs provides access to the filter IDs database cache. + // This cache is keyed as: {accountID} -> []{filterIDs}. + FilterIDs SliceCache[string] + // FilterKeyword provides access to the gtsmodel FilterKeyword database cache. FilterKeyword StructCache[*gtsmodel.FilterKeyword] @@ -691,12 +695,23 @@ func (c *Caches) initFilter() { {Fields: "ID"}, {Fields: "AccountID", Multiple: true}, }, - MaxSize: cap, - IgnoreErr: ignoreErrors, - Copy: copyF, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateFilter, }) } +func (c *Caches) initFilterIDs() { + cap := calculateSliceCacheMax( + config.GetCacheFilterIDsMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + c.DB.FilterIDs.Init(0, cap) +} + func (c *Caches) initFilterKeyword() { // Calculate maximum cache size. cap := calculateResultCacheMax( @@ -710,11 +725,6 @@ func (c *Caches) initFilterKeyword() { filterKeyword2 := new(gtsmodel.FilterKeyword) *filterKeyword2 = *filterKeyword1 - // Don't include ptr fields that - // will be populated separately. - // See internal/db/bundb/filter.go. - filterKeyword2.Filter = nil - // We specifically DO NOT unset // the regexp field here, as any // regexp.Regexp instance is safe @@ -726,12 +736,12 @@ func (c *Caches) initFilterKeyword() { c.DB.FilterKeyword.Init(structr.CacheConfig[*gtsmodel.FilterKeyword]{ Indices: []structr.IndexConfig{ {Fields: "ID"}, - {Fields: "AccountID", Multiple: true}, {Fields: "FilterID", Multiple: true}, }, - MaxSize: cap, - IgnoreErr: ignoreErrors, - Copy: copyF, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateFilterKeyword, }) } @@ -747,24 +757,18 @@ func (c *Caches) initFilterStatus() { copyF := func(filterStatus1 *gtsmodel.FilterStatus) *gtsmodel.FilterStatus { filterStatus2 := new(gtsmodel.FilterStatus) *filterStatus2 = *filterStatus1 - - // Don't include ptr fields that - // will be populated separately. - // See internal/db/bundb/filter.go. - filterStatus2.Filter = nil - return filterStatus2 } c.DB.FilterStatus.Init(structr.CacheConfig[*gtsmodel.FilterStatus]{ Indices: []structr.IndexConfig{ {Fields: "ID"}, - {Fields: "AccountID", Multiple: true}, {Fields: "FilterID", Multiple: true}, }, - MaxSize: cap, - IgnoreErr: ignoreErrors, - Copy: copyF, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateFilterStatus, }) } diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go index 88b7415ae..4941b2540 100644 --- a/internal/cache/invalidate.go +++ b/internal/cache/invalidate.go @@ -112,6 +112,29 @@ func (c *Caches) OnInvalidateEmojiCategory(category *gtsmodel.EmojiCategory) { c.DB.Emoji.Invalidate("CategoryID", category.ID) } +func (c *Caches) OnInvalidateFilter(filter *gtsmodel.Filter) { + // Invalidate list of filters for account. + c.DB.FilterIDs.Invalidate(filter.AccountID) + + // Invalidate all associated keywords and statuses. + c.DB.FilterKeyword.InvalidateIDs("ID", filter.KeywordIDs) + c.DB.FilterStatus.InvalidateIDs("ID", filter.StatusIDs) + + // Invalidate account's timelines (in case local). + c.Timelines.Home.Unprepare(filter.AccountID) + c.Timelines.List.Unprepare(filter.AccountID) +} + +func (c *Caches) OnInvalidateFilterKeyword(filterKeyword *gtsmodel.FilterKeyword) { + // Invalidate filter that keyword associated with. + c.DB.Filter.Invalidate("ID", filterKeyword.FilterID) +} + +func (c *Caches) OnInvalidateFilterStatus(filterStatus *gtsmodel.FilterStatus) { + // Invalidate filter that status associated with. + c.DB.Filter.Invalidate("ID", filterStatus.FilterID) +} + func (c *Caches) OnInvalidateFollow(follow *gtsmodel.Follow) { // Invalidate follow request with this same ID. c.DB.FollowRequest.Invalidate("ID", follow.ID) diff --git a/internal/cache/size.go b/internal/cache/size.go index ef9259f88..8a6c9e9ad 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -19,6 +19,7 @@ package cache import ( "crypto/rsa" + "regexp" "strings" "time" "unsafe" @@ -348,8 +349,6 @@ func sizeofEmojiCategory() uintptr { func sizeofFilter() uintptr { return uintptr(size.Of(>smodel.Filter{ ID: exampleID, - CreatedAt: exampleTime, - UpdatedAt: exampleTime, ExpiresAt: exampleTime, AccountID: exampleID, Title: exampleTextSmall, @@ -359,21 +358,18 @@ func sizeofFilter() uintptr { func sizeofFilterKeyword() uintptr { return uintptr(size.Of(>smodel.FilterKeyword{ - ID: exampleID, - CreatedAt: exampleTime, - UpdatedAt: exampleTime, - FilterID: exampleID, - Keyword: exampleTextSmall, + ID: exampleID, + FilterID: exampleID, + Keyword: exampleTextSmall, + Regexp: regexp.MustCompile("^match (this)? .*"), })) } func sizeofFilterStatus() uintptr { return uintptr(size.Of(>smodel.FilterStatus{ - ID: exampleID, - CreatedAt: exampleTime, - UpdatedAt: exampleTime, - FilterID: exampleID, - StatusID: exampleID, + ID: exampleID, + FilterID: exampleID, + StatusID: exampleID, })) } diff --git a/internal/config/config.go b/internal/config/config.go index 4bb7db801..528000478 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -226,6 +226,7 @@ type CacheConfiguration struct { EmojiMemRatio float64 `name:"emoji-mem-ratio"` EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` FilterMemRatio float64 `name:"filter-mem-ratio"` + FilterIDsMemRatio float64 `name:"filter-ids-mem-ratio"` FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"` FilterStatusMemRatio float64 `name:"filter-status-mem-ratio"` FollowMemRatio float64 `name:"follow-mem-ratio"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 234f0f8d0..82de65bb7 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -193,6 +193,7 @@ var Defaults = Configuration{ EmojiMemRatio: 3, EmojiCategoryMemRatio: 0.1, FilterMemRatio: 0.5, + FilterIDsMemRatio: 2, FilterKeywordMemRatio: 0.5, FilterStatusMemRatio: 0.5, FollowMemRatio: 2, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index e8ed3b0d6..7dfe1db23 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -170,6 +170,7 @@ const ( CacheEmojiMemRatioFlag = "cache-emoji-mem-ratio" CacheEmojiCategoryMemRatioFlag = "cache-emoji-category-mem-ratio" CacheFilterMemRatioFlag = "cache-filter-mem-ratio" + CacheFilterIDsMemRatioFlag = "cache-filter-ids-mem-ratio" CacheFilterKeywordMemRatioFlag = "cache-filter-keyword-mem-ratio" CacheFilterStatusMemRatioFlag = "cache-filter-status-mem-ratio" CacheFollowMemRatioFlag = "cache-follow-mem-ratio" @@ -362,6 +363,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) { flags.Float64("cache-emoji-mem-ratio", cfg.Cache.EmojiMemRatio, "") flags.Float64("cache-emoji-category-mem-ratio", cfg.Cache.EmojiCategoryMemRatio, "") flags.Float64("cache-filter-mem-ratio", cfg.Cache.FilterMemRatio, "") + flags.Float64("cache-filter-ids-mem-ratio", cfg.Cache.FilterIDsMemRatio, "") flags.Float64("cache-filter-keyword-mem-ratio", cfg.Cache.FilterKeywordMemRatio, "") flags.Float64("cache-filter-status-mem-ratio", cfg.Cache.FilterStatusMemRatio, "") flags.Float64("cache-follow-mem-ratio", cfg.Cache.FollowMemRatio, "") @@ -406,7 +408,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) { } func (cfg *Configuration) MarshalMap() map[string]any { - cfgmap := make(map[string]any, 189) + cfgmap := make(map[string]any, 190) cfgmap["log-level"] = cfg.LogLevel cfgmap["log-timestamp-format"] = cfg.LogTimestampFormat cfgmap["log-db-queries"] = cfg.LogDbQueries @@ -548,6 +550,7 @@ func (cfg *Configuration) MarshalMap() map[string]any { cfgmap["cache-emoji-mem-ratio"] = cfg.Cache.EmojiMemRatio cfgmap["cache-emoji-category-mem-ratio"] = cfg.Cache.EmojiCategoryMemRatio cfgmap["cache-filter-mem-ratio"] = cfg.Cache.FilterMemRatio + cfgmap["cache-filter-ids-mem-ratio"] = cfg.Cache.FilterIDsMemRatio cfgmap["cache-filter-keyword-mem-ratio"] = cfg.Cache.FilterKeywordMemRatio cfgmap["cache-filter-status-mem-ratio"] = cfg.Cache.FilterStatusMemRatio cfgmap["cache-follow-mem-ratio"] = cfg.Cache.FollowMemRatio @@ -1767,6 +1770,14 @@ func (cfg *Configuration) UnmarshalMap(cfgmap map[string]any) error { } } + if ival, ok := cfgmap["cache-filter-ids-mem-ratio"]; ok { + var err error + cfg.Cache.FilterIDsMemRatio, err = cast.ToFloat64E(ival) + if err != nil { + return fmt.Errorf("error casting %#v -> float64 for 'cache-filter-ids-mem-ratio': %w", ival, err) + } + } + if ival, ok := cfgmap["cache-filter-keyword-mem-ratio"]; ok { var err error cfg.Cache.FilterKeywordMemRatio, err = cast.ToFloat64E(ival) @@ -5278,6 +5289,28 @@ func GetCacheFilterMemRatio() float64 { return global.GetCacheFilterMemRatio() } // SetCacheFilterMemRatio safely sets the value for global configuration 'Cache.FilterMemRatio' field func SetCacheFilterMemRatio(v float64) { global.SetCacheFilterMemRatio(v) } +// GetCacheFilterIDsMemRatio safely fetches the Configuration value for state's 'Cache.FilterIDsMemRatio' field +func (st *ConfigState) GetCacheFilterIDsMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.FilterIDsMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheFilterIDsMemRatio safely sets the Configuration value for state's 'Cache.FilterIDsMemRatio' field +func (st *ConfigState) SetCacheFilterIDsMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.FilterIDsMemRatio = v + st.reloadToViper() +} + +// GetCacheFilterIDsMemRatio safely fetches the value for global configuration 'Cache.FilterIDsMemRatio' field +func GetCacheFilterIDsMemRatio() float64 { return global.GetCacheFilterIDsMemRatio() } + +// SetCacheFilterIDsMemRatio safely sets the value for global configuration 'Cache.FilterIDsMemRatio' field +func SetCacheFilterIDsMemRatio(v float64) { global.SetCacheFilterIDsMemRatio(v) } + // GetCacheFilterKeywordMemRatio safely fetches the Configuration value for state's 'Cache.FilterKeywordMemRatio' field func (st *ConfigState) GetCacheFilterKeywordMemRatio() (v float64) { st.mutex.RLock() @@ -6359,6 +6392,7 @@ func (st *ConfigState) GetTotalOfMemRatios() (total float64) { total += st.config.Cache.EmojiMemRatio total += st.config.Cache.EmojiCategoryMemRatio total += st.config.Cache.FilterMemRatio + total += st.config.Cache.FilterIDsMemRatio total += st.config.Cache.FilterKeywordMemRatio total += st.config.Cache.FilterStatusMemRatio total += st.config.Cache.FollowMemRatio @@ -6910,6 +6944,17 @@ func flattenConfigMap(cfgmap map[string]any) { } } + for _, key := range [][]string{ + {"cache", "filter-ids-mem-ratio"}, + } { + ival, ok := mapGet(cfgmap, key...) + if ok { + cfgmap["cache-filter-ids-mem-ratio"] = ival + nestedKeys[key[0]] = struct{}{} + break + } + } + for _, key := range [][]string{ {"cache", "filter-keyword-mem-ratio"}, } { diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index ba705dd33..6545414a7 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -35,6 +35,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/config" "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" "code.superseriousbusiness.org/gotosocial/internal/observability" @@ -118,11 +119,17 @@ func doMigration(ctx context.Context, db *bun.DB) error { log.Infof(ctx, "MIGRATED DATABASE TO %s", group) if db.Dialect().Name() == dialect.SQLite { - log.Info(ctx, - "running ANALYZE to update table and index statistics; this will take somewhere between "+ - "1-10 minutes, or maybe longer depending on your hardware and database size, please be patient", - ) - _, err := db.ExecContext(ctx, "ANALYZE") + // Perform a final WAL checkpoint after a migration on SQLite. + if strings.EqualFold(config.GetDbSqliteJournalMode(), "WAL") { + _, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(RESTART);") + if err != nil { + return gtserror.Newf("error performing wal_checkpoint: %w", err) + } + } + + log.Info(ctx, "running ANALYZE to update table and index statistics; this will take somewhere between "+ + "1-10 minutes, or maybe longer depending on your hardware and database size, please be patient") + _, err := db.ExecContext(ctx, "ANALYZE;") if err != nil { log.Warnf(ctx, "ANALYZE failed, query planner may make poor life choices: %s", err) } diff --git a/internal/db/bundb/filter.go b/internal/db/bundb/filter.go index 24208b1f3..dbc560a12 100644 --- a/internal/db/bundb/filter.go +++ b/internal/db/bundb/filter.go @@ -21,8 +21,8 @@ import ( "context" "errors" "slices" - "time" + "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -64,24 +64,14 @@ func (f *filterDB) GetFilterByID(ctx context.Context, id string) (*gtsmodel.Filt return filter, nil } -func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) { - // Fetch IDs of all filters owned by this account. - var filterIDs []string - if err := f.db. - NewSelect(). - Model((*gtsmodel.Filter)(nil)). - Column("id"). - Where("? = ?", bun.Ident("account_id"), accountID). - Scan(ctx, &filterIDs); err != nil { - return nil, err - } - if len(filterIDs) == 0 { +func (f *filterDB) GetFiltersByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Filter, error) { + if len(ids) == 0 { return nil, nil } // Get each filter by ID from the cache or DB. filters, err := f.state.Caches.DB.Filter.LoadIDs("ID", - filterIDs, + ids, func(uncached []string) ([]*gtsmodel.Filter, error) { filters := make([]*gtsmodel.Filter, 0, len(uncached)) if err := f.db. @@ -99,14 +89,15 @@ func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string) } // Put the filter structs in the same order as the filter IDs. - xslices.OrderBy(filters, filterIDs, func(filter *gtsmodel.Filter) string { return filter.ID }) + xslices.OrderBy(filters, ids, func(filter *gtsmodel.Filter) string { return filter.ID }) if gtscontext.Barebones(ctx) { return filters, nil } + var errs gtserror.MultiError + // Populate the filters. Remove any that we can't populate from the return slice. - errs := gtserror.NewMultiError(len(filters)) filters = slices.DeleteFunc(filters, func(filter *gtsmodel.Filter) bool { if err := f.populateFilter(ctx, filter); err != nil { errs.Appendf("error populating filter %s: %w", filter.ID, err) @@ -118,235 +109,115 @@ func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string) return filters, errs.Combine() } +func (f *filterDB) GetFilterIDsByAccountID(ctx context.Context, accountID string) ([]string, error) { + return f.state.Caches.DB.FilterIDs.Load(accountID, func() ([]string, error) { + var filterIDs []string + + if err := f.db. + NewSelect(). + Model((*gtsmodel.Filter)(nil)). + Column("id"). + Where("? = ?", bun.Ident("account_id"), accountID). + Scan(ctx, &filterIDs); err != nil { + return nil, err + } + + return filterIDs, nil + }) +} + +func (f *filterDB) GetFiltersByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) { + filterIDs, err := f.GetFilterIDsByAccountID(ctx, accountID) + if err != nil { + return nil, gtserror.Newf("error getting filter ids: %w", err) + } + return f.GetFiltersByIDs(ctx, filterIDs) +} + func (f *filterDB) populateFilter(ctx context.Context, filter *gtsmodel.Filter) error { var err error - errs := gtserror.NewMultiError(2) + var errs gtserror.MultiError - if filter.Keywords == nil { + if !filter.KeywordsPopulated() { // Filter keywords are not set, fetch from the database. - filter.Keywords, err = f.state.DB.GetFilterKeywordsForFilterID( - gtscontext.SetBarebones(ctx), - filter.ID, - ) + filter.Keywords, err = f.GetFilterKeywordsByIDs(ctx, filter.KeywordIDs) if err != nil { errs.Appendf("error populating filter keywords: %w", err) } - for i := range filter.Keywords { - filter.Keywords[i].Filter = filter - } } - if filter.Statuses == nil { + if !filter.StatusesPopulated() { // Filter statuses are not set, fetch from the database. - filter.Statuses, err = f.state.DB.GetFilterStatusesForFilterID( - gtscontext.SetBarebones(ctx), - filter.ID, - ) + filter.Statuses, err = f.GetFilterStatusesByIDs(ctx, filter.StatusIDs) if err != nil { errs.Appendf("error populating filter statuses: %w", err) } - for i := range filter.Statuses { - filter.Statuses[i].Filter = filter - } } return errs.Combine() } func (f *filterDB) PutFilter(ctx context.Context, filter *gtsmodel.Filter) error { - // Pre-compile filter keyword regular expressions. - for _, filterKeyword := range filter.Keywords { - if err := filterKeyword.Compile(); err != nil { - return gtserror.Newf("error compiling filter keyword regex: %w", err) - } - } - - // Update database. - if err := f.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { - if _, err := tx. - NewInsert(). - Model(filter). - Exec(ctx); err != nil { - return err - } - - if len(filter.Keywords) > 0 { - if _, err := tx. - NewInsert(). - Model(&filter.Keywords). - Exec(ctx); err != nil { - return err - } - } - - if len(filter.Statuses) > 0 { - if _, err := tx. - NewInsert(). - Model(&filter.Statuses). - Exec(ctx); err != nil { - return err - } - } - - return nil - }); err != nil { + return f.state.Caches.DB.Filter.Store(filter, func() error { + _, err := f.db.NewInsert().Model(filter).Exec(ctx) return err - } - - // Update cache. - f.state.Caches.DB.Filter.Put(filter) - f.state.Caches.DB.FilterKeyword.Put(filter.Keywords...) - f.state.Caches.DB.FilterStatus.Put(filter.Statuses...) - - return nil + }) } -func (f *filterDB) UpdateFilter( - ctx context.Context, - filter *gtsmodel.Filter, - filterColumns []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 { - filterKeyword.UpdatedAt = updatedAt - } - for _, filterStatus := range filter.Statuses { - filterStatus.UpdatedAt = updatedAt - } - - // If we're updating by column, ensure "updated_at" is included. - if len(filterColumns) > 0 { - filterColumns = append(filterColumns, "updated_at") - } - for i := range filterKeywordColumns { - if len(filterKeywordColumns[i]) > 0 { - filterKeywordColumns[i] = append(filterKeywordColumns[i], "updated_at") - } - } - - // Pre-compile filter keyword regular expressions. - for _, filterKeyword := range filter.Keywords { - if err := filterKeyword.Compile(); err != nil { - return gtserror.Newf("error compiling filter keyword regex: %w", err) - } - } - - // Update database. - if err := f.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { - if _, err := tx. - NewUpdate(). +func (f *filterDB) UpdateFilter(ctx context.Context, filter *gtsmodel.Filter, cols ...string) error { + return f.state.Caches.DB.Filter.Store(filter, func() error { + _, err := f.db.NewUpdate(). Model(filter). - Column(filterColumns...). Where("? = ?", bun.Ident("id"), filter.ID). - Exec(ctx); err != nil { - return err - } - - for i, filterKeyword := range filter.Keywords { - if _, err := NewUpsert(tx). - Model(filterKeyword). - Constraint("id"). - Column(filterKeywordColumns[i]...). - Exec(ctx); err != nil { - return err - } - } - - if len(filter.Statuses) > 0 { - if _, err := tx. - NewInsert(). - Ignore(). - Model(&filter.Statuses). - Exec(ctx); err != nil { - return err - } - } - - if len(deleteFilterKeywordIDs) > 0 { - if _, err := tx. - NewDelete(). - Model((*gtsmodel.FilterKeyword)(nil)). - Where("? = (?)", bun.Ident("id"), bun.In(deleteFilterKeywordIDs)). - Exec(ctx); err != nil { - return err - } - } - - if len(deleteFilterStatusIDs) > 0 { - if _, err := tx. - NewDelete(). - Model((*gtsmodel.FilterStatus)(nil)). - Where("? = (?)", bun.Ident("id"), bun.In(deleteFilterStatusIDs)). - Exec(ctx); err != nil { - return err - } - } - - return nil - }); err != nil { + Column(cols...). + Exec(ctx) return err - } - - // Update cache. - f.state.Caches.DB.Filter.Put(filter) - f.state.Caches.DB.FilterKeyword.Put(filter.Keywords...) - f.state.Caches.DB.FilterStatus.Put(filter.Statuses...) - // TODO: (Vyr) replace with cache multi-invalidate call - for _, id := range deleteFilterKeywordIDs { - f.state.Caches.DB.FilterKeyword.Invalidate("ID", id) - } - for _, id := range deleteFilterStatusIDs { - f.state.Caches.DB.FilterStatus.Invalidate("ID", id) - } - - return nil + }) } -func (f *filterDB) DeleteFilterByID(ctx context.Context, id string) error { +func (f *filterDB) DeleteFilter(ctx context.Context, filter *gtsmodel.Filter) error { if err := f.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { - // Delete all keywords attached to filter. + // Delete all keywords both known + // by filter, and possible stragglers, + // storing IDs in filter.KeywordIDs. if _, err := tx. NewDelete(). Model((*gtsmodel.FilterKeyword)(nil)). - Where("? = ?", bun.Ident("filter_id"), id). - Exec(ctx); err != nil { + Where("? = ?", bun.Ident("filter_id"), filter.ID). + Returning("?", bun.Ident("id")). + Exec(ctx, &filter.KeywordIDs); err != nil && + !errors.Is(err, db.ErrNoEntries) { return err } - // Delete all statuses attached to filter. + // Delete all statuses both known + // by filter, and possible stragglers. + // storing IDs in filter.StatusIDs. if _, err := tx. NewDelete(). Model((*gtsmodel.FilterStatus)(nil)). - Where("? = ?", bun.Ident("filter_id"), id). - Exec(ctx); err != nil { + Where("? = ?", bun.Ident("filter_id"), filter.ID). + Returning("?", bun.Ident("id")). + Exec(ctx, &filter.StatusIDs); err != nil && + !errors.Is(err, db.ErrNoEntries) { return err } - // Delete the filter itself. + // Delete filter itself. _, err := tx. NewDelete(). Model((*gtsmodel.Filter)(nil)). - Where("? = ?", bun.Ident("id"), id). + Where("? = ?", bun.Ident("id"), filter.ID). Exec(ctx) return err }); err != nil { return err } - // Invalidate this filter. - f.state.Caches.DB.Filter.Invalidate("ID", id) - - // Invalidate all keywords and statuses for this filter. - f.state.Caches.DB.FilterKeyword.Invalidate("FilterID", id) - f.state.Caches.DB.FilterStatus.Invalidate("FilterID", id) + // Invalidate the filter itself, and + // call invalidate hook in-case not cached. + f.state.Caches.DB.Filter.Invalidate("ID", filter.ID) + f.state.Caches.OnInvalidateFilter(filter) return nil } diff --git a/internal/db/bundb/filter_test.go b/internal/db/bundb/filter_test.go index 12f3476ed..ce41ac016 100644 --- a/internal/db/bundb/filter_test.go +++ b/internal/db/bundb/filter_test.go @@ -38,26 +38,30 @@ func (suite *FilterTestSuite) TestFilterCRUD() { // Create new example filter with attached keyword. filter := >smodel.Filter{ - ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", - AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47", - Title: "foss jail", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), - ContextPublic: util.Ptr(true), + ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", + AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47", + Title: "foss jail", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), } filterKeyword := >smodel.FilterKeyword{ - ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4", - AccountID: filter.AccountID, - FilterID: filter.ID, - Keyword: "GNU/Linux", + ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4", + FilterID: filter.ID, + Keyword: "GNU/Linux", } filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} + filter.KeywordIDs = []string{filterKeyword.ID} // Create new cancellable test context. ctx := suite.T().Context() ctx, cncl := context.WithCancel(ctx) defer cncl() + // Insert the example filter keyword into db. + if err := suite.db.PutFilterKeyword(ctx, filterKeyword); err != nil { + t.Fatalf("error inserting filter keyword: %v", err) + } + // Insert the example filter into db. if err := suite.db.PutFilter(ctx, filter); err != nil { t.Fatalf("error inserting filter: %v", err) @@ -74,27 +78,18 @@ func (suite *FilterTestSuite) TestFilterCRUD() { suite.Equal(filter.AccountID, check.AccountID) suite.Equal(filter.Title, check.Title) suite.Equal(filter.Action, check.Action) - suite.Equal(filter.ContextHome, check.ContextHome) - suite.Equal(filter.ContextNotifications, check.ContextNotifications) - suite.Equal(filter.ContextPublic, check.ContextPublic) - suite.Equal(filter.ContextThread, check.ContextThread) - suite.Equal(filter.ContextAccount, check.ContextAccount) - suite.NotZero(check.CreatedAt) - suite.NotZero(check.UpdatedAt) + suite.Equal(filter.Contexts, check.Contexts) suite.Equal(len(filter.Keywords), len(check.Keywords)) suite.Equal(filter.Keywords[0].ID, check.Keywords[0].ID) - suite.Equal(filter.Keywords[0].AccountID, check.Keywords[0].AccountID) suite.Equal(filter.Keywords[0].FilterID, check.Keywords[0].FilterID) suite.Equal(filter.Keywords[0].Keyword, check.Keywords[0].Keyword) suite.Equal(filter.Keywords[0].FilterID, check.Keywords[0].FilterID) - suite.NotZero(check.Keywords[0].CreatedAt) - suite.NotZero(check.Keywords[0].UpdatedAt) suite.Equal(len(filter.Statuses), len(check.Statuses)) // Fetch all filters. - all, err := suite.db.GetFiltersForAccountID(ctx, filter.AccountID) + all, err := suite.db.GetFiltersByAccountID(ctx, filter.AccountID) if err != nil { t.Fatalf("error fetching filters: %v", err) } @@ -108,28 +103,39 @@ func (suite *FilterTestSuite) TestFilterCRUD() { suite.Empty(all[0].Statuses) - // Update the filter context and add another keyword and a status. - check.ContextNotifications = util.Ptr(true) - + // Update the filter context and + // add another keyword and a status. + check.Contexts.SetNotifications() newKeyword := >smodel.FilterKeyword{ - ID: "01HNEMY810E5XKWDDMN5ZRE749", - FilterID: filter.ID, - AccountID: filter.AccountID, - Keyword: "tux", + ID: "01HNEMY810E5XKWDDMN5ZRE749", + FilterID: filter.ID, + Keyword: "tux", } check.Keywords = append(check.Keywords, newKeyword) - + check.KeywordIDs = append(check.KeywordIDs, newKeyword.ID) newStatus := >smodel.FilterStatus{ - ID: "01HNEMYD5XE7C8HH8TNCZ76FN2", - FilterID: filter.ID, - AccountID: filter.AccountID, - StatusID: "01HNEKZW34SQZ8PSDQ0Z10NZES", + ID: "01HNEMYD5XE7C8HH8TNCZ76FN2", + FilterID: filter.ID, + StatusID: "01HNEKZW34SQZ8PSDQ0Z10NZES", } check.Statuses = append(check.Statuses, newStatus) + check.StatusIDs = append(check.StatusIDs, newStatus.ID) - if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{nil, nil}, nil, nil); err != nil { + // Insert the new filter keyword. + if err := suite.db.PutFilterKeyword(ctx, newKeyword); err != nil { + t.Fatalf("error inserting filter keyword: %v", err) + } + + // Insert the new filter status. + if err := suite.db.PutFilterStatus(ctx, newStatus); err != nil { + t.Fatalf("error inserting filter status: %v", err) + } + + // Now update the filter with new keyword and status. + if err := suite.db.UpdateFilter(ctx, check); err != nil { t.Fatalf("error updating filter: %v", err) } + // Now fetch newly updated filter. check, err = suite.db.GetFilterByID(ctx, filter.ID) if err != nil { @@ -137,22 +143,11 @@ func (suite *FilterTestSuite) TestFilterCRUD() { } // Ensure expected fields were modified on check filter. - suite.True(check.UpdatedAt.After(filter.UpdatedAt)) - if suite.NotNil(check.ContextHome) { - suite.True(*check.ContextHome) - } - if suite.NotNil(check.ContextNotifications) { - suite.True(*check.ContextNotifications) - } - if suite.NotNil(check.ContextPublic) { - suite.True(*check.ContextPublic) - } - if suite.NotNil(check.ContextThread) { - suite.False(*check.ContextThread) - } - if suite.NotNil(check.ContextAccount) { - suite.False(*check.ContextAccount) - } + suite.True(check.Contexts.Home()) + suite.True(check.Contexts.Notifications()) + suite.True(check.Contexts.Public()) + suite.False(check.Contexts.Thread()) + suite.False(check.Contexts.Account()) // Ensure keyword entries were added. suite.Len(check.Keywords, 2) @@ -175,9 +170,19 @@ func (suite *FilterTestSuite) TestFilterCRUD() { check.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} check.Statuses = nil - if err := suite.db.UpdateFilter(ctx, check, nil, [][]string{{"whole_word"}}, []string{newKeyword.ID}, nil); err != nil { + // Update the original filter keyword. + filterKeyword.WholeWord = util.Ptr(true) + if err := suite.db.UpdateFilterKeyword(ctx, filterKeyword); err != nil { + t.Fatalf("error updating filter keyword: %v", err) + } + + // Drop most recently added filter keyword from filter. + check.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} + check.KeywordIDs = []string{filterKeyword.ID} + if err := suite.db.UpdateFilter(ctx, check); err != nil { t.Fatalf("error updating filter: %v", err) } + check, err = suite.db.GetFilterByID(ctx, filter.ID) if err != nil { t.Fatalf("error fetching updated filter: %v", err) @@ -186,23 +191,14 @@ func (suite *FilterTestSuite) TestFilterCRUD() { // Ensure expected fields were not modified. suite.Equal(filter.Title, check.Title) suite.Equal(gtsmodel.FilterActionWarn, check.Action) - if suite.NotNil(check.ContextHome) { - suite.True(*check.ContextHome) - } - if suite.NotNil(check.ContextNotifications) { - suite.True(*check.ContextNotifications) - } - if suite.NotNil(check.ContextPublic) { - suite.True(*check.ContextPublic) - } - if suite.NotNil(check.ContextThread) { - suite.False(*check.ContextThread) - } - if suite.NotNil(check.ContextAccount) { - suite.False(*check.ContextAccount) - } + suite.True(check.Contexts.Home()) + suite.True(check.Contexts.Notifications()) + suite.True(check.Contexts.Public()) + suite.False(check.Contexts.Thread()) + suite.False(check.Contexts.Account()) - // Ensure only changed field of keyword was modified, and other keyword was deleted. + // Ensure only changed field of keyword was + // modified, and other keyword was deleted. suite.Len(check.Keywords, 1) suite.Equal(filterKeyword.ID, check.Keywords[0].ID) suite.Equal("GNU/Linux", check.Keywords[0].Keyword) @@ -214,29 +210,8 @@ func (suite *FilterTestSuite) TestFilterCRUD() { suite.Len(check.Statuses, 1) suite.Equal(newStatus.ID, check.Statuses[0].ID) - // Add another status entry for the same status ID. It should be ignored without problems. - redundantStatus := >smodel.FilterStatus{ - ID: "01HQXJ5Y405XZSQ67C2BSQ6HJ0", - FilterID: filter.ID, - AccountID: filter.AccountID, - StatusID: newStatus.StatusID, - } - check.Statuses = []*gtsmodel.FilterStatus{redundantStatus} - 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) - if err != nil { - t.Fatalf("error fetching updated filter: %v", err) - } - - // Ensure status entry was not deleted, updated, or duplicated. - suite.Len(check.Statuses, 1) - suite.Equal(newStatus.ID, check.Statuses[0].ID) - suite.Equal(newStatus.StatusID, check.Statuses[0].StatusID) - // Now delete the filter from the DB. - if err := suite.db.DeleteFilterByID(ctx, filter.ID); err != nil { + if err := suite.db.DeleteFilter(ctx, filter); err != nil { t.Fatalf("error deleting filter: %v", err) } @@ -256,11 +231,11 @@ func (suite *FilterTestSuite) TestFilterTitleOverlap() { // Create an empty filter for account 1. account1filter1 := >smodel.Filter{ - ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", - AccountID: account1, - Title: "my filter", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), + ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", + AccountID: account1, + Title: "my filter", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome), } if err := suite.db.PutFilter(ctx, account1filter1); err != nil { suite.FailNow("", "error putting account1filter1: %s", err) @@ -269,11 +244,11 @@ func (suite *FilterTestSuite) TestFilterTitleOverlap() { // Create a filter for account 2 with // the same title, should be no issue. account2filter1 := >smodel.Filter{ - ID: "01JAG5GPXG7H5Y4ZP78GV1F2ET", - AccountID: account2, - Title: "my filter", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), + ID: "01JAG5GPXG7H5Y4ZP78GV1F2ET", + AccountID: account2, + Title: "my filter", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome), } if err := suite.db.PutFilter(ctx, account2filter1); err != nil { suite.FailNow("", "error putting account2filter1: %s", err) @@ -283,11 +258,11 @@ func (suite *FilterTestSuite) TestFilterTitleOverlap() { // account 1 with the same name as // an existing filter of theirs. account1filter2 := >smodel.Filter{ - ID: "01JAG5J8NYKQE2KYCD28Y4P05V", - AccountID: account1, - Title: "my filter", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), + ID: "01JAG5J8NYKQE2KYCD28Y4P05V", + AccountID: account1, + Title: "my filter", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome), } err := suite.db.PutFilter(ctx, account1filter2) if !errors.Is(err, db.ErrAlreadyExists) { diff --git a/internal/db/bundb/filterkeyword.go b/internal/db/bundb/filterkeyword.go index 1c80061e9..1476245b0 100644 --- a/internal/db/bundb/filterkeyword.go +++ b/internal/db/bundb/filterkeyword.go @@ -20,9 +20,7 @@ package bundb import ( "context" "slices" - "time" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -31,7 +29,7 @@ import ( ) func (f *filterDB) GetFilterKeywordByID(ctx context.Context, id string) (*gtsmodel.FilterKeyword, error) { - filterKeyword, err := f.state.Caches.DB.FilterKeyword.LoadOne( + return f.state.Caches.DB.FilterKeyword.LoadOne( "ID", func() (*gtsmodel.FilterKeyword, error) { var filterKeyword gtsmodel.FilterKeyword @@ -54,64 +52,16 @@ func (f *filterDB) GetFilterKeywordByID(ctx context.Context, id string) (*gtsmod }, id, ) - if err != nil { - return nil, err - } - - if !gtscontext.Barebones(ctx) { - err = f.populateFilterKeyword(ctx, filterKeyword) - if err != nil { - return nil, err - } - } - - return filterKeyword, nil } -func (f *filterDB) populateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) (err error) { - if filterKeyword.Filter == nil { - // Filter is not set, fetch from the cache or database. - filterKeyword.Filter, err = f.state.DB.GetFilterByID( - - // Don't populate the filter with all of its keywords - // and statuses or we'll just end up back here. - gtscontext.SetBarebones(ctx), - filterKeyword.FilterID, - ) - if err != nil { - return err - } - } - return nil -} - -func (f *filterDB) GetFilterKeywordsForFilterID(ctx context.Context, filterID string) ([]*gtsmodel.FilterKeyword, error) { - return f.getFilterKeywords(ctx, "filter_id", filterID) -} - -func (f *filterDB) GetFilterKeywordsForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.FilterKeyword, error) { - return f.getFilterKeywords(ctx, "account_id", accountID) -} - -func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id string) ([]*gtsmodel.FilterKeyword, error) { - var filterKeywordIDs []string - - if err := f.db. - NewSelect(). - Model((*gtsmodel.FilterKeyword)(nil)). - Column("id"). - Where("? = ?", bun.Ident(idColumn), id). - Scan(ctx, &filterKeywordIDs); err != nil { - return nil, err - } - - if len(filterKeywordIDs) == 0 { +func (f *filterDB) GetFilterKeywordsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.FilterKeyword, error) { + if len(ids) == 0 { return nil, nil } // Get each filter keyword by ID from the cache or DB. filterKeywords, err := f.state.Caches.DB.FilterKeyword.LoadIDs("ID", - filterKeywordIDs, + ids, func(uncached []string) ([]*gtsmodel.FilterKeyword, error) { filterKeywords := make([]*gtsmodel.FilterKeyword, 0, len(uncached)) @@ -140,23 +90,10 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st } // Put the filter keyword structs in the same order as the filter keyword IDs. - xslices.OrderBy(filterKeywords, filterKeywordIDs, func(filterKeyword *gtsmodel.FilterKeyword) string { + xslices.OrderBy(filterKeywords, ids, func(filterKeyword *gtsmodel.FilterKeyword) string { return filterKeyword.ID }) - if gtscontext.Barebones(ctx) { - return filterKeywords, nil - } - - // Populate the filter keywords. Remove any that we can't populate from the return slice. - filterKeywords = slices.DeleteFunc(filterKeywords, func(filterKeyword *gtsmodel.FilterKeyword) bool { - if err := f.populateFilterKeyword(ctx, filterKeyword); err != nil { - log.Errorf(ctx, "error populating filter keyword: %v", err) - return true - } - return false - }) - return filterKeywords, nil } @@ -178,11 +115,7 @@ func (f *filterDB) PutFilterKeyword(ctx context.Context, filterKeyword *gtsmodel }) } -func (f *filterDB) UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword, columns ...string) error { - filterKeyword.UpdatedAt = time.Now() - if len(columns) > 0 { - columns = append(columns, "updated_at") - } +func (f *filterDB) UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword, cols ...string) error { if filterKeyword.Regexp == nil { // Ensure regexp is compiled // before attempted caching. @@ -196,22 +129,20 @@ func (f *filterDB) UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmo NewUpdate(). Model(filterKeyword). Where("? = ?", bun.Ident("id"), filterKeyword.ID). - Column(columns...). + Column(cols...). Exec(ctx) return err }) } -func (f *filterDB) DeleteFilterKeywordByID(ctx context.Context, id string) error { +func (f *filterDB) DeleteFilterKeywordsByIDs(ctx context.Context, ids ...string) error { if _, err := f.db. NewDelete(). Model((*gtsmodel.FilterKeyword)(nil)). - Where("? = ?", bun.Ident("id"), id). + Where("? IN (?)", bun.Ident("id"), bun.In(ids)). Exec(ctx); err != nil { return err } - - f.state.Caches.DB.FilterKeyword.Invalidate("ID", id) - + f.state.Caches.DB.FilterKeyword.InvalidateIDs("ID", ids) return nil } diff --git a/internal/db/bundb/filterkeyword_test.go b/internal/db/bundb/filterkeyword_test.go index ab814d413..bce308f42 100644 --- a/internal/db/bundb/filterkeyword_test.go +++ b/internal/db/bundb/filterkeyword_test.go @@ -32,12 +32,11 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() { // Create new filter. filter := >smodel.Filter{ - ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", - AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47", - Title: "foss jail", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), - ContextPublic: util.Ptr(true), + ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", + AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47", + Title: "foss jail", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), } // Create new cancellable test context. @@ -51,19 +50,11 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() { t.Fatalf("error inserting filter: %v", err) } - // There should be no filter keywords yet. - all, err := suite.db.GetFilterKeywordsForAccountID(ctx, filter.AccountID) - if err != nil { - t.Fatalf("error fetching filter keywords: %v", err) - } - suite.Empty(all) - // Add a filter keyword to it. filterKeyword := >smodel.FilterKeyword{ - ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4", - AccountID: filter.AccountID, - FilterID: filter.ID, - Keyword: "GNU/Linux", + ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4", + FilterID: filter.ID, + Keyword: "GNU/Linux", } // Insert the new filter keyword into the DB. @@ -78,28 +69,17 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() { t.Fatalf("error fetching filter keyword: %v", err) } suite.Equal(filterKeyword.ID, check.ID) - suite.NotZero(check.CreatedAt) - suite.NotZero(check.UpdatedAt) - suite.Equal(filterKeyword.AccountID, check.AccountID) suite.Equal(filterKeyword.FilterID, check.FilterID) suite.Equal(filterKeyword.Keyword, check.Keyword) suite.Equal(filterKeyword.WholeWord, check.WholeWord) - // Loading filter keywords by account ID should find the one we inserted. - all, err = suite.db.GetFilterKeywordsForAccountID(ctx, filter.AccountID) + // Check that fetching multiple filter keywords by IDs works. + checks, err := suite.db.GetFilterKeywordsByIDs(ctx, []string{filterKeyword.ID}) if err != nil { t.Fatalf("error fetching filter keywords: %v", err) } - suite.Len(all, 1) - suite.Equal(filterKeyword.ID, all[0].ID) - - // Loading filter keywords by filter ID should also find the one we inserted. - all, err = suite.db.GetFilterKeywordsForFilterID(ctx, filter.ID) - if err != nil { - t.Fatalf("error fetching filter keywords: %v", err) - } - suite.Len(all, 1) - suite.Equal(filterKeyword.ID, all[0].ID) + suite.Len(checks, 1) + suite.Equal(filterKeyword.ID, checks[0].ID) // Modify the filter keyword. filterKeyword.WholeWord = util.Ptr(true) @@ -114,15 +94,12 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() { t.Fatalf("error fetching filter keyword: %v", err) } suite.Equal(filterKeyword.ID, check.ID) - suite.NotZero(check.CreatedAt) - suite.True(check.UpdatedAt.After(check.CreatedAt)) - suite.Equal(filterKeyword.AccountID, check.AccountID) suite.Equal(filterKeyword.FilterID, check.FilterID) suite.Equal(filterKeyword.Keyword, check.Keyword) suite.Equal(filterKeyword.WholeWord, check.WholeWord) // Delete the filter keyword from the DB. - err = suite.db.DeleteFilterKeywordByID(ctx, filter.ID) + err = suite.db.DeleteFilterKeywordsByIDs(ctx, filter.ID) if err != nil { t.Fatalf("error deleting filter keyword: %v", err) } diff --git a/internal/db/bundb/filterstatus.go b/internal/db/bundb/filterstatus.go index a14e2a7b4..d15705a14 100644 --- a/internal/db/bundb/filterstatus.go +++ b/internal/db/bundb/filterstatus.go @@ -19,86 +19,38 @@ package bundb import ( "context" - "slices" - "time" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" - "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/util/xslices" "github.com/uptrace/bun" ) func (f *filterDB) GetFilterStatusByID(ctx context.Context, id string) (*gtsmodel.FilterStatus, error) { - filterStatus, err := f.state.Caches.DB.FilterStatus.LoadOne( + return f.state.Caches.DB.FilterStatus.LoadOne( "ID", func() (*gtsmodel.FilterStatus, error) { var filterStatus gtsmodel.FilterStatus - err := f.db. + if err := f.db. NewSelect(). Model(&filterStatus). Where("? = ?", bun.Ident("id"), id). - Scan(ctx) - return &filterStatus, err + Scan(ctx); err != nil { + return nil, err + } + return &filterStatus, nil }, id, ) - if err != nil { - return nil, err - } - - if !gtscontext.Barebones(ctx) { - err = f.populateFilterStatus(ctx, filterStatus) - if err != nil { - return nil, err - } - } - - return filterStatus, nil } -func (f *filterDB) populateFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) error { - if filterStatus.Filter == nil { - // Filter is not set, fetch from the cache or database. - filter, err := f.state.DB.GetFilterByID( - // Don't populate the filter with all of its keywords and statuses or we'll just end up back here. - gtscontext.SetBarebones(ctx), - filterStatus.FilterID, - ) - if err != nil { - return err - } - filterStatus.Filter = filter - } - - return nil -} - -func (f *filterDB) GetFilterStatusesForFilterID(ctx context.Context, filterID string) ([]*gtsmodel.FilterStatus, error) { - return f.getFilterStatuses(ctx, "filter_id", filterID) -} - -func (f *filterDB) GetFilterStatusesForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.FilterStatus, error) { - return f.getFilterStatuses(ctx, "account_id", accountID) -} - -func (f *filterDB) getFilterStatuses(ctx context.Context, idColumn string, id string) ([]*gtsmodel.FilterStatus, error) { - var filterStatusIDs []string - if err := f.db. - NewSelect(). - Model((*gtsmodel.FilterStatus)(nil)). - Column("id"). - Where("? = ?", bun.Ident(idColumn), id). - Scan(ctx, &filterStatusIDs); err != nil { - return nil, err - } - if len(filterStatusIDs) == 0 { +func (f *filterDB) GetFilterStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.FilterStatus, error) { + if len(ids) == 0 { return nil, nil } // Get each filter status by ID from the cache or DB. filterStatuses, err := f.state.Caches.DB.FilterStatus.LoadIDs("ID", - filterStatusIDs, + ids, func(uncached []string) ([]*gtsmodel.FilterStatus, error) { filterStatuses := make([]*gtsmodel.FilterStatus, 0, len(uncached)) if err := f.db. @@ -116,29 +68,11 @@ func (f *filterDB) getFilterStatuses(ctx context.Context, idColumn string, id st } // Put the filter status structs in the same order as the filter status IDs. - xslices.OrderBy(filterStatuses, filterStatusIDs, func(filterStatus *gtsmodel.FilterStatus) string { + xslices.OrderBy(filterStatuses, ids, func(filterStatus *gtsmodel.FilterStatus) string { return filterStatus.ID }) - if gtscontext.Barebones(ctx) { - return filterStatuses, nil - } - - // Populate the filter statuses. Remove any that we can't populate from the return slice. - errs := gtserror.NewMultiError(len(filterStatuses)) - filterStatuses = slices.DeleteFunc(filterStatuses, func(filterStatus *gtsmodel.FilterStatus) bool { - if err := f.populateFilterStatus(ctx, filterStatus); err != nil { - errs.Appendf( - "error populating filter status %s: %w", - filterStatus.ID, - err, - ) - return true - } - return false - }) - - return filterStatuses, errs.Combine() + return filterStatuses, nil } func (f *filterDB) PutFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) error { @@ -152,11 +86,6 @@ func (f *filterDB) PutFilterStatus(ctx context.Context, filterStatus *gtsmodel.F } func (f *filterDB) UpdateFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus, columns ...string) error { - filterStatus.UpdatedAt = time.Now() - if len(columns) > 0 { - columns = append(columns, "updated_at") - } - return f.state.Caches.DB.FilterStatus.Store(filterStatus, func() error { _, err := f.db. NewUpdate(). @@ -168,16 +97,14 @@ func (f *filterDB) UpdateFilterStatus(ctx context.Context, filterStatus *gtsmode }) } -func (f *filterDB) DeleteFilterStatusByID(ctx context.Context, id string) error { +func (f *filterDB) DeleteFilterStatusesByIDs(ctx context.Context, ids ...string) error { if _, err := f.db. NewDelete(). Model((*gtsmodel.FilterStatus)(nil)). - Where("? = ?", bun.Ident("id"), id). + Where("? IN (?)", bun.Ident("id"), bun.In(ids)). Exec(ctx); err != nil { return err } - - f.state.Caches.DB.FilterStatus.Invalidate("ID", id) - + f.state.Caches.DB.FilterStatus.InvalidateIDs("ID", ids) return nil } diff --git a/internal/db/bundb/filterstatus_test.go b/internal/db/bundb/filterstatus_test.go index 9485ee3f7..27f5c17b3 100644 --- a/internal/db/bundb/filterstatus_test.go +++ b/internal/db/bundb/filterstatus_test.go @@ -23,7 +23,6 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" - "code.superseriousbusiness.org/gotosocial/internal/util" ) // TestFilterStatusCRD tests CRD (no U) and read-all operations on filter statuses. @@ -32,12 +31,11 @@ func (suite *FilterTestSuite) TestFilterStatusCRD() { // Create new filter. filter := >smodel.Filter{ - ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", - AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47", - Title: "foss jail", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), - ContextPublic: util.Ptr(true), + ID: "01HNEJNVZZVXJTRB3FX3K2B1YF", + AccountID: "01HNEJXCPRTJVJY9MV0VVHGD47", + Title: "foss jail", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), } // Create new cancellable test context. @@ -51,19 +49,11 @@ func (suite *FilterTestSuite) TestFilterStatusCRD() { t.Fatalf("error inserting filter: %v", err) } - // There should be no filter statuses yet. - all, err := suite.db.GetFilterStatusesForAccountID(ctx, filter.AccountID) - if err != nil { - t.Fatalf("error fetching filter statuses: %v", err) - } - suite.Empty(all) - // Add a filter status to it. filterStatus := >smodel.FilterStatus{ - ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4", - AccountID: filter.AccountID, - FilterID: filter.ID, - StatusID: "01HQXGMQ3QFXRT4GX9WNQ8KC0X", + ID: "01HNEK4RW5QEAMG9Y4ET6ST0J4", + FilterID: filter.ID, + StatusID: "01HQXGMQ3QFXRT4GX9WNQ8KC0X", } // Insert the new filter status into the DB. @@ -78,30 +68,19 @@ func (suite *FilterTestSuite) TestFilterStatusCRD() { t.Fatalf("error fetching filter status: %v", err) } suite.Equal(filterStatus.ID, check.ID) - suite.NotZero(check.CreatedAt) - suite.NotZero(check.UpdatedAt) - suite.Equal(filterStatus.AccountID, check.AccountID) suite.Equal(filterStatus.FilterID, check.FilterID) suite.Equal(filterStatus.StatusID, check.StatusID) - // Loading filter statuses by account ID should find the one we inserted. - all, err = suite.db.GetFilterStatusesForAccountID(ctx, filter.AccountID) + // Check that fetching multiple filter statuses by IDs works. + checks, err := suite.db.GetFilterStatusesByIDs(ctx, []string{filterStatus.ID}) if err != nil { t.Fatalf("error fetching filter statuses: %v", err) } - suite.Len(all, 1) - suite.Equal(filterStatus.ID, all[0].ID) - - // Loading filter statuses by filter ID should also find the one we inserted. - all, err = suite.db.GetFilterStatusesForFilterID(ctx, filter.ID) - if err != nil { - t.Fatalf("error fetching filter statuses: %v", err) - } - suite.Len(all, 1) - suite.Equal(filterStatus.ID, all[0].ID) + suite.Len(checks, 1) + suite.Equal(filterStatus.ID, checks[0].ID) // Delete the filter status from the DB. - err = suite.db.DeleteFilterStatusByID(ctx, filter.ID) + err = suite.db.DeleteFilterStatusesByIDs(ctx, filter.ID) if err != nil { t.Fatalf("error deleting filter status: %v", err) } diff --git a/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go b/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go index a1eb700a7..b7b185f99 100644 --- a/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go +++ b/internal/db/bundb/migrations/20241018151036_filter_unique_fix.go @@ -20,7 +20,7 @@ package migrations import ( "context" - "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + gtsmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20241018151036_filter_unique_fix" "github.com/uptrace/bun" "github.com/uptrace/bun/dialect" ) diff --git a/internal/db/bundb/migrations/20241018151036_filter_unique_fix/filter.go b/internal/db/bundb/migrations/20241018151036_filter_unique_fix/filter.go new file mode 100644 index 000000000..e90eabaac --- /dev/null +++ b/internal/db/bundb/migrations/20241018151036_filter_unique_fix/filter.go @@ -0,0 +1,77 @@ +// 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 gtsmodel + +import ( + "regexp" + "time" +) + +// Filter stores a filter created by a local account. +type Filter struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire. + AccountID string `bun:"type:CHAR(26),notnull,nullzero,unique:filters_account_id_title_uniq"` // ID of the local account that created the filter. + Title string `bun:",nullzero,notnull,unique:filters_account_id_title_uniq"` // The name of the filter. + Action FilterAction `bun:",nullzero,notnull"` // The action to take. + Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter. + Statuses []*FilterStatus `bun:"-"` // Statuses for this filter. + ContextHome *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists. + ContextNotifications *bool `bun:",nullzero,notnull,default:false"` // Apply filter to notifications. + ContextPublic *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists. + ContextThread *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing a status's associated thread. + ContextAccount *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing an account profile. +} + +// FilterKeyword stores a single keyword to filter statuses against. +type FilterKeyword struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword. + FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to. + Filter *Filter `bun:"-"` // Filter corresponding to FilterID + Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against. + WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries? + Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression +} + +// FilterStatus stores a single status to filter. +type FilterStatus struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword. + FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to. + Filter *Filter `bun:"-"` // Filter corresponding to FilterID + StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter. +} + +// FilterAction represents the action to take on a filtered status. +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. + FilterActionHide FilterAction = "hide" +) diff --git a/internal/db/bundb/migrations/20250617122055_filter_improvements.go b/internal/db/bundb/migrations/20250617122055_filter_improvements.go new file mode 100644 index 000000000..09fde089e --- /dev/null +++ b/internal/db/bundb/migrations/20250617122055_filter_improvements.go @@ -0,0 +1,303 @@ +// 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 migrations + +import ( + "context" + "database/sql" + "errors" + "reflect" + "strings" + + oldmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20241018151036_filter_unique_fix" + newmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250617122055_filter_improvements" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + // Replace 'context_*' and 'action' columns with space-saving enum / bitfields. + if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + newFilterType := reflect.TypeOf((*newmodel.Filter)(nil)) + + // Generate bun definition for new filter table contexts column. + newColDef, err := getBunColumnDef(tx, newFilterType, "Contexts") + if err != nil { + return gtserror.Newf("error getting bun column def: %w", err) + } + + // Add new column type to table. + if _, err := tx.NewAddColumn(). + Model((*oldmodel.Filter)(nil)). + ColumnExpr(newColDef). + Exec(ctx); err != nil { + return gtserror.Newf("error adding filter.contexts column: %w", err) + } + + // Generate bun definition for new filter table action column. + newColDef, err = getBunColumnDef(tx, newFilterType, "Action") + if err != nil { + return gtserror.Newf("error getting bun column def: %w", err) + } + + // For now, name it as '_new'. + newColDef = strings.ReplaceAll( + newColDef, + "action", + "action_new", + ) + + // Add new column type to table. + if _, err := tx.NewAddColumn(). + Model((*oldmodel.Filter)(nil)). + ColumnExpr(newColDef). + Exec(ctx); err != nil { + return gtserror.Newf("error adding filter.contexts column: %w", err) + } + + var oldFilters []*oldmodel.Filter + + // Select all filters. + if err := tx.NewSelect(). + Model(&oldFilters). + Column("id", + "context_home", + "context_notifications", + "context_public", + "context_thread", + "context_account", + "action"). + Scan(ctx); err != nil { + return gtserror.Newf("error selecting filters: %w", err) + } + + for _, oldFilter := range oldFilters { + var newContexts newmodel.FilterContexts + var newAction newmodel.FilterAction + + // Convert old contexts + // to new contexts type. + if *oldFilter.ContextHome { + newContexts.SetHome() + } + if *oldFilter.ContextNotifications { + newContexts.SetNotifications() + } + if *oldFilter.ContextPublic { + newContexts.SetPublic() + } + if *oldFilter.ContextThread { + newContexts.SetThread() + } + if *oldFilter.ContextAccount { + newContexts.SetAccount() + } + + // Convert old action + // to new action type. + switch oldFilter.Action { + case oldmodel.FilterActionHide: + newAction = newmodel.FilterActionHide + case oldmodel.FilterActionWarn: + newAction = newmodel.FilterActionWarn + default: + return gtserror.Newf("invalid filter action %q for %s", oldFilter.Action, oldFilter.ID) + } + + // Update filter row with + // the new contexts value. + if _, err := tx.NewUpdate(). + Model((*oldmodel.Filter)(nil)). + Where("? = ?", bun.Ident("id"), oldFilter.ID). + Set("? = ?", bun.Ident("contexts"), newContexts). + Set("? = ?", bun.Ident("action_new"), newAction). + Exec(ctx); err != nil { + return gtserror.Newf("error updating filter.contexts: %w", err) + } + } + + // Drop the old updated columns. + for _, col := range []string{ + "context_home", + "context_notifications", + "context_public", + "context_thread", + "context_account", + "action", + } { + if _, err := tx.NewDropColumn(). + Model((*oldmodel.Filter)(nil)). + Column(col). + Exec(ctx); err != nil { + return gtserror.Newf("error dropping filter.%s column: %w", col, err) + } + } + + // Rename the new action + // column to correct name. + if _, err := tx.NewRaw( + "ALTER TABLE ? RENAME COLUMN ? TO ?", + bun.Ident("filters"), + bun.Ident("action_new"), + bun.Ident("action"), + ).Exec(ctx); err != nil { + return gtserror.Newf("error renaming new action column: %w", err) + } + + return nil + }); err != nil { + return err + } + + // SQLITE: force WAL checkpoint to merge writes. + if err := doWALCheckpoint(ctx, db); err != nil { + return err + } + + // Drop a bunch of (now, and more generally) unused columns from filter tables. + if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + for model, indices := range map[any][]string{ + (*oldmodel.FilterKeyword)(nil): {"filter_keywords_account_id_idx"}, + (*oldmodel.FilterStatus)(nil): {"filter_statuses_account_id_idx"}, + } { + for _, index := range indices { + if _, err := tx.NewDropIndex(). + Model(model). + Index(index). + Exec(ctx); err != nil { + return gtserror.Newf("error dropping %s index: %w", index, err) + } + } + } + for model, cols := range map[any][]string{ + (*oldmodel.Filter)(nil): {"created_at", "updated_at"}, + (*oldmodel.FilterKeyword)(nil): {"created_at", "updated_at", "account_id"}, + (*oldmodel.FilterStatus)(nil): {"created_at", "updated_at", "account_id"}, + } { + for _, col := range cols { + if _, err := tx.NewDropColumn(). + Model(model). + Column(col). + Exec(ctx); err != nil { + return gtserror.Newf("error dropping %T.%s column: %w", model, col, err) + } + } + } + return nil + }); err != nil { + return err + } + + // SQLITE: force WAL checkpoint to merge writes. + if err := doWALCheckpoint(ctx, db); err != nil { + return err + } + + // Create links from 'filters' table to 'filter_{keywords,statuses}' tables. + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + newFilterType := reflect.TypeOf((*newmodel.Filter)(nil)) + + var filterIDs string + + // Select all filter IDs. + if err := tx.NewSelect(). + Model((*newmodel.Filter)(nil)). + Column("id"). + Scan(ctx, &filterIDs); err != nil && !errors.Is(err, sql.ErrNoRows) { + return gtserror.Newf("error selecting filter ids: %w", err) + } + + for _, data := range []struct { + Field string + Model any + }{ + { + Field: "KeywordIDs", + Model: (*newmodel.FilterKeyword)(nil), + }, + { + Field: "StatusIDs", + Model: (*newmodel.FilterStatus)(nil), + }, + } { + // Generate bun definition for new filter table field column. + newColDef, err := getBunColumnDef(tx, newFilterType, data.Field) + if err != nil { + return gtserror.Newf("error getting bun column def: %w", err) + } + + // Add new column type to table. + if _, err := tx.NewAddColumn(). + Model((*oldmodel.Filter)(nil)). + ColumnExpr(newColDef). + Exec(ctx); err != nil { + return gtserror.Newf("error adding filter.%s column: %w", data.Field, err) + } + + // Get the SQL field information from bun for Filter{}.$Field. + field, _, err := getModelField(tx, newFilterType, data.Field) + if err != nil { + return gtserror.Newf("error getting bun model field: %w", err) + } + + // Extract column name. + col := field.SQLName + + var relatedIDs []string + for _, filterID := range filterIDs { + // Reset related IDs. + clear(relatedIDs) + relatedIDs = relatedIDs[:0] + + // Select $Model IDs that + // are attached to filterID. + if err := tx.NewSelect(). + Model(data.Model). + Column("id"). + Where("? = ?", bun.Ident("filter_id"), filterID). + Scan(ctx, &relatedIDs); err != nil { + return gtserror.Newf("error selecting %T ids: %w", data.Model, err) + } + + // Now update the relevant filter + // row to contain these related IDs. + if _, err := tx.NewUpdate(). + Model((*newmodel.Filter)(nil)). + Where("? = ?", bun.Ident("id"), filterID). + Set("? = ?", bun.Ident(col), relatedIDs). + Exec(ctx); err != nil { + return gtserror.Newf("error updating filters.%s ids: %w", col, err) + } + } + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/migrations/20250617122055_filter_improvements/filter.go b/internal/db/bundb/migrations/20250617122055_filter_improvements/filter.go new file mode 100644 index 000000000..20d3ba32e --- /dev/null +++ b/internal/db/bundb/migrations/20250617122055_filter_improvements/filter.go @@ -0,0 +1,243 @@ +// 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 gtsmodel + +import ( + "regexp" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/util" +) + +// smallint is the largest size supported +// by a PostgreSQL SMALLINT, since an SQLite +// SMALLINT is actually variable in size. +type smallint int16 + +// enumType is the type we (at least, should) use +// for database enum types, as smallest int size. +type enumType smallint + +// bitFieldType is the type we use +// for database int bit fields, at +// least where the smallest int size +// will suffice for number of fields. +type bitFieldType smallint + +// FilterContext represents the +// context in which a Filter applies. +// +// These are used as bit-field masks to determine +// which are enabled in a FilterContexts bit field, +// as well as to signify internally any particular +// context in which a status should be filtered in. +type FilterContext bitFieldType + +const ( + // FilterContextNone means no filters should + // be applied, this is for internal use only. + FilterContextNone FilterContext = 0 + + // FilterContextHome means this status is being + // filtered as part of a home or list timeline. + FilterContextHome FilterContext = 1 << 1 + + // FilterContextNotifications means this status is + // being filtered as part of the notifications timeline. + FilterContextNotifications FilterContext = 1 << 2 + + // FilterContextPublic means this status is + // being filtered as part of a public or tag timeline. + FilterContextPublic FilterContext = 1 << 3 + + // FilterContextThread means this status is + // being filtered as part of a thread's context. + FilterContextThread FilterContext = 1 << 4 + + // FilterContextAccount means this status is + // being filtered as part of an account's statuses. + FilterContextAccount FilterContext = 1 << 5 +) + +// FilterContexts stores multiple contexts +// in which a Filter applies as bits in an int. +type FilterContexts bitFieldType + +// Applies returns whether receiving FilterContexts applies in FilterContexts. +func (ctxs FilterContexts) Applies(ctx FilterContext) bool { + switch ctx { + case FilterContextHome: + return ctxs.Home() + case FilterContextNotifications: + return ctxs.Notifications() + case FilterContextPublic: + return ctxs.Public() + case FilterContextThread: + return ctxs.Thread() + case FilterContextAccount: + return ctxs.Account() + default: + return false + } +} + +// Home returns whether FilterContextHome is set. +func (ctxs FilterContexts) Home() bool { + return ctxs&FilterContexts(FilterContextHome) != 0 +} + +// SetHome will set the FilterContextHome bit. +func (ctxs *FilterContexts) SetHome() { + *ctxs |= FilterContexts(FilterContextHome) +} + +// UnsetHome will unset the FilterContextHome bit. +func (ctxs *FilterContexts) UnsetHome() { + *ctxs &= ^FilterContexts(FilterContextHome) +} + +// Notifications returns whether FilterContextNotifications is set. +func (ctxs FilterContexts) Notifications() bool { + return ctxs&FilterContexts(FilterContextNotifications) != 0 +} + +// SetNotifications will set the FilterContextNotifications bit. +func (ctxs *FilterContexts) SetNotifications() { + *ctxs |= FilterContexts(FilterContextNotifications) +} + +// UnsetNotifications will unset the FilterContextNotifications bit. +func (ctxs *FilterContexts) UnsetNotifications() { + *ctxs &= ^FilterContexts(FilterContextNotifications) +} + +// Public returns whether FilterContextPublic is set. +func (ctxs FilterContexts) Public() bool { + return ctxs&FilterContexts(FilterContextPublic) != 0 +} + +// SetPublic will set the FilterContextPublic bit. +func (ctxs *FilterContexts) SetPublic() { + *ctxs |= FilterContexts(FilterContextPublic) +} + +// UnsetPublic will unset the FilterContextPublic bit. +func (ctxs *FilterContexts) UnsetPublic() { + *ctxs &= ^FilterContexts(FilterContextPublic) +} + +// Thread returns whether FilterContextThread is set. +func (ctxs FilterContexts) Thread() bool { + return ctxs&FilterContexts(FilterContextThread) != 0 +} + +// SetThread will set the FilterContextThread bit. +func (ctxs *FilterContexts) SetThread() { + *ctxs |= FilterContexts(FilterContextThread) +} + +// UnsetThread will unset the FilterContextThread bit. +func (ctxs *FilterContexts) UnsetThread() { + *ctxs &= ^FilterContexts(FilterContextThread) +} + +// Account returns whether FilterContextAccount is set. +func (ctxs FilterContexts) Account() bool { + return ctxs&FilterContexts(FilterContextAccount) != 0 +} + +// SetAccount will set / unset the FilterContextAccount bit. +func (ctxs *FilterContexts) SetAccount() { + *ctxs |= FilterContexts(FilterContextAccount) +} + +// UnsetAccount will unset the FilterContextAccount bit. +func (ctxs *FilterContexts) UnsetAccount() { + *ctxs &= ^FilterContexts(FilterContextAccount) +} + +// FilterAction represents the action +// to take on a filtered status. +type FilterAction enumType + +const ( + // FilterActionNone filters should not exist, except + // internally, for partially constructed or invalid filters. + FilterActionNone FilterAction = 0 + + // FilterActionWarn means that the + // status should be shown behind a warning. + FilterActionWarn FilterAction = 1 + + // FilterActionHide means that the status should + // be removed from timeline results entirely. + FilterActionHide FilterAction = 2 +) + +// Filter stores a filter created by a local account. +type Filter struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire. + AccountID string `bun:"type:CHAR(26),notnull,nullzero,unique:filters_account_id_title_uniq"` // ID of the local account that created the filter. + Title string `bun:",nullzero,notnull,unique:filters_account_id_title_uniq"` // The name of the filter. + Action FilterAction `bun:",nullzero,notnull,default:0"` // The action to take. + Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter. + KeywordIDs []string `bun:"keywords,array"` // + Statuses []*FilterStatus `bun:"-"` // Statuses for this filter. + StatusIDs []string `bun:"statuses,array"` // + Contexts FilterContexts `bun:",nullzero,notnull,default:0"` // Which contexts does this filter apply in? +} + +// FilterKeyword stores a single keyword to filter statuses against. +type FilterKeyword struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to. + Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against. + WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries? + Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression +} + +// Compile will compile this FilterKeyword as a prepared regular expression. +func (k *FilterKeyword) Compile() (err error) { + var ( + wordBreakStart string + wordBreakEnd string + ) + + if util.PtrOrZero(k.WholeWord) { + // Either word boundary or + // whitespace or start of line. + wordBreakStart = `(?:\b|\s|^)` + + // Either word boundary or + // whitespace or end of line. + wordBreakEnd = `(?:\b|\s|$)` + } + + // Compile keyword filter regexp. + quoted := regexp.QuoteMeta(k.Keyword) + k.Regexp, err = regexp.Compile(`(?i)` + wordBreakStart + quoted + wordBreakEnd) + return // caller is expected to wrap this error +} + +// FilterStatus stores a single status to filter. +type FilterStatus struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to. + StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter. +} diff --git a/internal/db/filter.go b/internal/db/filter.go index 4962aa1de..89c527a68 100644 --- a/internal/db/filter.go +++ b/internal/db/filter.go @@ -23,81 +23,51 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) -// Filter contains methods for creating, reading, updating, and deleting filters and their keyword and status entries. +// Filter contains methods for creating, reading, updating, +// and deleting filters and their keyword and status entries. type Filter interface { - // // GetFilterByID gets one filter with the given id. GetFilterByID(ctx context.Context, id string) (*gtsmodel.Filter, error) - // GetFiltersForAccountID gets all filters owned by the given accountID. - GetFiltersForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) + // GetFiltersByAccountID gets all filters owned by the given accountID. + GetFiltersByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) // PutFilter puts a new filter in the database, adding any attached keywords or statuses. // It uses a transaction to ensure no partial updates. PutFilter(ctx context.Context, filter *gtsmodel.Filter) error - // UpdateFilter updates the given filter, - // upserts any attached keywords and inserts any new statuses (existing statuses cannot be updated), - // 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, - deleteFilterKeywordIDs []string, - deleteFilterStatusIDs []string, - ) error + // UpdateFilter ... + UpdateFilter(ctx context.Context, filter *gtsmodel.Filter, cols ...string) error - // DeleteFilterByID deletes one filter with the given ID. - // It uses a transaction to ensure no partial updates. - DeleteFilterByID(ctx context.Context, id string) error - - // - - // + // DeleteFilter deletes the given filter and all associated FilterKeyword{} + // and FilterStatus{} models from the database in a single transaction. + DeleteFilter(ctx context.Context, filter *gtsmodel.Filter) error // GetFilterKeywordByID gets one filter keyword with the given ID. GetFilterKeywordByID(ctx context.Context, id string) (*gtsmodel.FilterKeyword, error) - // GetFilterKeywordsForFilterID gets filter keywords from the given filterID. - GetFilterKeywordsForFilterID(ctx context.Context, filterID string) ([]*gtsmodel.FilterKeyword, error) - - // GetFilterKeywordsForAccountID gets filter keywords from the given accountID. - GetFilterKeywordsForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.FilterKeyword, error) + // GetFilterKeywordsByIDs ... + GetFilterKeywordsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.FilterKeyword, error) // PutFilterKeyword inserts a single filter keyword into the database. PutFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) error // UpdateFilterKeyword updates the given filter keyword. - // Columns is optional, if not specified all will be updated. - UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword, columns ...string) error + UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword, cols ...string) error - // DeleteFilterKeywordByID deletes one filter keyword with the given id. - DeleteFilterKeywordByID(ctx context.Context, id string) error - - // - - // + // DeleteFilterKeywordsByIDs deletes filter keywords with the given ids. + DeleteFilterKeywordsByIDs(ctx context.Context, ids ...string) error // GetFilterStatusByID gets one filter status with the given ID. GetFilterStatusByID(ctx context.Context, id string) (*gtsmodel.FilterStatus, error) - // GetFilterStatusesForFilterID gets filter statuses from the given filterID. - GetFilterStatusesForFilterID(ctx context.Context, filterID string) ([]*gtsmodel.FilterStatus, error) - - // GetFilterStatusesForAccountID gets filter keywords from the given accountID. - GetFilterStatusesForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.FilterStatus, error) + // GetFilterStatusesByIDs ... + GetFilterStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.FilterStatus, error) // PutFilterStatus inserts a single filter status into the database. PutFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) error - // DeleteFilterStatusByID deletes one filter status with the given id. - DeleteFilterStatusByID(ctx context.Context, id string) error - - // + // DeleteFilterStatusesByIDs deletes filter statuses with the given ids. + DeleteFilterStatusesByIDs(ctx context.Context, ids ...string) error } diff --git a/internal/filter/status/status.go b/internal/filter/status/status.go index 7cf0a7a1e..1a611cdd1 100644 --- a/internal/filter/status/status.go +++ b/internal/filter/status/status.go @@ -24,22 +24,3 @@ import ( // ErrHideStatus indicates that a status has been filtered and should not be returned at all. var ErrHideStatus = errors.New("hide status") - -// FilterContext determines the filters that apply to a given status or list of statuses. -type FilterContext string - -const ( - // FilterContextNone means no filters should be applied. - // There are no filters with this context; it's for internal use only. - FilterContextNone FilterContext = "" - // FilterContextHome means this status is being filtered as part of a home or list timeline. - FilterContextHome FilterContext = "home" - // FilterContextNotifications means this status is being filtered as part of the notifications timeline. - FilterContextNotifications FilterContext = "notifications" - // FilterContextPublic means this status is being filtered as part of a public or tag timeline. - FilterContextPublic FilterContext = "public" - // FilterContextThread means this status is being filtered as part of a thread's context. - FilterContextThread FilterContext = "thread" - // FilterContextAccount means this status is being filtered as part of an account's statuses. - FilterContextAccount FilterContext = "account" -) diff --git a/internal/gtserror/withcode.go b/internal/gtserror/withcode.go index e2059474d..8f25d0754 100644 --- a/internal/gtserror/withcode.go +++ b/internal/gtserror/withcode.go @@ -118,7 +118,7 @@ func WrapWithCode(code int, err error) WithCode { // NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text. func NewErrorBadRequest(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusBadRequest) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -131,7 +131,7 @@ func NewErrorBadRequest(original error, helpText ...string) WithCode { // NewErrorUnauthorized returns an ErrorWithCode 401 with the given original error and optional help text. func NewErrorUnauthorized(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusUnauthorized) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -144,7 +144,7 @@ func NewErrorUnauthorized(original error, helpText ...string) WithCode { // NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text. func NewErrorForbidden(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusForbidden) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -157,7 +157,7 @@ func NewErrorForbidden(original error, helpText ...string) WithCode { // NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text. func NewErrorNotFound(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusNotFound) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -170,7 +170,7 @@ func NewErrorNotFound(original error, helpText ...string) WithCode { // NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text. func NewErrorInternalError(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusInternalServerError) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -183,7 +183,7 @@ func NewErrorInternalError(original error, helpText ...string) WithCode { // NewErrorConflict returns an ErrorWithCode 409 with the given original error and optional help text. func NewErrorConflict(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusConflict) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -196,7 +196,7 @@ func NewErrorConflict(original error, helpText ...string) WithCode { // NewErrorNotAcceptable returns an ErrorWithCode 406 with the given original error and optional help text. func NewErrorNotAcceptable(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusNotAcceptable) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -209,7 +209,7 @@ func NewErrorNotAcceptable(original error, helpText ...string) WithCode { // NewErrorUnprocessableEntity returns an ErrorWithCode 422 with the given original error and optional help text. func NewErrorUnprocessableEntity(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusUnprocessableEntity) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -222,7 +222,7 @@ func NewErrorUnprocessableEntity(original error, helpText ...string) WithCode { // NewErrorGone returns an ErrorWithCode 410 with the given original error and optional help text. func NewErrorGone(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusGone) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ @@ -235,7 +235,7 @@ func NewErrorGone(original error, helpText ...string) WithCode { // NewErrorNotImplemented returns an ErrorWithCode 501 with the given original error and optional help text. func NewErrorNotImplemented(original error, helpText ...string) WithCode { safe := http.StatusText(http.StatusNotImplemented) - if helpText != nil { + if len(helpText) > 0 { safe = safe + ": " + strings.Join(helpText, ": ") } return &withCode{ diff --git a/internal/gtsmodel/common.go b/internal/gtsmodel/common.go index e740bbb81..6fb415e5f 100644 --- a/internal/gtsmodel/common.go +++ b/internal/gtsmodel/common.go @@ -17,8 +17,17 @@ package gtsmodel +// smallint is the largest size supported +// by a PostgreSQL SMALLINT, since an SQLite +// SMALLINT is actually variable in size. +type smallint int16 + // enumType is the type we (at least, should) use -// for database enum types. it is the largest size -// supported by a PostgreSQL SMALLINT, since an -// SQLite SMALLINT is actually variable in size. -type enumType int16 +// for database enum types, as smallest int size. +type enumType smallint + +// bitFieldType is the type we use +// for database int bit fields, at +// least where the smallest int size +// will suffice for number of fields. +type bitFieldType smallint diff --git a/internal/gtsmodel/filter.go b/internal/gtsmodel/filter.go index 36ebc8391..1d457d878 100644 --- a/internal/gtsmodel/filter.go +++ b/internal/gtsmodel/filter.go @@ -18,28 +18,251 @@ package gtsmodel import ( + "fmt" "regexp" + "strconv" "time" "code.superseriousbusiness.org/gotosocial/internal/util" + "codeberg.org/gruf/go-byteutil" ) +// FilterContext represents the +// context in which a Filter applies. +// +// These are used as bit-field masks to determine +// which are enabled in a FilterContexts bit field, +// as well as to signify internally any particular +// context in which a status should be filtered in. +type FilterContext bitFieldType + +const ( + // FilterContextNone means no filters should + // be applied, this is for internal use only. + FilterContextNone FilterContext = 0 + + // FilterContextHome means this status is being + // filtered as part of a home or list timeline. + FilterContextHome FilterContext = 1 << 1 + + // FilterContextNotifications means this status is + // being filtered as part of the notifications timeline. + FilterContextNotifications FilterContext = 1 << 2 + + // FilterContextPublic means this status is + // being filtered as part of a public or tag timeline. + FilterContextPublic FilterContext = 1 << 3 + + // FilterContextThread means this status is + // being filtered as part of a thread's context. + FilterContextThread FilterContext = 1 << 4 + + // FilterContextAccount means this status is + // being filtered as part of an account's statuses. + FilterContextAccount FilterContext = 1 << 5 +) + +// String returns human-readable form of FilterContext. +func (ctx FilterContext) String() string { + switch ctx { + case FilterContextNone: + return "" + case FilterContextHome: + return "home" + case FilterContextNotifications: + return "notifications" + case FilterContextPublic: + return "public" + case FilterContextThread: + return "thread" + case FilterContextAccount: + return "account" + default: + panic(fmt.Sprintf("invalid filter context: %d", ctx)) + } +} + +// FilterContexts stores multiple contexts +// in which a Filter applies as bits in an int. +type FilterContexts bitFieldType + +// Applies returns whether receiving FilterContexts applies in FilterContexts. +func (ctxs FilterContexts) Applies(ctx FilterContext) bool { + return ctxs&FilterContexts(ctx) != 0 +} + +// Home returns whether FilterContextHome is set. +func (ctxs FilterContexts) Home() bool { + return ctxs&FilterContexts(FilterContextHome) != 0 +} + +// SetHome will set the FilterContextHome bit. +func (ctxs *FilterContexts) SetHome() { + *ctxs |= FilterContexts(FilterContextHome) +} + +// UnsetHome will unset the FilterContextHome bit. +func (ctxs *FilterContexts) UnsetHome() { + *ctxs &= ^FilterContexts(FilterContextHome) +} + +// Notifications returns whether FilterContextNotifications is set. +func (ctxs FilterContexts) Notifications() bool { + return ctxs&FilterContexts(FilterContextNotifications) != 0 +} + +// SetNotifications will set the FilterContextNotifications bit. +func (ctxs *FilterContexts) SetNotifications() { + *ctxs |= FilterContexts(FilterContextNotifications) +} + +// UnsetNotifications will unset the FilterContextNotifications bit. +func (ctxs *FilterContexts) UnsetNotifications() { + *ctxs &= ^FilterContexts(FilterContextNotifications) +} + +// Public returns whether FilterContextPublic is set. +func (ctxs FilterContexts) Public() bool { + return ctxs&FilterContexts(FilterContextPublic) != 0 +} + +// SetPublic will set the FilterContextPublic bit. +func (ctxs *FilterContexts) SetPublic() { + *ctxs |= FilterContexts(FilterContextPublic) +} + +// UnsetPublic will unset the FilterContextPublic bit. +func (ctxs *FilterContexts) UnsetPublic() { + *ctxs &= ^FilterContexts(FilterContextPublic) +} + +// Thread returns whether FilterContextThread is set. +func (ctxs FilterContexts) Thread() bool { + return ctxs&FilterContexts(FilterContextThread) != 0 +} + +// SetThread will set the FilterContextThread bit. +func (ctxs *FilterContexts) SetThread() { + *ctxs |= FilterContexts(FilterContextThread) +} + +// UnsetThread will unset the FilterContextThread bit. +func (ctxs *FilterContexts) UnsetThread() { + *ctxs &= ^FilterContexts(FilterContextThread) +} + +// Account returns whether FilterContextAccount is set. +func (ctxs FilterContexts) Account() bool { + return ctxs&FilterContexts(FilterContextAccount) != 0 +} + +// SetAccount will set / unset the FilterContextAccount bit. +func (ctxs *FilterContexts) SetAccount() { + *ctxs |= FilterContexts(FilterContextAccount) +} + +// UnsetAccount will unset the FilterContextAccount bit. +func (ctxs *FilterContexts) UnsetAccount() { + *ctxs &= ^FilterContexts(FilterContextAccount) +} + +// String returns a single human-readable form of FilterContexts. +func (ctxs FilterContexts) String() string { + var buf byteutil.Buffer + buf.Guarantee(72) // worst-case estimate + buf.B = append(buf.B, '{') + buf.B = append(buf.B, "home="...) + buf.B = strconv.AppendBool(buf.B, ctxs.Home()) + buf.B = append(buf.B, ',') + buf.B = append(buf.B, "notifications="...) + buf.B = strconv.AppendBool(buf.B, ctxs.Notifications()) + buf.B = append(buf.B, ',') + buf.B = append(buf.B, "public="...) + buf.B = strconv.AppendBool(buf.B, ctxs.Public()) + buf.B = append(buf.B, ',') + buf.B = append(buf.B, "thread="...) + buf.B = strconv.AppendBool(buf.B, ctxs.Thread()) + buf.B = append(buf.B, ',') + buf.B = append(buf.B, "account="...) + buf.B = strconv.AppendBool(buf.B, ctxs.Account()) + buf.B = append(buf.B, '}') + return buf.String() +} + +// FilterAction represents the action +// to take on a filtered status. +type FilterAction enumType + +const ( + // FilterActionNone filters should not exist, except + // internally, for partially constructed or invalid filters. + FilterActionNone FilterAction = 0 + + // FilterActionWarn means that the + // status should be shown behind a warning. + FilterActionWarn FilterAction = 1 + + // FilterActionHide means that the status should + // be removed from timeline results entirely. + FilterActionHide FilterAction = 2 +) + +// String returns human-readable form of FilterAction. +func (act FilterAction) String() string { + switch act { + case FilterActionNone: + return "" + case FilterActionWarn: + return "warn" + case FilterActionHide: + return "hide" + default: + panic(fmt.Sprintf("invalid filter action: %d", act)) + } +} + // Filter stores a filter created by a local account. type Filter struct { - ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database - CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created - UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated - ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire. - AccountID string `bun:"type:CHAR(26),notnull,nullzero,unique:filters_account_id_title_uniq"` // ID of the local account that created the filter. - Title string `bun:",nullzero,notnull,unique:filters_account_id_title_uniq"` // The name of the filter. - Action FilterAction `bun:",nullzero,notnull"` // The action to take. - Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter. - Statuses []*FilterStatus `bun:"-"` // Statuses for this filter. - ContextHome *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists. - ContextNotifications *bool `bun:",nullzero,notnull,default:false"` // Apply filter to notifications. - ContextPublic *bool `bun:",nullzero,notnull,default:false"` // Apply filter to home timeline and lists. - ContextThread *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing a status's associated thread. - ContextAccount *bool `bun:",nullzero,notnull,default:false"` // Apply filter when viewing an account profile. + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time filter should expire. If null, should not expire. + AccountID string `bun:"type:CHAR(26),notnull,nullzero,unique:filters_account_id_title_uniq"` // ID of the local account that created the filter. + Title string `bun:",nullzero,notnull,unique:filters_account_id_title_uniq"` // The name of the filter. + Action FilterAction `bun:",nullzero,notnull,default:0"` // The action to take. + Keywords []*FilterKeyword `bun:"-"` // Keywords for this filter. + KeywordIDs []string `bun:"keywords,array"` // + Statuses []*FilterStatus `bun:"-"` // Statuses for this filter. + StatusIDs []string `bun:"statuses,array"` // + Contexts FilterContexts `bun:",nullzero,notnull,default:0"` // Which contexts does this filter apply in? +} + +// KeywordsPopulated returns whether keywords +// are populated according to current KeywordIDs. +func (f *Filter) KeywordsPopulated() bool { + if len(f.KeywordIDs) != len(f.Keywords) { + // this is the quickest indicator. + return false + } + for i, id := range f.KeywordIDs { + if f.Keywords[i].ID != id { + return false + } + } + return true +} + +// StatusesPopulated returns whether statuses +// are populated according to current StatusIDs. +func (f *Filter) StatusesPopulated() bool { + if len(f.StatusIDs) != len(f.Statuses) { + // this is the quickest indicator. + return false + } + for i, id := range f.StatusIDs { + if f.Statuses[i].ID != id { + return false + } + } + return true } // Expired returns whether the filter has expired at a given time. @@ -51,11 +274,7 @@ func (f *Filter) Expired(now time.Time) bool { // FilterKeyword stores a single keyword to filter statuses against. type FilterKeyword struct { ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database - CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created - UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated - AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword. FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to. - Filter *Filter `bun:"-"` // Filter corresponding to FilterID Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against. WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries? Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression @@ -72,6 +291,7 @@ func (k *FilterKeyword) Compile() (err error) { // Either word boundary or // whitespace or start of line. wordBreakStart = `(?:\b|\s|^)` + // Either word boundary or // whitespace or end of line. wordBreakEnd = `(?:\b|\s|$)` @@ -85,23 +305,7 @@ func (k *FilterKeyword) Compile() (err error) { // FilterStatus stores a single status to filter. type FilterStatus struct { - ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database - CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created - UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated - AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword. - FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to. - Filter *Filter `bun:"-"` // Filter corresponding to FilterID - StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter. + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the filter that this keyword belongs to. + StatusID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_statuses_filter_id_status_id_uniq"` // ID of the status to filter. } - -// FilterAction represents the action to take on a filtered status. -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. - FilterActionHide FilterAction = "hide" -) diff --git a/internal/processing/account/bookmarks.go b/internal/processing/account/bookmarks.go index 329bcf30c..e6f0886f9 100644 --- a/internal/processing/account/bookmarks.go +++ b/internal/processing/account/bookmarks.go @@ -23,7 +23,6 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -75,7 +74,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode } // Convert the status. - item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) + item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, gtsmodel.FilterContextNone, nil) if err != nil { log.Errorf(ctx, "error converting bookmarked status to api: %s", err) continue diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 0ff9ef7e1..3b56750c5 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -24,7 +24,6 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -97,7 +96,7 @@ func (p *Processor) StatusesGet( return nil, gtserror.NewErrorInternalError(err) } - filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) + filters, err := p.state.DB.GetFiltersByAccountID(ctx, requestingAccount.ID) if err != nil { err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) return nil, gtserror.NewErrorInternalError(err) @@ -105,7 +104,7 @@ func (p *Processor) StatusesGet( for _, s := range filtered { // Convert filtered statuses to API statuses. - item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters) + item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, gtsmodel.FilterContextAccount, filters) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index 441a58384..83acddc84 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -213,7 +213,7 @@ func (p *Processor) GetAPIStatus( apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, - statusfilter.FilterContextNone, + gtsmodel.FilterContextNone, nil, ) if err != nil { @@ -234,7 +234,7 @@ func (p *Processor) GetVisibleAPIStatuses( ctx context.Context, requester *gtsmodel.Account, statuses []*gtsmodel.Status, - filterContext statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, filters []*gtsmodel.Filter, ) []apimodel.Status { @@ -277,7 +277,7 @@ func (p *Processor) GetVisibleAPIStatuses( apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requester, - filterContext, + filterCtx, filters, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { diff --git a/internal/processing/conversations/conversations.go b/internal/processing/conversations/conversations.go index e31f60500..70fafa437 100644 --- a/internal/processing/conversations/conversations.go +++ b/internal/processing/conversations/conversations.go @@ -101,7 +101,7 @@ func (p *Processor) getFilters( ctx context.Context, requestingAccount *gtsmodel.Account, ) ([]*gtsmodel.Filter, gtserror.WithCode) { - filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) + filters, err := p.state.DB.GetFiltersByAccountID(ctx, requestingAccount.ID) if err != nil { return nil, gtserror.NewErrorInternalError( gtserror.Newf( diff --git a/internal/processing/filters/common/common.go b/internal/processing/filters/common/common.go new file mode 100644 index 000000000..a119d3bd4 --- /dev/null +++ b/internal/processing/filters/common/common.go @@ -0,0 +1,184 @@ +// 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 common + +import ( + "context" + "errors" + "fmt" + "net/http" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/state" +) + +type Processor struct{ state *state.State } + +func New(state *state.State) *Processor { return &Processor{state} } + +// CheckFilterExists calls .GetFilter() with a barebones context to not +// fetch any sub-models, and not returning the result. this functionally +// just uses .GetFilter() for the ownership and existence checks. +func (p *Processor) CheckFilterExists( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) gtserror.WithCode { + _, errWithCode := p.GetFilter(gtscontext.SetBarebones(ctx), requester, id) + return errWithCode +} + +// GetFilter fetches the filter with given ID, also checking +// the given requesting account is the owner of the filter. +func (p *Processor) GetFilter( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) ( + *gtsmodel.Filter, + gtserror.WithCode, +) { + // Get the filter from the database with given ID. + filter, err := p.state.DB.GetFilterByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Check it exists. + if filter == nil { + const text = "filter not found" + return nil, gtserror.NewWithCode(http.StatusNotFound, text) + } + + // Check that the requester owns it. + if filter.AccountID != requester.ID { + const text = "filter not found" + err := gtserror.New("filter does not belong to account") + return nil, gtserror.NewErrorNotFound(err, text) + } + + return filter, nil +} + +// GetFilterStatus fetches the filter status with given ID, also +// checking the given requesting account is the owner of it. +func (p *Processor) GetFilterStatus( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) ( + *gtsmodel.FilterStatus, + *gtsmodel.Filter, + gtserror.WithCode, +) { + + // Get the filter status from the database with given ID. + filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter status: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + // Check it even exists. + if filterStatus == nil { + const text = "filter status not found" + return nil, nil, gtserror.NewWithCode(http.StatusNotFound, text) + } + + // Get the filter this filter status is + // associated with, without sub-models. + // (this also checks filter ownership). + filter, errWithCode := p.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + filterStatus.FilterID, + ) + if errWithCode != nil { + return nil, nil, errWithCode + } + + return filterStatus, filter, nil +} + +// GetFilterKeyword fetches the filter keyword with given ID, +// also checking the given requesting account is the owner of it. +func (p *Processor) GetFilterKeyword( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) ( + *gtsmodel.FilterKeyword, + *gtsmodel.Filter, + gtserror.WithCode, +) { + + // Get the filter keyword from the database with given ID. + keyword, err := p.state.DB.GetFilterKeywordByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter keyword: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + // Check it exists. + if keyword == nil { + const text = "filter keyword not found" + return nil, nil, gtserror.NewWithCode(http.StatusNotFound, text) + } + + // Get the filter this filter keyword is + // associated with, without sub-models. + // (this also checks filter ownership). + filter, errWithCode := p.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + keyword.FilterID, + ) + if errWithCode != nil { + return nil, nil, errWithCode + } + + return keyword, filter, nil +} + +// FromAPIContexts converts a slice of frontend API model FilterContext types to our internal FilterContexts bit field. +func FromAPIContexts(apiContexts []apimodel.FilterContext) (gtsmodel.FilterContexts, gtserror.WithCode) { + var contexts gtsmodel.FilterContexts + for _, context := range apiContexts { + switch context { + case apimodel.FilterContextHome: + contexts.SetHome() + case apimodel.FilterContextNotifications: + contexts.SetNotifications() + case apimodel.FilterContextPublic: + contexts.SetPublic() + case apimodel.FilterContextThread: + contexts.SetThread() + case apimodel.FilterContextAccount: + contexts.SetAccount() + default: + text := fmt.Sprintf("unsupported filter context: %s", context) + return 0, gtserror.NewWithCode(http.StatusBadRequest, text) + } + } + return contexts, nil +} diff --git a/internal/processing/filters/v1/convert.go b/internal/processing/filters/v1/convert.go deleted file mode 100644 index 417cf7b7d..000000000 --- a/internal/processing/filters/v1/convert.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 v1 - -import ( - "context" - "fmt" - - apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" - "code.superseriousbusiness.org/gotosocial/internal/gtserror" - "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" -) - -// apiFilter is a shortcut to return the API v1 filter version of the given -// filter keyword, or return an appropriate error if conversion fails. -func (p *Processor) apiFilter(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) (*apimodel.FilterV1, gtserror.WithCode) { - apiFilter, err := p.converter.FilterKeywordToAPIFilterV1(ctx, filterKeyword) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting filter keyword to API v1 filter: %w", err)) - } - - return apiFilter, nil -} diff --git a/internal/processing/filters/v1/create.go b/internal/processing/filters/v1/create.go index 24517dd7b..b2ec69442 100644 --- a/internal/processing/filters/v1/create.go +++ b/internal/processing/filters/v1/create.go @@ -20,7 +20,7 @@ package v1 import ( "context" "errors" - "fmt" + "net/http" "time" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" @@ -28,68 +28,72 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" "code.superseriousbusiness.org/gotosocial/internal/util" ) // Create a new filter and 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) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.FilterCreateUpdateRequestV1) (*apimodel.FilterV1, gtserror.WithCode) { +func (p *Processor) Create(ctx context.Context, requester *gtsmodel.Account, form *apimodel.FilterCreateUpdateRequestV1) (*apimodel.FilterV1, gtserror.WithCode) { + var errWithCode gtserror.WithCode + + // Create new wrapping filter. filter := >smodel.Filter{ ID: id.NewULID(), - AccountID: account.ID, + AccountID: requester.ID, Title: form.Phrase, - Action: gtsmodel.FilterActionWarn, } + if *form.Irreversible { + // Irreversible = action hide. filter.Action = gtsmodel.FilterActionHide - } - if form.ExpiresIn != nil && *form.ExpiresIn != 0 { - 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), - ) - } + } else { + // Default action = action warn. + filter.Action = gtsmodel.FilterActionWarn } - filterKeyword := >smodel.FilterKeyword{ - ID: id.NewULID(), - AccountID: account.ID, - FilterID: filter.ID, - Filter: filter, - Keyword: form.Phrase, - WholeWord: util.Ptr(util.PtrOrValue(form.WholeWord, false)), - } - filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} - - if err := p.state.DB.PutFilter(ctx, filter); 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) + // Check form for valid expiry and set on filter. + if form.ExpiresIn != nil && *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + filter.ExpiresAt = time.Now().Add(expiresIn) } - apiFilter, errWithCode := p.apiFilter(ctx, filterKeyword) + // Parse contexts filter applies in from incoming request form data. + filter.Contexts, errWithCode = common.FromAPIContexts(form.Context) if errWithCode != nil { return nil, errWithCode } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Create new keyword attached to filter. + filterKeyword := >smodel.FilterKeyword{ + ID: id.NewULID(), + FilterID: filter.ID, + Keyword: form.Phrase, + WholeWord: util.Ptr(util.PtrOrValue(form.WholeWord, false)), + } - return apiFilter, nil + // Attach the new keyword to filter before insert. + filter.Keywords = append(filter.Keywords, filterKeyword) + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) + + // Insert newly created filter into the database. + switch err := p.state.DB.PutFilter(ctx, filter); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) + + // Return as converted frontend filter keyword model. + return typeutils.FilterKeywordToAPIFilterV1(filter, filterKeyword), nil } diff --git a/internal/processing/filters/v1/delete.go b/internal/processing/filters/v1/delete.go index 6a081ff04..cab8b185d 100644 --- a/internal/processing/filters/v1/delete.go +++ b/internal/processing/filters/v1/delete.go @@ -19,52 +19,52 @@ package v1 import ( "context" - "errors" + "slices" - "code.superseriousbusiness.org/gotosocial/internal/db" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) -// Delete an existing filter keyword and (if empty afterwards) filter for the given account. +// Delete an existing filter keyword and (if empty +// afterwards) filter for the given account. func (p *Processor) Delete( ctx context.Context, - account *gtsmodel.Account, + requester *gtsmodel.Account, filterKeywordID string, ) gtserror.WithCode { - // Get enough of the filter keyword that we can look up its filter ID. - filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return gtserror.NewErrorNotFound(err) - } - return gtserror.NewErrorInternalError(err) - } - if filterKeyword.AccountID != account.ID { - return gtserror.NewErrorNotFound(nil) - } - - // Get the filter for this keyword. - filter, err := p.state.DB.GetFilterByID(ctx, filterKeyword.FilterID) - if err != nil { - return gtserror.NewErrorNotFound(err) + // Get the filter keyword with given ID, and associated filter, also checking ownership. + filterKeyword, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return errWithCode } if len(filter.Keywords) > 1 || len(filter.Statuses) > 0 { - // The filter has other keywords or statuses. Delete only the requested filter keyword. - if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil { + // The filter has other keywords or statuses, just delete the one filter keyword. + if err := p.state.DB.DeleteFilterKeywordsByIDs(ctx, filterKeyword.ID); err != nil { + err := gtserror.Newf("error deleting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Delete this filter keyword from the slice of IDs attached to filter. + filter.KeywordIDs = slices.DeleteFunc(filter.KeywordIDs, func(id string) bool { + return filterKeyword.ID == id + }) + + // Update filter in the database now the keyword has been unattached. + if err := p.state.DB.UpdateFilter(ctx, filter, "keywords"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) return gtserror.NewErrorInternalError(err) } } else { - // Delete the entire filter. - if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil { + // Delete the filter and this keyword that is attached to it. + if err := p.state.DB.DeleteFilter(ctx, filter); err != nil { + err := gtserror.Newf("error deleting filter: %w", err) return gtserror.NewErrorInternalError(err) } } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v1/filters.go b/internal/processing/filters/v1/filters.go index 89b509912..bcbbd70c0 100644 --- a/internal/processing/filters/v1/filters.go +++ b/internal/processing/filters/v1/filters.go @@ -18,19 +18,25 @@ package v1 import ( + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" "code.superseriousbusiness.org/gotosocial/internal/processing/stream" "code.superseriousbusiness.org/gotosocial/internal/state" "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { + // embedded common logic + c *common.Processor + state *state.State converter *typeutils.Converter stream *stream.Processor } -func New(state *state.State, converter *typeutils.Converter, stream *stream.Processor) Processor { +func New(state *state.State, converter *typeutils.Converter, common *common.Processor, stream *stream.Processor) Processor { return Processor{ + c: common, + state: state, converter: converter, stream: stream, diff --git a/internal/processing/filters/v1/get.go b/internal/processing/filters/v1/get.go index ad35e6272..bdde123e9 100644 --- a/internal/processing/filters/v1/get.go +++ b/internal/processing/filters/v1/get.go @@ -25,47 +25,58 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Get looks up a filter keyword by ID and returns it as a v1 filter. -func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterV1, 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) +func (p *Processor) Get(ctx context.Context, requester *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterV1, gtserror.WithCode) { + filterKeyword, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } - if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound(nil) - } - - return p.apiFilter(ctx, filterKeyword) + return typeutils.FilterKeywordToAPIFilterV1(filter, filterKeyword), nil } // GetAll looks up all filter keywords for the current account and returns them as v1 filters. -func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.FilterV1, gtserror.WithCode) { - filters, err := p.state.DB.GetFilterKeywordsForAccountID( - ctx, - account.ID, +func (p *Processor) GetAll(ctx context.Context, requester *gtsmodel.Account) ([]*apimodel.FilterV1, gtserror.WithCode) { + var totalKeywords int + + // Get a list of all filters owned by this account, + // (without any sub-models attached, done later). + filters, err := p.state.DB.GetFiltersByAccountID( + gtscontext.SetBarebones(ctx), + requester.ID, ) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filters: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilters := make([]*apimodel.FilterV1, 0, len(filters)) + // Get a total count of all expected + // keywords for slice preallocation. for _, filter := range filters { - apiFilter, errWithCode := p.apiFilter(ctx, filter) - if errWithCode != nil { - return nil, errWithCode + totalKeywords += len(filter.KeywordIDs) + } + + // Create a slice to store converted V1 frontend models. + apiFilters := make([]*apimodel.FilterV1, 0, totalKeywords) + + for _, filter := range filters { + // For each of the fetched filters, fetch all of their associated keywords. + keywords, err := p.state.DB.GetFilterKeywordsByIDs(ctx, filter.KeywordIDs) + if err != nil { + err := gtserror.Newf("error getting filter keywords: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - apiFilters = append(apiFilters, apiFilter) + // Convert each keyword to frontend. + for _, keyword := range keywords { + apiFilter := typeutils.FilterKeywordToAPIFilterV1(filter, keyword) + apiFilters = append(apiFilters, apiFilter) + } } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v1/update.go b/internal/processing/filters/v1/update.go index 8b50c3fcf..7e25e6fde 100644 --- a/internal/processing/filters/v1/update.go +++ b/internal/processing/filters/v1/update.go @@ -21,77 +21,59 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "time" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" - "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // Update an existing filter and 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) Update( ctx context.Context, - account *gtsmodel.Account, + requester *gtsmodel.Account, filterKeywordID string, form *apimodel.FilterCreateUpdateRequestV1, ) (*apimodel.FilterV1, gtserror.WithCode) { - // Get enough of the filter keyword that we can look up its filter 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) + // Get the filter keyword with given ID, and associated filter, also checking ownership. + filterKeyword, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } - // Get the filter for this keyword. - filter, err := p.state.DB.GetFilterByID(ctx, filterKeyword.FilterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } + var title string + var action gtsmodel.FilterAction + var contexts gtsmodel.FilterContexts + var expiresAt time.Time + var wholeword bool + + // Get filter title. + title = form.Phrase - title := form.Phrase - action := gtsmodel.FilterActionWarn if *form.Irreversible { + // Irreversible = action hide. action = gtsmodel.FilterActionHide + } else { + // Default action = action warn. + action = gtsmodel.FilterActionWarn } - expiresAt := time.Time{} - if form.ExpiresIn != nil && *form.ExpiresIn != 0 { - expiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn)) + + // Check form for valid expiry and set on filter. + if form.ExpiresIn != nil && *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + expiresAt = time.Now().Add(expiresIn) } - contextHome := false - contextNotifications := false - contextPublic := false - contextThread := false - contextAccount := false - for _, context := range form.Context { - switch context { - case apimodel.FilterContextHome: - contextHome = true - case apimodel.FilterContextNotifications: - contextNotifications = true - case apimodel.FilterContextPublic: - contextPublic = true - case apimodel.FilterContextThread: - contextThread = true - case apimodel.FilterContextAccount: - contextAccount = true - default: - return nil, gtserror.NewErrorUnprocessableEntity( - fmt.Errorf("unsupported filter context '%s'", context), - ) - } + + // Parse contexts filter applies in from incoming form data. + contexts, errWithCode = common.FromAPIContexts(form.Context) + if errWithCode != nil { + return nil, errWithCode } // v1 filter APIs can't change certain fields for a filter with multiple keywords or any statuses, @@ -108,11 +90,7 @@ func (p *Processor) Update( if expiresAt != filter.ExpiresAt { forbiddenFields = append(forbiddenFields, "expires_in") } - if contextHome != util.PtrOrValue(filter.ContextHome, false) || - contextNotifications != util.PtrOrValue(filter.ContextNotifications, false) || - contextPublic != util.PtrOrValue(filter.ContextPublic, false) || - contextThread != util.PtrOrValue(filter.ContextThread, false) || - contextAccount != util.PtrOrValue(filter.ContextAccount, false) { + if contexts != filter.Contexts { forbiddenFields = append(forbiddenFields, "context") } if len(forbiddenFields) > 0 { @@ -122,54 +100,75 @@ func (p *Processor) Update( } } - // Now that we've checked that the changes are legal, apply them to the filter and keyword. - filter.Title = title - filter.Action = action - filter.ExpiresAt = expiresAt - filter.ContextHome = &contextHome - filter.ContextNotifications = &contextNotifications - filter.ContextPublic = &contextPublic - filter.ContextThread = &contextThread - filter.ContextAccount = &contextAccount - filterKeyword.Keyword = form.Phrase - filterKeyword.WholeWord = util.Ptr(util.PtrOrValue(form.WholeWord, false)) + // Filter columns that + // we're going to update. + var filterCols []string + var keywordCols []string - // We only want to update the relevant filter keyword. - filter.Keywords = []*gtsmodel.FilterKeyword{filterKeyword} - filter.Statuses = nil - filterKeyword.Filter = filter + // Check for changed filter title / filter keyword phrase. + if title != filter.Title || title != filterKeyword.Keyword { + keywordCols = append(keywordCols, "keyword") + filterCols = append(filterCols, "title") + filterKeyword.Keyword = title + filter.Title = title + } - filterColumns := []string{ - "title", - "action", - "expires_at", - "context_home", - "context_notifications", - "context_public", - "context_thread", - "context_account", + // Check for changed action. + if action != filter.Action { + filterCols = append(filterCols, "action") + filter.Action = action } - filterKeywordColumns := [][]string{ - { - "keyword", - "whole_word", - }, + + // Check for changed filter expiry time. + if !expiresAt.Equal(filter.ExpiresAt) { + filterCols = append(filterCols, "expires_at") + filter.ExpiresAt = expiresAt } - if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, 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()) - } + + // Check for changed filter context. + if contexts != filter.Contexts { + filterCols = append(filterCols, "contexts") + filter.Contexts = contexts + } + + // Check for changed wholeword flag. + if form.WholeWord != nil && + *form.WholeWord != *filterKeyword.WholeWord { + keywordCols = append(keywordCols, "whole_word") + filterKeyword.WholeWord = &wholeword + } + + // Update filter keyword model in the database with determined changed cols. + switch err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, keywordCols...); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate keyword" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error updating filter: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilter, errWithCode := p.apiFilter(ctx, filterKeyword) - if errWithCode != nil { - return nil, errWithCode + // Update filter model in the database with determined changed cols. + switch err := p.state.DB.UpdateFilter(ctx, filter, filterCols...); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error updating filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) - return apiFilter, nil + // Return as converted frontend filter keyword model. + return typeutils.FilterKeywordToAPIFilterV1(filter, filterKeyword), nil } diff --git a/internal/processing/filters/v2/convert.go b/internal/processing/filters/v2/convert.go deleted file mode 100644 index 590edd04b..000000000 --- a/internal/processing/filters/v2/convert.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 "code.superseriousbusiness.org/gotosocial/internal/api/model" - "code.superseriousbusiness.org/gotosocial/internal/gtserror" - "code.superseriousbusiness.org/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 index c221e1539..d77c23424 100644 --- a/internal/processing/filters/v2/create.go +++ b/internal/processing/filters/v2/create.go @@ -20,7 +20,7 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" "time" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" @@ -28,79 +28,85 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" "code.superseriousbusiness.org/gotosocial/internal/typeutils" - "code.superseriousbusiness.org/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) { + var errWithCode gtserror.WithCode + + // Create new filter model. filter := >smodel.Filter{ ID: id.NewULID(), AccountID: account.ID, Title: form.Title, - Action: typeutils.APIFilterActionToFilterAction(*form.FilterAction), - } - if form.ExpiresIn != nil && *form.ExpiresIn != 0 { - 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) + // Parse filter action from form and set on filter, checking for validity. + filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction) + if filter.Action == 0 { + const text = "invalid filter action" + return nil, gtserror.NewWithCode(http.StatusBadRequest, text) } - 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) - } - - apiFilter, errWithCode := p.apiFilter(ctx, filter) + // Parse contexts filter applies in from incoming request form data. + filter.Contexts, errWithCode = common.FromAPIContexts(form.Context) if errWithCode != nil { return nil, errWithCode } + // Check form for valid expiry and set on filter. + if form.ExpiresIn != nil && *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + filter.ExpiresAt = time.Now().Add(expiresIn) + } + + // Create new attached filter keywords. + for _, keyword := range form.Keywords { + filterKeyword := >smodel.FilterKeyword{ + ID: id.NewULID(), + FilterID: filter.ID, + Keyword: keyword.Keyword, + WholeWord: keyword.WholeWord, + } + + // Append the new filter key word to filter itself. + filter.Keywords = append(filter.Keywords, filterKeyword) + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) + } + + // Create new attached filter statuses. + for _, status := range form.Statuses { + filterStatus := >smodel.FilterStatus{ + ID: id.NewULID(), + FilterID: filter.ID, + StatusID: status.StatusID, + } + + // Append the new filter status to filter itself. + filter.Statuses = append(filter.Statuses, filterStatus) + filter.StatusIDs = append(filter.StatusIDs, filterStatus.ID) + } + + // Insert the new filter model into the database. + switch err := p.state.DB.PutFilter(ctx, filter); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title, keyword or status" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + // Send a filters changed event. p.stream.FiltersChanged(ctx, account) - return apiFilter, nil + // Return as converted frontend filter model. + return typeutils.FilterToAPIFilterV2(filter), nil } diff --git a/internal/processing/filters/v2/delete.go b/internal/processing/filters/v2/delete.go index b6a4c6321..ca3ade431 100644 --- a/internal/processing/filters/v2/delete.go +++ b/internal/processing/filters/v2/delete.go @@ -19,38 +19,33 @@ package v2 import ( "context" - "fmt" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) -// Delete an existing filter and all its attached keywords and statuses for the given account. +// 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, + requester *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) + + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return errWithCode } - // Check that the account owns it. - if filter.AccountID != account.ID { - return gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) - } - - // Delete the entire filter. - if err := p.state.DB.DeleteFilterByID(ctx, filter.ID); err != nil { + // Delete filter from the database with all associated models. + if err := p.state.DB.DeleteFilter(ctx, filter); err != nil { + err := gtserror.Newf("error deleting filter: %w", err) return gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v2/filters.go b/internal/processing/filters/v2/filters.go index 82fef36b6..8c0ade1ca 100644 --- a/internal/processing/filters/v2/filters.go +++ b/internal/processing/filters/v2/filters.go @@ -18,19 +18,25 @@ package v2 import ( + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" "code.superseriousbusiness.org/gotosocial/internal/processing/stream" "code.superseriousbusiness.org/gotosocial/internal/state" "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) type Processor struct { + // embedded common logic + c *common.Processor + state *state.State converter *typeutils.Converter stream *stream.Processor } -func New(state *state.State, converter *typeutils.Converter, stream *stream.Processor) Processor { +func New(state *state.State, converter *typeutils.Converter, common *common.Processor, stream *stream.Processor) Processor { return Processor{ + c: common, + state: state, converter: converter, stream: stream, diff --git a/internal/processing/filters/v2/get.go b/internal/processing/filters/v2/get.go index 7240d1ba3..4cdf9e8ee 100644 --- a/internal/processing/filters/v2/get.go +++ b/internal/processing/filters/v2/get.go @@ -19,56 +19,43 @@ package v2 import ( "context" - "errors" - "fmt" "slices" "strings" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" - "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // 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) +func (p *Processor) Get(ctx context.Context, requester *gtsmodel.Account, filterID string) (*apimodel.FilterV2, gtserror.WithCode) { + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return nil, errWithCode } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) - } - - return p.apiFilter(ctx, filter) + return typeutils.FilterToAPIFilterV2(filter), nil } // 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, - ) +func (p *Processor) GetAll(ctx context.Context, requester *gtsmodel.Account) ([]*apimodel.FilterV2, gtserror.WithCode) { + + // Get all filters belonging to this requester from the database. + filters, err := p.state.DB.GetFiltersByAccountID(ctx, requester.ID) if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + err := gtserror.Newf("error getting account filters: %w", err) 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) + // Convert all these filters to frontend API models. + apiFilters := make([]*apimodel.FilterV2, len(filters)) + if len(apiFilters) != len(filters) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) + } + for i, filter := range filters { + apiFilter := typeutils.FilterToAPIFilterV2(filter) + apiFilters[i] = apiFilter } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v2/keywordcreate.go b/internal/processing/filters/v2/keywordcreate.go index 89ada34f4..da91d5fd3 100644 --- a/internal/processing/filters/v2/keywordcreate.go +++ b/internal/processing/filters/v2/keywordcreate.go @@ -20,51 +20,60 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // KeywordCreate adds a filter keyword to an existing filter for the given account, using the provided parameters. // These params should have already been normalized and validated by the time they reach this function. -func (p *Processor) KeywordCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterKeywordCreateUpdateRequest) (*apimodel.FilterKeyword, gtserror.WithCode) { - // Check that the filter is owned by the given account. - filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) +func (p *Processor) KeywordCreate(ctx context.Context, requester *gtsmodel.Account, filterID string, form *apimodel.FilterKeywordCreateUpdateRequest) (*apimodel.FilterKeyword, gtserror.WithCode) { + + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return nil, errWithCode } + // Create new filter keyword model. 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()) - } + // Insert the new filter keyword model into the database. + switch err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate keyword" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter keyword: %w", err) return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Now update the filter it is attached to with new keyword. + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) + filter.Keywords = append(filter.Keywords, filterKeyword) - return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil + // Update the existing filter model in the database (only the needed col). + if err := p.state.DB.UpdateFilter(ctx, filter, "keywords"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) + + return typeutils.FilterKeywordToAPIFilterKeyword(filterKeyword), nil } diff --git a/internal/processing/filters/v2/keyworddelete.go b/internal/processing/filters/v2/keyworddelete.go index 390c7b2cf..a0ec887e3 100644 --- a/internal/processing/filters/v2/keyworddelete.go +++ b/internal/processing/filters/v2/keyworddelete.go @@ -19,7 +19,7 @@ package v2 import ( "context" - "fmt" + "slices" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -28,29 +28,34 @@ import ( // KeywordDelete deletes an existing filter keyword from a filter. func (p *Processor) KeywordDelete( ctx context.Context, - account *gtsmodel.Account, - filterID string, + requester *gtsmodel.Account, + filterKeywordID string, ) gtserror.WithCode { - // Get the filter keyword. - filterKeyword, err := p.state.DB.GetFilterKeywordByID(ctx, filterID) - if err != nil { - return gtserror.NewErrorNotFound(err) + // Get filter keyword with given ID, also checking ownership to requester. + _, filter, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return errWithCode } - // Check that the account owns it. - if filterKeyword.AccountID != account.ID { - return gtserror.NewErrorNotFound( - fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), - ) - } - - // Delete the filter keyword. - if err := p.state.DB.DeleteFilterKeywordByID(ctx, filterKeyword.ID); err != nil { + // Delete this one filter keyword from the database, now ownership is confirmed. + if err := p.state.DB.DeleteFilterKeywordsByIDs(ctx, filterKeywordID); err != nil { + err := gtserror.Newf("error deleting filter keyword: %w", err) return gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Delete this filter keyword from the slice of IDs attached to filter. + filter.KeywordIDs = slices.DeleteFunc(filter.KeywordIDs, func(id string) bool { + return filterKeywordID == id + }) + + // Update filter in the database now the keyword has been unattached. + if err := p.state.DB.UpdateFilter(ctx, filter, "keywords"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v2/keywordget.go b/internal/processing/filters/v2/keywordget.go index e824b2e57..3cf120ed8 100644 --- a/internal/processing/filters/v2/keywordget.go +++ b/internal/processing/filters/v2/keywordget.go @@ -20,7 +20,6 @@ package v2 import ( "context" "errors" - "fmt" "slices" "strings" @@ -29,54 +28,47 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // 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) +func (p *Processor) KeywordGet(ctx context.Context, requester *gtsmodel.Account, filterKeywordID string) (*apimodel.FilterKeyword, gtserror.WithCode) { + filterKeyword, _, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } - if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), - ) - } - - return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil + return typeutils.FilterKeywordToAPIFilterKeyword(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) - } +func (p *Processor) KeywordsGetForFilterID(ctx context.Context, requester *gtsmodel.Account, filterID string) ([]*apimodel.FilterKeyword, gtserror.WithCode) { - filterKeywords, err := p.state.DB.GetFilterKeywordsForFilterID( - ctx, - filter.ID, + // Get the filter with given ID (but + // without any sub-models attached). + filter, errWithCode := p.c.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + filterID, ) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + if errWithCode != nil { + return nil, errWithCode + } + + // Fetch all associated filter keywords to the determined existent filter. + filterKeywords, err := p.state.DB.GetFilterKeywordsByIDs(ctx, filter.KeywordIDs) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter keywords: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilterKeywords := make([]*apimodel.FilterKeyword, 0, len(filterKeywords)) - for _, filterKeyword := range filterKeywords { - apiFilterKeywords = append(apiFilterKeywords, p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword)) + // Convert all of the filter keyword models from internal to frontend form. + apiFilterKeywords := make([]*apimodel.FilterKeyword, len(filterKeywords)) + if len(apiFilterKeywords) != len(filterKeywords) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) + } + for i, filterKeyword := range filterKeywords { + apiFilterKeywords[i] = typeutils.FilterKeywordToAPIFilterKeyword(filterKeyword) } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v2/keywordupdate.go b/internal/processing/filters/v2/keywordupdate.go index 4c0a54b83..9d1e5bd0c 100644 --- a/internal/processing/filters/v2/keywordupdate.go +++ b/internal/processing/filters/v2/keywordupdate.go @@ -20,50 +20,51 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // 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, + requester *gtsmodel.Account, filterKeywordID string, form *apimodel.FilterKeywordCreateUpdateRequest, ) (*apimodel.FilterKeyword, gtserror.WithCode) { - // Get the filter keyword by ID. - filterKeyword, err := p.state.DB.GetFilterKeywordByID(gtscontext.SetBarebones(ctx), filterKeywordID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filterKeyword.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter keyword %s doesn't belong to account %s", filterKeyword.ID, account.ID), - ) + + // Get the filter keyword with given ID, also checking ownership to requester. + filterKeyword, _, errWithCode := p.c.GetFilterKeyword(ctx, requester, filterKeywordID) + if errWithCode != nil { + return nil, errWithCode } + // Update the keyword model fields. 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()) - } + // Update existing filter keyword model in the database, (only necessary cols). + switch err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, []string{ + "keyword", "whole_word"}...); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate keyword" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter keyword: %w", err) return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) - return p.converter.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword), nil + return typeutils.FilterKeywordToAPIFilterKeyword(filterKeyword), nil } diff --git a/internal/processing/filters/v2/statuscreate.go b/internal/processing/filters/v2/statuscreate.go index 927986c69..1acab448c 100644 --- a/internal/processing/filters/v2/statuscreate.go +++ b/internal/processing/filters/v2/statuscreate.go @@ -20,50 +20,59 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // StatusCreate adds a filter status to an existing filter for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. -func (p *Processor) StatusCreate(ctx context.Context, account *gtsmodel.Account, filterID string, form *apimodel.FilterStatusCreateRequest) (*apimodel.FilterStatus, gtserror.WithCode) { - // Check that the filter is owned by the given account. - filter, err := p.state.DB.GetFilterByID(gtscontext.SetBarebones(ctx), filterID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - if filter.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) +func (p *Processor) StatusCreate(ctx context.Context, requester *gtsmodel.Account, filterID string, form *apimodel.FilterStatusCreateRequest) (*apimodel.FilterStatus, gtserror.WithCode) { + + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) + if errWithCode != nil { + return nil, errWithCode } + // Create new filter status model. filterStatus := >smodel.FilterStatus{ - ID: id.NewULID(), - AccountID: account.ID, - FilterID: filter.ID, - StatusID: form.StatusID, + ID: id.NewULID(), + 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()) - } + // Insert the new filter status model into the database. + switch err := p.state.DB.PutFilterStatus(ctx, filterStatus); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate status" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error inserting filter status: %w", err) return nil, gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Now update the filter it is attached to with new status. + filter.StatusIDs = append(filter.StatusIDs, filterStatus.ID) + filter.Statuses = append(filter.Statuses, filterStatus) - return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil + // Update the existing filter model in the database (only the needed col). + if err := p.state.DB.UpdateFilter(ctx, filter, "statuses"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) + + return typeutils.FilterStatusToAPIFilterStatus(filterStatus), nil } diff --git a/internal/processing/filters/v2/statusdelete.go b/internal/processing/filters/v2/statusdelete.go index e25f7279e..4309bac1a 100644 --- a/internal/processing/filters/v2/statusdelete.go +++ b/internal/processing/filters/v2/statusdelete.go @@ -19,7 +19,7 @@ package v2 import ( "context" - "fmt" + "slices" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -28,29 +28,34 @@ import ( // StatusDelete deletes an existing filter status from a filter. func (p *Processor) StatusDelete( ctx context.Context, - account *gtsmodel.Account, - filterID string, + requester *gtsmodel.Account, + filterStatusID string, ) gtserror.WithCode { - // Get the filter status. - filterStatus, err := p.state.DB.GetFilterStatusByID(ctx, filterID) - if err != nil { - return gtserror.NewErrorNotFound(err) + // Get filter status with given ID, also checking ownership to requester. + _, filter, errWithCode := p.c.GetFilterStatus(ctx, requester, filterStatusID) + if errWithCode != nil { + return errWithCode } - // Check that the account owns it. - if filterStatus.AccountID != account.ID { - return gtserror.NewErrorNotFound( - fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID), - ) - } - - // Delete the filter status. - if err := p.state.DB.DeleteFilterStatusByID(ctx, filterStatus.ID); err != nil { + // Delete this one filter status from the database, now ownership is confirmed. + if err := p.state.DB.DeleteFilterStatusesByIDs(ctx, filterStatusID); err != nil { + err := gtserror.Newf("error deleting filter status: %w", err) return gtserror.NewErrorInternalError(err) } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Delete this filter keyword from the slice of IDs attached to filter. + filter.StatusIDs = slices.DeleteFunc(filter.StatusIDs, func(id string) bool { + return filterStatusID == id + }) + + // Update filter in the database now the status has been unattached. + if err := p.state.DB.UpdateFilter(ctx, filter, "statuses"); err != nil { + err := gtserror.Newf("error updating filter: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) return nil } diff --git a/internal/processing/filters/v2/statusget.go b/internal/processing/filters/v2/statusget.go index 06a56d271..7aa51f830 100644 --- a/internal/processing/filters/v2/statusget.go +++ b/internal/processing/filters/v2/statusget.go @@ -20,7 +20,6 @@ package v2 import ( "context" "errors" - "fmt" "slices" "strings" @@ -29,54 +28,47 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" ) // 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) +func (p *Processor) StatusGet(ctx context.Context, requester *gtsmodel.Account, filterStatusID string) (*apimodel.FilterStatus, gtserror.WithCode) { + filterStatus, _, errWithCode := p.c.GetFilterStatus(ctx, requester, filterStatusID) + if errWithCode != nil { + return nil, errWithCode } - if filterStatus.AccountID != account.ID { - return nil, gtserror.NewErrorNotFound( - fmt.Errorf("filter status %s doesn't belong to account %s", filterStatus.ID, account.ID), - ) - } - - return p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus), nil + return typeutils.FilterStatusToAPIFilterStatus(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) - } +func (p *Processor) StatusesGetForFilterID(ctx context.Context, requester *gtsmodel.Account, filterID string) ([]*apimodel.FilterStatus, gtserror.WithCode) { - filterStatuses, err := p.state.DB.GetFilterStatusesForFilterID( - ctx, - filter.ID, + // Get the filter with given ID (but + // without any sub-models attached). + filter, errWithCode := p.c.GetFilter( + gtscontext.SetBarebones(ctx), + requester, + filterID, ) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, nil - } + if errWithCode != nil { + return nil, errWithCode + } + + // Fetch all associated filter statuses to the determined existent filter. + filterStatuses, err := p.state.DB.GetFilterStatusesByIDs(ctx, filter.StatusIDs) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting filter statuses: %w", err) return nil, gtserror.NewErrorInternalError(err) } - apiFilterStatuses := make([]*apimodel.FilterStatus, 0, len(filterStatuses)) - for _, filterStatus := range filterStatuses { - apiFilterStatuses = append(apiFilterStatuses, p.converter.FilterStatusToAPIFilterStatus(ctx, filterStatus)) + // Convert all of the filter status models from internal to frontend form. + apiFilterStatuses := make([]*apimodel.FilterStatus, len(filterStatuses)) + if len(apiFilterStatuses) != len(filterStatuses) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) + } + for i, filterStatus := range filterStatuses { + apiFilterStatuses[i] = typeutils.FilterStatusToAPIFilterStatus(filterStatus) } // Sort them by ID so that they're in a stable order. diff --git a/internal/processing/filters/v2/update.go b/internal/processing/filters/v2/update.go index 9d38cac66..96a43612f 100644 --- a/internal/processing/filters/v2/update.go +++ b/internal/processing/filters/v2/update.go @@ -20,7 +20,8 @@ package v2 import ( "context" "errors" - "fmt" + "net/http" + "slices" "time" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" @@ -28,243 +29,356 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" "code.superseriousbusiness.org/gotosocial/internal/typeutils" - "code.superseriousbusiness.org/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, + requester *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( - fmt.Errorf("filter %s doesn't belong to account %s", filter.ID, account.ID), - ) - } - - // Filter columns that we're going to update. - filterColumns := []string{} - - // Apply filter changes. - if form.Title != nil { - filterColumns = append(filterColumns, "title") - filter.Title = *form.Title - } - if form.FilterAction != nil { - filterColumns = append(filterColumns, "action") - filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction) - } - if form.ExpiresIn != nil { - expiresIn := *form.ExpiresIn - filterColumns = append(filterColumns, "expires_at") - if expiresIn == 0 { - // Unset the expiration date. - filter.ExpiresAt = time.Time{} - } else { - // Update the expiration date. - filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(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) - } - - apiFilter, errWithCode := p.apiFilter(ctx, filter) + // Get the filter with given ID, also checking ownership. + filter, errWithCode := p.c.GetFilter(ctx, requester, filterID) if errWithCode != nil { return nil, errWithCode } - // Send a filters changed event. - p.stream.FiltersChanged(ctx, account) + // Filter columns that + // we're going to update. + cols := make([]string, 0, 6) - return apiFilter, nil + // Check for title change. + if form.Title != nil { + cols = append(cols, "title") + filter.Title = *form.Title + } + + // Check action type change. + if form.FilterAction != nil { + cols = append(cols, "action") + + // Parse filter action from form and set on filter, checking for validity. + filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction) + if filter.Action == 0 { + const text = "invalid filter action" + return nil, gtserror.NewWithCode(http.StatusBadRequest, text) + } + } + + // Check expiry change. + if form.ExpiresIn != nil { + cols = append(cols, "expires_at") + filter.ExpiresAt = time.Time{} + + // Check form for valid + // expiry and set on filter. + if *form.ExpiresIn > 0 { + expiresIn := time.Duration(*form.ExpiresIn) * time.Second + filter.ExpiresAt = time.Now().Add(expiresIn) + } + } + + // Check context change. + if form.Context != nil { + cols = append(cols, "contexts") + + // Parse contexts filter applies in from incoming request form data. + filter.Contexts, errWithCode = common.FromAPIContexts(*form.Context) + if errWithCode != nil { + return nil, errWithCode + } + } + + // Check for any changes to attached keywords on filter. + keywordQs, errWithCode := p.updateFilterKeywords(ctx, + filter, form.Keywords) + if errWithCode != nil { + return nil, errWithCode + } else if len(keywordQs.create) > 0 || len(keywordQs.delete) > 0 { + + // Attached keywords have changed. + cols = append(cols, "keywords") + } + + // Check for any changes to attached statuses on filter. + statusQs, errWithCode := p.updateFilterStatuses(ctx, + filter, form.Statuses) + if errWithCode != nil { + return nil, errWithCode + } else if len(statusQs.create) > 0 || len(statusQs.delete) > 0 { + + // Attached statuses have changed. + cols = append(cols, "statuses") + } + + // Perform all the deferred database queries. + errWithCode = performTxs(keywordQs, statusQs) + if errWithCode != nil { + return nil, errWithCode + } + + // Update the filter model in the database with determined cols. + switch err := p.state.DB.UpdateFilter(ctx, filter, cols...); { + case err == nil: + // no issue + + case errors.Is(err, db.ErrAlreadyExists): + const text = "duplicate title" + return nil, gtserror.NewWithCode(http.StatusConflict, text) + + default: + err := gtserror.Newf("error updating filter: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Stream a filters changed event to WS. + p.stream.FiltersChanged(ctx, requester) + + // Return as converted frontend filter model. + return typeutils.FilterToAPIFilterV2(filter), 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 +func (p *Processor) updateFilterKeywords(ctx context.Context, filter *gtsmodel.Filter, form []apimodel.FilterKeywordCreateUpdateDeleteRequest) (deferredQs, gtserror.WithCode) { + if len(form) == 0 { + // No keyword changes. + return deferredQs{}, 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), - ) + var deferred deferredQs + for _, request := range form { + if request.ID != nil { + // Look by ID for keyword attached to filter. + idx := slices.IndexFunc(filter.Keywords, + func(f *gtsmodel.FilterKeyword) bool { + return f.ID == (*request.ID) + }) + if idx == -1 { + const text = "filter keyword not found" + return deferred, gtserror.NewWithCode(http.StatusNotFound, text) } - // Process deletes. - if *formKeyword.Destroy { - delete(filterKeywordsByID, id) - deleteFilterKeywordIDs = append(deleteFilterKeywordIDs, id) + // If this is a delete, update filter's id list. + if request.Destroy != nil && *request.Destroy { + filter.Keywords = slices.Delete(filter.Keywords, idx, idx+1) + filter.KeywordIDs = slices.Delete(filter.KeywordIDs, idx, idx+1) + + // Append database delete to funcs for later processing by caller. + deferred.delete = append(deferred.delete, func() gtserror.WithCode { + if err := p.state.DB.DeleteFilterKeywordsByIDs(ctx, *request.ID); // + err != nil { + err := gtserror.Newf("error deleting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) continue } - // Process updates. - columns := make([]string, 0, 2) - if formKeyword.Keyword != nil { - columns = append(columns, "keyword") - filterKeyword.Keyword = *formKeyword.Keyword + // Get the filter keyword at index. + filterKeyword := filter.Keywords[idx] + + // Filter keywords database + // columns we need to update. + cols := make([]string, 0, 2) + + // Check for changes to keyword string. + if val := request.Keyword; val != nil { + cols = append(cols, "keyword") + filterKeyword.Keyword = *val } - if formKeyword.WholeWord != nil { - columns = append(columns, "whole_word") - filterKeyword.WholeWord = formKeyword.WholeWord + + // Check for changes to wholeword flag. + if val := request.WholeWord; val != nil { + cols = append(cols, "whole_word") + filterKeyword.WholeWord = val } - filterKeywordColumnsByID[id] = columns + + // Verify that this is valid regular expression. + if err := filterKeyword.Compile(); err != nil { + const text = "invalid regular expression" + err := gtserror.Newf("invalid regular expression: %w", err) + return deferred, gtserror.NewWithCodeSafe( + http.StatusBadRequest, + err, text, + ) + } + + if len(cols) > 0 { + // Append database update to funcs for later processing by caller. + deferred.update = append(deferred.update, func() gtserror.WithCode { + if err := p.state.DB.UpdateFilterKeyword(ctx, filterKeyword, cols...); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate keyword" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error updating filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) + } + continue } - // Process creates. + // Check for valid request. + if request.Keyword == nil { + const text = "missing keyword" + return deferred, gtserror.NewWithCode(http.StatusBadRequest, text) + } + + // Create new filter keyword for insert. filterKeyword := >smodel.FilterKeyword{ ID: id.NewULID(), - AccountID: filter.AccountID, FilterID: filter.ID, - Filter: filter, - Keyword: *formKeyword.Keyword, - WholeWord: util.Ptr(util.PtrOrValue(formKeyword.WholeWord, false)), + Keyword: *request.Keyword, + WholeWord: request.WholeWord, } - 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 { + // Verify that this is valid regular expression. + if err := filterKeyword.Compile(); err != nil { + const text = "invalid regular expression" + err := gtserror.Newf("invalid regular expression: %w", err) + return deferred, gtserror.NewWithCodeSafe( + http.StatusBadRequest, + err, text, + ) + } + + // Append new filter keyword to filter and list of IDs. 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]) + filter.KeywordIDs = append(filter.KeywordIDs, filterKeyword.ID) + + // Append database insert to funcs for later processing by caller. + deferred.create = append(deferred.create, func() gtserror.WithCode { + if err := p.state.DB.PutFilterKeyword(ctx, filterKeyword); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate keyword" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error inserting filter keyword: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) } - return filterKeywordColumns, deleteFilterKeywordIDs, nil + return deferred, 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 +func (p *Processor) updateFilterStatuses(ctx context.Context, filter *gtsmodel.Filter, form []apimodel.FilterStatusCreateDeleteRequest) (deferredQs, gtserror.WithCode) { + if len(form) == 0 { + // No keyword changes. + return deferredQs{}, 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), - ) + var deferred deferredQs + for _, request := range form { + if request.ID != nil { + // Look by ID for status attached to filter. + idx := slices.IndexFunc(filter.Statuses, + func(f *gtsmodel.FilterStatus) bool { + return f.ID == *request.ID + }) + if idx == -1 { + const text = "filter status not found" + return deferred, gtserror.NewWithCode(http.StatusNotFound, text) } - // Process deletes. - if *formStatus.Destroy { - delete(filterStatusesByID, id) - deleteFilterStatusIDs = append(deleteFilterStatusIDs, id) - continue - } + // If this is a delete, update filter's id list. + if request.Destroy != nil && *request.Destroy { + filter.Statuses = slices.Delete(filter.Statuses, idx, idx+1) + filter.StatusIDs = slices.Delete(filter.StatusIDs, idx, idx+1) - // Filter statuses don't have updates. + // Append database delete to funcs for later processing by caller. + deferred.delete = append(deferred.delete, func() gtserror.WithCode { + if err := p.state.DB.DeleteFilterStatusesByIDs(ctx, *request.ID); // + err != nil { + err := gtserror.Newf("error deleting filter status: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) + } continue } - // Process creates. - filterStatus := >smodel.FilterStatus{ - ID: id.NewULID(), - AccountID: filter.AccountID, - FilterID: filter.ID, - Filter: filter, - StatusID: *formStatus.StatusID, + // Check for valid request. + if request.StatusID == nil { + const text = "missing status" + return deferred, gtserror.NewWithCode(http.StatusBadRequest, text) } - filterStatusesByID[filterStatus.ID] = filterStatus - } - // Replace the filter's keywords list with our updated version. - filter.Statuses = nil - for _, filterStatus := range filterStatusesByID { + // Create new filter status for insert. + filterStatus := >smodel.FilterStatus{ + ID: id.NewULID(), + FilterID: filter.ID, + StatusID: *request.StatusID, + } + + // Append new filter status to filter and list of IDs. filter.Statuses = append(filter.Statuses, filterStatus) + filter.StatusIDs = append(filter.StatusIDs, filterStatus.ID) + + // Append database insert to funcs for later processing by caller. + deferred.create = append(deferred.create, func() gtserror.WithCode { + if err := p.state.DB.PutFilterStatus(ctx, filterStatus); // + err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "duplicate status" + return gtserror.NewWithCode(http.StatusConflict, text) + } + err := gtserror.Newf("error inserting filter status: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + }) } - return deleteFilterStatusIDs, nil + return deferred, nil +} + +// deferredQs stores selection of +// deferred database queries. +type deferredQs struct { + create []func() gtserror.WithCode + update []func() gtserror.WithCode + delete []func() gtserror.WithCode +} + +// performTx performs the passed deferredQs functions, +// prioritising create / update operations before deletes. +func performTxs(queries ...deferredQs) gtserror.WithCode { + + // Perform create / update + // operations before anything. + for _, q := range queries { + for _, create := range q.create { + if errWithCode := create(); errWithCode != nil { + return errWithCode + } + } + for _, update := range q.update { + if errWithCode := update(); errWithCode != nil { + return errWithCode + } + } + } + + // Perform deletes last. + for _, q := range queries { + for _, delete := range q.delete { + if errWithCode := delete(); errWithCode != nil { + return errWithCode + } + } + } + + return nil } diff --git a/internal/processing/processor.go b/internal/processing/processor.go index b8adb9bb8..22574f1d7 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -34,6 +34,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/processing/common" "code.superseriousbusiness.org/gotosocial/internal/processing/conversations" "code.superseriousbusiness.org/gotosocial/internal/processing/fedi" + filterCommon "code.superseriousbusiness.org/gotosocial/internal/processing/filters/common" filtersv1 "code.superseriousbusiness.org/gotosocial/internal/processing/filters/v1" filtersv2 "code.superseriousbusiness.org/gotosocial/internal/processing/filters/v2" "code.superseriousbusiness.org/gotosocial/internal/processing/interactionrequests" @@ -224,6 +225,7 @@ func NewProcessor( processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, parseMentionFunc) processor.media = media.New(&common, state, converter, federator, mediaManager, federator.TransportController()) processor.stream = stream.New(state, oauthServer) + filterCommon := filterCommon.New(state) // Instantiate the rest of the sub // processors + pin them to this struct. @@ -232,8 +234,8 @@ func NewProcessor( processor.application = application.New(state, converter) processor.conversations = conversations.New(state, converter, visFilter, muteFilter) processor.fedi = fedi.New(state, &common, converter, federator, visFilter) - processor.filtersv1 = filtersv1.New(state, converter, &processor.stream) - processor.filtersv2 = filtersv2.New(state, converter, &processor.stream) + processor.filtersv1 = filtersv1.New(state, converter, filterCommon, &processor.stream) + processor.filtersv2 = filtersv2.New(state, converter, filterCommon, &processor.stream) processor.interactionRequests = interactionrequests.New(&common, state, converter) processor.list = list.New(state, converter) processor.markers = markers.New(state, converter) diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go index 97eb813db..b4568722d 100644 --- a/internal/processing/search/util.go +++ b/internal/processing/search/util.go @@ -21,7 +21,6 @@ import ( "context" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -114,7 +113,7 @@ func (p *Processor) packageStatuses( continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, gtsmodel.FilterContextNone, nil) if err != nil { log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err) continue diff --git a/internal/processing/status/context.go b/internal/processing/status/context.go index 6f3e7a4fd..531dff1d6 100644 --- a/internal/processing/status/context.go +++ b/internal/processing/status/context.go @@ -24,7 +24,6 @@ import ( "strings" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" ) @@ -278,7 +277,7 @@ func (p *Processor) ContextGet( ) (*apimodel.ThreadContext, gtserror.WithCode) { // Retrieve filters as they affect // what should be shown to requester. - filters, err := p.state.DB.GetFiltersForAccountID( + filters, err := p.state.DB.GetFiltersByAccountID( ctx, // Populate filters. requester.ID, ) @@ -305,7 +304,7 @@ func (p *Processor) ContextGet( apiContext.Ancestors = p.c.GetVisibleAPIStatuses(ctx, requester, threadContext.ancestors, - statusfilter.FilterContextThread, + gtsmodel.FilterContextThread, filters, ) @@ -313,7 +312,7 @@ func (p *Processor) ContextGet( apiContext.Descendants = p.c.GetVisibleAPIStatuses(ctx, requester, threadContext.descendants, - statusfilter.FilterContextThread, + gtsmodel.FilterContextThread, filters, ) diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go index 8fc4bcfe8..74e7a4933 100644 --- a/internal/processing/stream/statusupdate_test.go +++ b/internal/processing/stream/statusupdate_test.go @@ -22,7 +22,7 @@ import ( "encoding/json" "testing" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/stream" "code.superseriousbusiness.org/gotosocial/internal/typeutils" "github.com/stretchr/testify/suite" @@ -39,7 +39,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { suite.NoError(errWithCode) editedStatus := suite.testStatuses["remote_account_1_status_1"] - apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(suite.T().Context(), editedStatus, account, statusfilter.FilterContextNotifications, nil) + apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(suite.T().Context(), editedStatus, account, gtsmodel.FilterContextNotifications, nil) suite.NoError(err) suite.streamProcessor.StatusUpdate(suite.T().Context(), account, apiStatus, stream.TimelineHome) diff --git a/internal/processing/timeline/faved.go b/internal/processing/timeline/faved.go index 84788a8fa..c1b44fa92 100644 --- a/internal/processing/timeline/faved.go +++ b/internal/processing/timeline/faved.go @@ -25,8 +25,8 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" "code.superseriousbusiness.org/gotosocial/internal/db" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" "code.superseriousbusiness.org/gotosocial/internal/util" ) @@ -56,7 +56,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *apiutil.Auth, continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, gtsmodel.FilterContextNone, nil) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go index ba74b770c..3089f52fc 100644 --- a/internal/processing/timeline/home.go +++ b/internal/processing/timeline/home.go @@ -22,7 +22,6 @@ import ( "net/url" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -77,7 +76,7 @@ func (p *Processor) HomeTimelineGet( pageQuery, // Status filter context. - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, // Database load function. func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { diff --git a/internal/processing/timeline/home_test.go b/internal/processing/timeline/home_test.go index 184d361f0..2d0c912f8 100644 --- a/internal/processing/timeline/home_test.go +++ b/internal/processing/timeline/home_test.go @@ -24,7 +24,6 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" "code.superseriousbusiness.org/gotosocial/internal/paging" - "code.superseriousbusiness.org/gotosocial/internal/util" "github.com/stretchr/testify/suite" ) @@ -49,24 +48,20 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() { filteredStatus = suite.testStatuses["admin_account_status_2"] filteredStatusFound = false filterID = id.NewULID() - filter = >smodel.Filter{ + filterStatusID = id.NewULID() + filterStatus = >smodel.FilterStatus{ + ID: filterStatusID, + FilterID: filterID, + StatusID: filteredStatus.ID, + } + filter = >smodel.Filter{ ID: filterID, AccountID: requester.ID, Title: "timeline filtering test", Action: gtsmodel.FilterActionHide, - Statuses: []*gtsmodel.FilterStatus{ - { - ID: id.NewULID(), - AccountID: requester.ID, - FilterID: filterID, - StatusID: filteredStatus.ID, - }, - }, - ContextHome: util.Ptr(true), - ContextNotifications: util.Ptr(false), - ContextPublic: util.Ptr(false), - ContextThread: util.Ptr(false), - ContextAccount: util.Ptr(false), + Statuses: []*gtsmodel.FilterStatus{filterStatus}, + StatusIDs: []string{filterStatusID}, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome), } ) @@ -95,6 +90,11 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() { // Clear the timeline to drop all cached statuses. suite.state.Caches.Timelines.Home.Clear(requester.ID) + // Create the filter status associated with the main filter. + if err := suite.db.PutFilterStatus(ctx, filterStatus); err != nil { + suite.FailNow(err.Error()) + } + // Create a filter to hide one status on the timeline. if err := suite.db.PutFilter(ctx, filter); err != nil { suite.FailNow(err.Error()) diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go index c8e6bc5f1..265cd5ca2 100644 --- a/internal/processing/timeline/list.go +++ b/internal/processing/timeline/list.go @@ -23,7 +23,6 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -88,7 +87,7 @@ func (p *Processor) ListTimelineGet( nil, // Status filter context. - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, // Database load function. func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go index ad60fd90c..143145bb9 100644 --- a/internal/processing/timeline/notification.go +++ b/internal/processing/timeline/notification.go @@ -59,7 +59,7 @@ func (p *Processor) NotificationsGet( return util.EmptyPageableResponse(), nil } - filters, err := p.state.DB.GetFiltersForAccountID(ctx, requester.ID) + filters, err := p.state.DB.GetFiltersByAccountID(ctx, requester.ID) if err != nil { err = gtserror.Newf("error getting account %s filters: %w", requester.ID, err) return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/processing/timeline/public.go b/internal/processing/timeline/public.go index cfb58201d..d724bfaa1 100644 --- a/internal/processing/timeline/public.go +++ b/internal/processing/timeline/public.go @@ -21,7 +21,6 @@ import ( "context" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -79,7 +78,7 @@ func (p *Processor) publicTimelineGet( localOnlyFalse, // Status filter context. - statusfilter.FilterContextPublic, + gtsmodel.FilterContextPublic, // Database load function. func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { @@ -148,7 +147,7 @@ func (p *Processor) localTimelineGet( localOnlyTrue, // Status filter context. - statusfilter.FilterContextPublic, + gtsmodel.FilterContextPublic, // Database load function. func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { diff --git a/internal/processing/timeline/public_test.go b/internal/processing/timeline/public_test.go index 5aa09f138..3320a45da 100644 --- a/internal/processing/timeline/public_test.go +++ b/internal/processing/timeline/public_test.go @@ -24,7 +24,6 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" "code.superseriousbusiness.org/gotosocial/internal/paging" - "code.superseriousbusiness.org/gotosocial/internal/util" "github.com/stretchr/testify/suite" ) @@ -110,24 +109,20 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { filteredStatus = suite.testStatuses["admin_account_status_2"] filteredStatusFound = false filterID = id.NewULID() - filter = >smodel.Filter{ + filterStatusID = id.NewULID() + filterStatus = >smodel.FilterStatus{ + ID: filterStatusID, + FilterID: filterID, + StatusID: filteredStatus.ID, + } + filter = >smodel.Filter{ ID: filterID, AccountID: requester.ID, Title: "timeline filtering test", Action: gtsmodel.FilterActionHide, - Statuses: []*gtsmodel.FilterStatus{ - { - ID: id.NewULID(), - AccountID: requester.ID, - FilterID: filterID, - StatusID: filteredStatus.ID, - }, - }, - ContextHome: util.Ptr(false), - ContextNotifications: util.Ptr(false), - ContextPublic: util.Ptr(true), - ContextThread: util.Ptr(false), - ContextAccount: util.Ptr(false), + Statuses: []*gtsmodel.FilterStatus{filterStatus}, + StatusIDs: []string{filterStatusID}, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextPublic), } ) @@ -153,6 +148,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline") } + // Create the filter status associated with the main filter. + if err := suite.db.PutFilterStatus(ctx, filterStatus); err != nil { + suite.FailNow(err.Error()) + } + // Create a filter to hide one status on the timeline. if err := suite.db.PutFilter(ctx, filter); err != nil { suite.FailNow(err.Error()) diff --git a/internal/processing/timeline/tag.go b/internal/processing/timeline/tag.go index 88333d343..995f9f8cc 100644 --- a/internal/processing/timeline/tag.go +++ b/internal/processing/timeline/tag.go @@ -24,7 +24,6 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/db" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -87,7 +86,7 @@ func (p *Processor) TagTimelineGet( nil, // Status filter context. - statusfilter.FilterContextPublic, + gtsmodel.FilterContextPublic, // Database load function. func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) { diff --git a/internal/processing/timeline/timeline.go b/internal/processing/timeline/timeline.go index a86702d42..a37785879 100644 --- a/internal/processing/timeline/timeline.go +++ b/internal/processing/timeline/timeline.go @@ -70,7 +70,7 @@ func (p *Processor) getStatusTimeline( page *paging.Page, pagePath string, pageQuery url.Values, - filterCtx statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, loadPage func(*paging.Page) (statuses []*gtsmodel.Status, err error), filter func(*gtsmodel.Status) (delete bool), postFilter func(*gtsmodel.Status) (remove bool), @@ -83,7 +83,7 @@ func (p *Processor) getStatusTimeline( if requester != nil { // Fetch all filters relevant for requesting account. - filters, err = p.state.DB.GetFiltersForAccountID(ctx, + filters, err = p.state.DB.GetFiltersByAccountID(ctx, requester.ID, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index 3f6964259..1c30c11be 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -27,7 +27,6 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/ap" "code.superseriousbusiness.org/gotosocial/internal/config" "code.superseriousbusiness.org/gotosocial/internal/db" - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" "code.superseriousbusiness.org/gotosocial/internal/messages" @@ -213,7 +212,7 @@ func (suite *FromClientAPITestSuite) statusJSON( ctx, status, requestingAccount, - statusfilter.FilterContextNone, + gtsmodel.FilterContextNone, nil, ) if err != nil { diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index 044315349..b11fb103e 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -743,7 +743,7 @@ func (s *Surface) Notify( } } - filters, err := s.State.DB.GetFiltersForAccountID(ctx, targetAccount.ID) + filters, err := s.State.DB.GetFiltersByAccountID(ctx, targetAccount.ID) if err != nil { return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err) } diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index 7ef5fee87..7f9bcd596 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -180,7 +180,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( follow.Account, status, stream.TimelineHome, - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, filters, ); homeTimelined { @@ -275,7 +275,7 @@ func (s *Surface) listTimelineStatusForFollow( follow.Account, status, stream.TimelineList+":"+list.ID, // key streamType to this specific list - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, filters, ) @@ -288,7 +288,7 @@ func (s *Surface) listTimelineStatusForFollow( // getFiltersAndMutes returns an account's filters and mutes. func (s *Surface) getFilters(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) { - filters, err := s.State.DB.GetFiltersForAccountID(ctx, accountID) + filters, err := s.State.DB.GetFiltersByAccountID(ctx, accountID) if err != nil { return nil, gtserror.Newf("couldn't retrieve filters for account %s: %w", accountID, err) } @@ -369,7 +369,7 @@ func (s *Surface) timelineStatus( account *gtsmodel.Account, status *gtsmodel.Status, streamType string, - filterCtx statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, filters []*gtsmodel.Filter, ) bool { @@ -436,7 +436,7 @@ func (s *Surface) timelineAndNotifyStatusForTagFollowers( tagFollowerAccount, status, stream.TimelineHome, - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, filters, ) } @@ -731,7 +731,7 @@ func (s *Surface) timelineStreamStatusUpdate( apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, filters, ) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 3ac7e1536..ed8f3a4cd 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -848,14 +848,14 @@ func (c *Converter) StatusToAPIStatus( ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account, - filterContext statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, filters []*gtsmodel.Filter, ) (*apimodel.Status, error) { return c.statusToAPIStatus( ctx, status, requestingAccount, - filterContext, + filterCtx, filters, true, true, @@ -870,7 +870,7 @@ func (c *Converter) statusToAPIStatus( ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account, - filterContext statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, filters []*gtsmodel.Filter, placeholdAttachments bool, addPendingNote bool, @@ -879,7 +879,7 @@ func (c *Converter) statusToAPIStatus( ctx, status, requestingAccount, // Can be nil. - filterContext, // Can be empty. + filterCtx, // Can be empty. filters, ) if err != nil { @@ -945,13 +945,13 @@ func (c *Converter) statusToAPIFilterResults( ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account, - filterContext statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, filters []*gtsmodel.Filter, ) ([]apimodel.FilterResult, error) { // If there are no filters or mutes, we're done. // We never hide statuses authored by the requesting account, // since not being able to see your own posts is confusing. - if filterContext == "" || (len(filters) == 0) || s.AccountID == requestingAccount.ID { + if filterCtx == 0 || (len(filters) == 0) || s.AccountID == requestingAccount.ID { return nil, nil } @@ -967,6 +967,7 @@ func (c *Converter) statusToAPIFilterResults( cache := c.state.Caches.StatusesFilterableFields fields, stored := cache.Get(statusKey) if !stored { + // We don't have filterable fields // cached, calculate + cache now. fields = filterableFields(s) @@ -976,7 +977,7 @@ func (c *Converter) statusToAPIFilterResults( // Record all matching warn filters and the reasons they matched. filterResults := make([]apimodel.FilterResult, 0, len(filters)) for _, filter := range filters { - if !filterAppliesInContext(filter, filterContext) { + if !filter.Contexts.Applies(filterCtx) { // Filter doesn't apply // to this context. continue @@ -1004,7 +1005,8 @@ func (c *Converter) statusToAPIFilterResults( } } - // A status has only one ID. Not clear why this is a list in the Mastodon API. + // A status has only one ID. Not clear + // why this is a list in the Mastodon API. statusMatches := make([]string, 0, 1) for _, filterStatus := range filter.Statuses { if s.ID == filterStatus.StatusID { @@ -1017,12 +1019,8 @@ func (c *Converter) statusToAPIFilterResults( switch filter.Action { case gtsmodel.FilterActionWarn: // Record what matched. - apiFilter, err := c.FilterToAPIFilterV2(ctx, filter) - if err != nil { - return nil, err - } filterResults = append(filterResults, apimodel.FilterResult{ - Filter: *apiFilter, + Filter: *FilterToAPIFilterV2(filter), KeywordMatches: keywordMatches, StatusMatches: statusMatches, }) @@ -1037,23 +1035,6 @@ func (c *Converter) statusToAPIFilterResults( return filterResults, nil } -// filterAppliesInContext returns whether a given filter applies in a given context. -func filterAppliesInContext(filter *gtsmodel.Filter, filterContext statusfilter.FilterContext) bool { - switch filterContext { - case statusfilter.FilterContextHome: - return util.PtrOrValue(filter.ContextHome, false) - case statusfilter.FilterContextNotifications: - return util.PtrOrValue(filter.ContextNotifications, false) - case statusfilter.FilterContextPublic: - return util.PtrOrValue(filter.ContextPublic, false) - case statusfilter.FilterContextThread: - return util.PtrOrValue(filter.ContextThread, false) - case statusfilter.FilterContextAccount: - return util.PtrOrValue(filter.ContextAccount, false) - } - return false -} - // StatusToWebStatus converts a gts model status into an // api representation suitable for serving into a web template. // @@ -1063,9 +1044,9 @@ func (c *Converter) StatusToWebStatus( s *gtsmodel.Status, ) (*apimodel.WebStatus, error) { apiStatus, err := c.statusToFrontend(ctx, s, - nil, // No authed requester. - statusfilter.FilterContextNone, // No filters. - nil, // No filters. + nil, // No authed requester. + gtsmodel.FilterContextNone, // No filters. + nil, // No filters. ) if err != nil { return nil, err @@ -1234,7 +1215,7 @@ func (c *Converter) statusToFrontend( ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account, - filterContext statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, filters []*gtsmodel.Filter, ) ( *apimodel.Status, @@ -1243,7 +1224,7 @@ func (c *Converter) statusToFrontend( apiStatus, err := c.baseStatusToFrontend(ctx, status, requestingAccount, - filterContext, + filterCtx, filters, ) if err != nil { @@ -1254,7 +1235,7 @@ func (c *Converter) statusToFrontend( reblog, err := c.baseStatusToFrontend(ctx, status.BoostOf, requestingAccount, - filterContext, + filterCtx, filters, ) if errors.Is(err, statusfilter.ErrHideStatus) { @@ -1287,7 +1268,7 @@ func (c *Converter) baseStatusToFrontend( ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account, - filterContext statusfilter.FilterContext, + filterCtx gtsmodel.FilterContext, filters []*gtsmodel.Filter, ) ( *apimodel.Status, @@ -1466,7 +1447,7 @@ func (c *Converter) baseStatusToFrontend( } // Apply filters. - filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters) + filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterCtx, filters) if err != nil { if errors.Is(err, statusfilter.ErrHideStatus) { return nil, err @@ -2007,7 +1988,7 @@ func (c *Converter) NotificationToAPINotification( apiStatus, err = c.StatusToAPIStatus( ctx, n.Status, n.TargetAccount, - statusfilter.FilterContextNotifications, + gtsmodel.FilterContextNotifications, filters, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { @@ -2058,7 +2039,7 @@ func (c *Converter) ConversationToAPIConversation( ctx, conversation.LastStatus, requester, - statusfilter.FilterContextNotifications, + gtsmodel.FilterContextNotifications, filters, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { @@ -2327,7 +2308,7 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo ctx, s, requestingAccount, - statusfilter.FilterContextNone, + gtsmodel.FilterContextNone, nil, // No filters. true, // Placehold unknown attachments. @@ -2558,48 +2539,53 @@ func (c *Converter) convertAttachmentsToAPIAttachments(ctx context.Context, atta } // FilterToAPIFiltersV1 converts one GTS model filter into an API v1 filter list -func (c *Converter) FilterToAPIFiltersV1(ctx context.Context, filter *gtsmodel.Filter) ([]*apimodel.FilterV1, error) { +func FilterToAPIFiltersV1(filter *gtsmodel.Filter) []*apimodel.FilterV1 { apiFilters := make([]*apimodel.FilterV1, 0, len(filter.Keywords)) for _, filterKeyword := range filter.Keywords { - apiFilter, err := c.FilterKeywordToAPIFilterV1(ctx, filterKeyword) - if err != nil { - return nil, err - } + apiFilter := FilterKeywordToAPIFilterV1(filter, filterKeyword) apiFilters = append(apiFilters, apiFilter) } - return apiFilters, nil + return apiFilters } // FilterKeywordToAPIFilterV1 converts one GTS model filter and filter keyword into an API v1 filter -func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) (*apimodel.FilterV1, error) { - if filterKeyword.Filter == nil { - return nil, gtserror.New("FilterKeyword model's Filter field isn't populated, but needs to be") - } - filter := filterKeyword.Filter - +func FilterKeywordToAPIFilterV1(filter *gtsmodel.Filter, keyword *gtsmodel.FilterKeyword) *apimodel.FilterV1 { return &apimodel.FilterV1{ // v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID. - ID: filterKeyword.ID, - Phrase: filterKeyword.Keyword, + ID: keyword.ID, + Phrase: keyword.Keyword, Context: filterToAPIFilterContexts(filter), - WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false), + WholeWord: util.PtrOrValue(keyword.WholeWord, false), ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt), Irreversible: filter.Action == gtsmodel.FilterActionHide, - }, nil + } } // FilterToAPIFilterV2 converts one GTS model filter into an API v2 filter. -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, *c.FilterKeywordToAPIFilterKeyword(ctx, filterKeyword)) +func FilterToAPIFilterV2(filter *gtsmodel.Filter) *apimodel.FilterV2 { + apiFilterKeywords := make([]apimodel.FilterKeyword, len(filter.Keywords)) + if len(apiFilterKeywords) != len(filter.Keywords) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) } - - apiFilterStatuses := make([]apimodel.FilterStatus, 0, len(filter.Keywords)) - for _, filterStatus := range filter.Statuses { - apiFilterStatuses = append(apiFilterStatuses, *c.FilterStatusToAPIFilterStatus(ctx, filterStatus)) + for i, filterKeyword := range filter.Keywords { + apiFilterKeywords[i] = apimodel.FilterKeyword{ + ID: filterKeyword.ID, + Keyword: filterKeyword.Keyword, + WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false), + } + } + apiFilterStatuses := make([]apimodel.FilterStatus, len(filter.Statuses)) + if len(apiFilterStatuses) != len(filter.Statuses) { + // bound check eliminiation compiler-hint + panic(gtserror.New("BCE")) + } + for i, filterStatus := range filter.Statuses { + apiFilterStatuses[i] = apimodel.FilterStatus{ + ID: filterStatus.ID, + StatusID: filterStatus.StatusID, + } } - return &apimodel.FilterV2{ ID: filter.ID, Title: filter.Title, @@ -2608,7 +2594,7 @@ func (c *Converter) FilterToAPIFilterV2(ctx context.Context, filter *gtsmodel.Fi FilterAction: filterActionToAPIFilterAction(filter.Action), Keywords: apiFilterKeywords, Statuses: apiFilterStatuses, - }, nil + } } func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string { @@ -2620,19 +2606,19 @@ func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string { func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext { apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues) - if util.PtrOrValue(filter.ContextHome, false) { + if filter.Contexts.Home() { apiContexts = append(apiContexts, apimodel.FilterContextHome) } - if util.PtrOrValue(filter.ContextNotifications, false) { + if filter.Contexts.Notifications() { apiContexts = append(apiContexts, apimodel.FilterContextNotifications) } - if util.PtrOrValue(filter.ContextPublic, false) { + if filter.Contexts.Public() { apiContexts = append(apiContexts, apimodel.FilterContextPublic) } - if util.PtrOrValue(filter.ContextThread, false) { + if filter.Contexts.Thread() { apiContexts = append(apiContexts, apimodel.FilterContextThread) } - if util.PtrOrValue(filter.ContextAccount, false) { + if filter.Contexts.Account() { apiContexts = append(apiContexts, apimodel.FilterContextAccount) } return apiContexts @@ -2649,7 +2635,7 @@ func filterActionToAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterActio } // FilterKeywordToAPIFilterKeyword converts a GTS model filter status into an API filter status. -func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) *apimodel.FilterKeyword { +func FilterKeywordToAPIFilterKeyword(filterKeyword *gtsmodel.FilterKeyword) *apimodel.FilterKeyword { return &apimodel.FilterKeyword{ ID: filterKeyword.ID, Keyword: filterKeyword.Keyword, @@ -2658,7 +2644,7 @@ func (c *Converter) FilterKeywordToAPIFilterKeyword(ctx context.Context, filterK } // FilterStatusToAPIFilterStatus converts a GTS model filter status into an API filter status. -func (c *Converter) FilterStatusToAPIFilterStatus(ctx context.Context, filterStatus *gtsmodel.FilterStatus) *apimodel.FilterStatus { +func FilterStatusToAPIFilterStatus(filterStatus *gtsmodel.FilterStatus) *apimodel.FilterStatus { return &apimodel.FilterStatus{ ID: filterStatus.ID, StatusID: filterStatus.StatusID, @@ -3027,7 +3013,7 @@ func (c *Converter) InteractionReqToAPIInteractionReq( ctx, req.Status, requestingAcct, - statusfilter.FilterContextNone, + gtsmodel.FilterContextNone, nil, // No filters. ) if err != nil { @@ -3041,7 +3027,7 @@ func (c *Converter) InteractionReqToAPIInteractionReq( ctx, req.Reply, requestingAcct, - statusfilter.FilterContextNone, + gtsmodel.FilterContextNone, nil, // No filters. true, // Placehold unknown attachments. diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 03d812556..1795180e9 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -465,7 +465,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { testStatus := suite.testStatuses["admin_account_status_1"] requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, gtsmodel.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -628,7 +628,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendHTMLContentWarning testStatus.ContentWarning = `

First paragraph of content warning

Here's the title!

Big boobs
Tee hee!

Some more text
And a bunch more

Hasta la victoria siempre!

` requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, gtsmodel.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -794,7 +794,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendApplicationDeleted } requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(ctx, testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(ctx, testStatus, requestingAccount, gtsmodel.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -971,7 +971,6 @@ func (suite *InternalToFrontendTestSuite) filteredStatusToFrontend(action gtsmod expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"] suite.NoError(expectedMatchingFilterKeyword.Compile()) - expectedMatchingFilterKeyword.Filter = expectedMatchingFilter expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword} @@ -981,7 +980,7 @@ func (suite *InternalToFrontendTestSuite) filteredStatusToFrontend(action gtsmod suite.T().Context(), testStatus, requestingAccount, - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, requestingAccountFilters, ) } @@ -1521,20 +1520,16 @@ func (suite *InternalToFrontendTestSuite) testHashtagFilteredStatusToFrontend(wh } filter := >smodel.Filter{ - Action: gtsmodel.FilterActionWarn, - Keywords: []*gtsmodel.FilterKeyword{filterKeyword}, - ContextHome: util.Ptr(true), - ContextNotifications: util.Ptr(false), - ContextPublic: util.Ptr(false), - ContextThread: util.Ptr(false), - ContextAccount: util.Ptr(false), + Action: gtsmodel.FilterActionWarn, + Keywords: []*gtsmodel.FilterKeyword{filterKeyword}, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome), } apiStatus, err := suite.typeconverter.StatusToAPIStatus( suite.T().Context(), testStatus, requestingAccount, - statusfilter.FilterContextHome, + gtsmodel.FilterContextHome, []*gtsmodel.Filter{filter}, ) if err != nil { @@ -1564,7 +1559,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments testStatus := suite.testStatuses["remote_account_2_status_1"] requestingAccount := suite.testAccounts["admin_account"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, gtsmodel.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -1891,7 +1886,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() *testStatus = *suite.testStatuses["admin_account_status_1"] testStatus.Language = "" requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, gtsmodel.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -2052,7 +2047,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction *testStatus = *suite.testStatuses["local_account_1_status_3"] testStatus.Language = "" requestingAccount := suite.testAccounts["admin_account"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, gtsmodel.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -2165,7 +2160,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() suite.T().Context(), testStatus, requestingAccount, - statusfilter.FilterContextNone, + gtsmodel.FilterContextNone, nil, ) if err != nil { diff --git a/test/envparsing.sh b/test/envparsing.sh index b6d75cf07..0c737c1d9 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -42,6 +42,7 @@ EXPECT=$(cat << "EOF" "cache-domain-permission-subscription-mem-ratio": 0.5, "cache-emoji-category-mem-ratio": 0.1, "cache-emoji-mem-ratio": 3, + "cache-filter-ids-mem-ratio": 2, "cache-filter-keyword-mem-ratio": 0.5, "cache-filter-mem-ratio": 0.5, "cache-filter-status-mem-ratio": 0.5, diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 42caf59bd..d8cfb9b82 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -4091,54 +4091,45 @@ func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[strin func NewTestFilters() map[string]*gtsmodel.Filter { return map[string]*gtsmodel.Filter{ "local_account_1_filter_1": { - ID: "01HN26VM6KZTW1ANNRVSBMA461", - CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", - Title: "fnord", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), - ContextPublic: util.Ptr(true), + ID: "01HN26VM6KZTW1ANNRVSBMA461", + AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", + Title: "fnord", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), + KeywordIDs: []string{"01HN272TAVWAXX72ZX4M8JZ0PS"}, }, "local_account_1_filter_2": { - ID: "01HN277FSPQAWXZXK92QPPYF79", - CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", - Title: "metasyntactic variables", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), - ContextPublic: util.Ptr(true), + ID: "01HN277FSPQAWXZXK92QPPYF79", + AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", + Title: "metasyntactic variables", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), + KeywordIDs: []string{"01HN277Y11ENG4EC1ERMAC9FH4", "01HN278494N88BA2FY4DZ5JTNS", "01HXATJTGYT4BTG2YASE5M7GSD"}, }, "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), + ID: "01HWXQDXE4QX4R9EGMG729Y76C", + AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", + Title: "puppies", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), + StatusIDs: []string{"01HWXQDY8EE182AWQKS45JV50W"}, }, "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), + ID: "01HZ55WWWP82WYP2A1BKWK8Y9Q", + AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", + Title: "empty filter with no keywords or statuses", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), + KeywordIDs: []string{}, }, "local_account_2_filter_1": { - ID: "01HNGFYJBED9FS0VWRVMY4TKXH", - CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - AccountID: "01F8MH1VYJAE00TVVGMM5JNJ8X", - Title: "gamer words", - Action: gtsmodel.FilterActionWarn, - ContextHome: util.Ptr(true), - ContextPublic: util.Ptr(true), + ID: "01HNGFYJBED9FS0VWRVMY4TKXH", + AccountID: "01F8MH1VYJAE00TVVGMM5JNJ8X", + Title: "gamer words", + Action: gtsmodel.FilterActionWarn, + Contexts: gtsmodel.FilterContexts(gtsmodel.FilterContextHome | gtsmodel.FilterContextPublic), + KeywordIDs: []string{"01HNGG51HV2JT67XQ5MQ7RA1WE"}, + StatusIDs: []string{"01HX9WXVEH05E78ABR81FZFFFY"}, }, } } @@ -4147,45 +4138,30 @@ func NewTestFilterKeywords() map[string]*gtsmodel.FilterKeyword { return map[string]*gtsmodel.FilterKeyword{ "local_account_1_filter_1_keyword_1": { ID: "01HN272TAVWAXX72ZX4M8JZ0PS", - CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", FilterID: "01HN26VM6KZTW1ANNRVSBMA461", Keyword: "fnord", WholeWord: util.Ptr(true), }, "local_account_1_filter_2_keyword_1": { ID: "01HN277Y11ENG4EC1ERMAC9FH4", - CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", FilterID: "01HN277FSPQAWXZXK92QPPYF79", Keyword: "foo", WholeWord: util.Ptr(true), }, "local_account_1_filter_2_keyword_2": { ID: "01HN278494N88BA2FY4DZ5JTNS", - CreatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", FilterID: "01HN277FSPQAWXZXK92QPPYF79", 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"), - UpdatedAt: TimeMustParse("2024-01-25T12:20:03+02:00"), - AccountID: "01F8MH1VYJAE00TVVGMM5JNJ8X", FilterID: "01HNGFYJBED9FS0VWRVMY4TKXH", Keyword: "Virtual Boy", WholeWord: util.Ptr(true), @@ -4196,20 +4172,14 @@ func NewTestFilterKeywords() map[string]*gtsmodel.FilterKeyword { func NewTestFilterStatuses() 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", + ID: "01HWXQDY8EE182AWQKS45JV50W", + 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", + ID: "01HX9WXVEH05E78ABR81FZFFFY", + FilterID: "01HNGFYJBED9FS0VWRVMY4TKXH", + StatusID: "01FVW7JHQFSFK166WWKR8CBA6M", }, } }