[performance] filter model and database table improvements (#4277)

- removes unnecessary fields / columns (created_at, updated_at)
- replaces filter.context_* columns with singular filter.contexts bit field which should save both struct memory and database space
- replaces filter.action string with integer enum type which should save both struct memory and database space
- adds links from filter to filter_* tables with Filter{}.KeywordIDs and Filter{}.StatusIDs fields (this also means we now have those ID slices cached, which reduces some lookups)
- removes account_id fields from filter_* tables, since there's a more direct connection between filter and filter_* tables, and filter.account_id already exists
- refactors a bunch of the filter processor logic to save on code repetition, factor in the above changes, fix a few bugs with missed error returns and bring it more in-line with some of our newer code

Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4277
Co-authored-by: kim <grufwub@gmail.com>
Co-committed-by: kim <grufwub@gmail.com>
This commit is contained in:
kim 2025-06-24 17:24:34 +02:00 committed by tobi
commit 996da6e029
82 changed files with 2440 additions and 1722 deletions

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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)
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -86,6 +86,7 @@ func (c *Caches) Init() {
c.initDomainPermissionExclude()
c.initEmoji()
c.initEmojiCategory()
c.initFilterIDs()
c.initFilter()
c.initFilterKeyword()
c.initFilterStatus()

48
internal/cache/db.go vendored
View file

@ -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,
})
}

View file

@ -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)

View file

@ -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(&gtsmodel.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(&gtsmodel.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(&gtsmodel.FilterStatus{
ID: exampleID,
CreatedAt: exampleTime,
UpdatedAt: exampleTime,
FilterID: exampleID,
StatusID: exampleID,
ID: exampleID,
FilterID: exampleID,
StatusID: exampleID,
}))
}

View file

@ -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"`

View file

@ -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,

View file

@ -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"},
} {

View file

@ -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)
}

View file

@ -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
}

View file

@ -38,26 +38,30 @@ func (suite *FilterTestSuite) TestFilterCRUD() {
// Create new example filter with attached keyword.
filter := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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) {

View file

@ -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
}

View file

@ -32,12 +32,11 @@ func (suite *FilterTestSuite) TestFilterKeywordCRUD() {
// Create new filter.
filter := &gtsmodel.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 := &gtsmodel.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)
}

View file

@ -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
}

View file

@ -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 := &gtsmodel.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 := &gtsmodel.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)
}

View file

@ -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"
)

View file

@ -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 <http://www.gnu.org/licenses/>.
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"
)

View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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.
}

View file

@ -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 {
//<editor-fold desc="Filter methods">
// 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
//</editor-fold>
//<editor-fold desc="Filter keyword methods">
// 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
//</editor-fold>
//<editor-fold desc="Filter status methods">
// 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
//</editor-fold>
// DeleteFilterStatusesByIDs deletes filter statuses with the given ids.
DeleteFilterStatusesByIDs(ctx context.Context, ids ...string) error
}

View file

@ -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"
)

View file

@ -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{

View file

@ -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

View file

@ -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"
)

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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(

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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.

View file

@ -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
}

View file

@ -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 <http://www.gnu.org/licenses/>.
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
}

View file

@ -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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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.

View file

@ -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 := &gtsmodel.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
}

View file

@ -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
}

View file

@ -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.

View file

@ -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
}

View file

@ -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 := &gtsmodel.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
}

View file

@ -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
}

View file

@ -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.

View file

@ -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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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
}

View file

@ -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)

View file

@ -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

View file

@ -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,
)

View file

@ -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)

View file

@ -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

View file

@ -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) {

View file

@ -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 = &gtsmodel.Filter{
filterStatusID = id.NewULID()
filterStatus = &gtsmodel.FilterStatus{
ID: filterStatusID,
FilterID: filterID,
StatusID: filteredStatus.ID,
}
filter = &gtsmodel.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())

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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 = &gtsmodel.Filter{
filterStatusID = id.NewULID()
filterStatus = &gtsmodel.FilterStatus{
ID: filterStatusID,
FilterID: filterID,
StatusID: filteredStatus.ID,
}
filter = &gtsmodel.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())

View file

@ -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) {

View file

@ -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) {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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,
)

View file

@ -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.

View file

@ -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 = `<p>First paragraph of content warning</p><h4>Here's the title!</h4><p></p><p>Big boobs<br>Tee hee!<br><br>Some more text<br>And a bunch more<br><br>Hasta la victoria siempre!</p>`
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 := &gtsmodel.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 {

View file

@ -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,

View file

@ -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",
},
}
}