diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go index 703df5e4b..3ec12fb83 100644 --- a/cmd/gotosocial/action/server/server.go +++ b/cmd/gotosocial/action/server/server.go @@ -41,6 +41,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/federation" "code.superseriousbusiness.org/gotosocial/internal/federation/federatingdb" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/spam" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtserror" @@ -268,6 +269,7 @@ var Start action.GTSAction = func(ctx context.Context) error { ) typeConverter := typeutils.NewConverter(state) visFilter := visibility.NewFilter(state) + muteFilter := mutes.NewFilter(state) intFilter := interaction.NewFilter(state) spamFilter := spam.NewFilter(state) federatingDB := federatingdb.New(state, typeConverter, visFilter, intFilter, spamFilter) @@ -348,6 +350,7 @@ var Start action.GTSAction = func(ctx context.Context) error { emailSender, webPushSender, visFilter, + muteFilter, intFilter, ) diff --git a/internal/api/client/notifications/notificationsget.go b/internal/api/client/notifications/notificationsget.go index e6f0d342b..5550ed082 100644 --- a/internal/api/client/notifications/notificationsget.go +++ b/internal/api/client/notifications/notificationsget.go @@ -169,7 +169,7 @@ func (m *Module) NotificationsGETHandler(c *gin.Context) { ctx := c.Request.Context() resp, errWithCode := m.processor.Timeline().NotificationsGet( ctx, - authed, + authed.Account, page, parseNotificationTypes(ctx, c.QueryArray(TypesKey)), // Include types. parseNotificationTypes(ctx, c.QueryArray(ExcludeTypesKey)), // Exclude types. diff --git a/internal/api/wellknown/webfinger/webfingerget_test.go b/internal/api/wellknown/webfinger/webfingerget_test.go index 0ad35d4d9..2bdc0f461 100644 --- a/internal/api/wellknown/webfinger/webfingerget_test.go +++ b/internal/api/wellknown/webfinger/webfingerget_test.go @@ -33,6 +33,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/cleaner" "code.superseriousbusiness.org/gotosocial/internal/config" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/processing" @@ -98,6 +99,7 @@ func (suite *WebfingerGetTestSuite) funkifyAccountDomain(host string, accountDom suite.emailSender, testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), interaction.NewFilter(&suite.state), ) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index d05b85a15..54777441f 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -49,6 +49,10 @@ type Caches struct { // Timelines ... Timelines TimelineCaches + // Mutes provides access to the item mutes + // cache. (used by the item mutes filter). + Mutes MutesCache + // Visibility provides access to the item visibility // cache. (used by the visibility filter). Visibility VisibilityCache @@ -125,6 +129,7 @@ func (c *Caches) Init() { c.initWebfinger() c.initWebPushSubscription() c.initWebPushSubscriptionIDs() + c.initMutes() c.initVisibility() } diff --git a/internal/cache/db.go b/internal/cache/db.go index 78cc01e06..5592ca493 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -1531,9 +1531,10 @@ func (c *Caches) initThreadMute() { {Fields: "AccountID", Multiple: true}, {Fields: "ThreadID,AccountID"}, }, - MaxSize: cap, - IgnoreErr: ignoreErrors, - Copy: copyF, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateThreadMute, }) } diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go index 3512bb51e..88b7415ae 100644 --- a/internal/cache/invalidate.go +++ b/internal/cache/invalidate.go @@ -28,12 +28,18 @@ import ( // HOOKS TO BE CALLED ON DELETE YOU MUST FIRST POPULATE IT IN THE CACHE. func (c *Caches) OnInvalidateAccount(account *gtsmodel.Account) { - // Invalidate stats for this account. + // Invalidate cached stats objects for this account. c.DB.AccountStats.Invalidate("AccountID", account.ID) - // Invalidate account ID cached visibility. + // Invalidate as possible visibility target result. c.Visibility.Invalidate("ItemID", account.ID) - c.Visibility.Invalidate("RequesterID", account.ID) + + // If account is local, invalidate as + // possible mute / visibility result requester. + if account.IsLocal() { + c.Visibility.Invalidate("RequesterID", account.ID) + c.Mutes.Invalidate("RequesterID", account.ID) + } // Invalidate this account's // following / follower lists. @@ -66,13 +72,31 @@ func (c *Caches) OnInvalidateApplication(app *gtsmodel.Application) { } func (c *Caches) OnInvalidateBlock(block *gtsmodel.Block) { - // Invalidate block origin account ID cached visibility. - c.Visibility.Invalidate("ItemID", block.AccountID) - c.Visibility.Invalidate("RequesterID", block.AccountID) + // Invalidate both block origin and target as + // possible lookup targets for visibility results. + c.Visibility.InvalidateIDs("ItemID", []string{ + block.TargetAccountID, + block.AccountID, + }) - // Invalidate block target account ID cached visibility. - c.Visibility.Invalidate("ItemID", block.TargetAccountID) - c.Visibility.Invalidate("RequesterID", block.TargetAccountID) + // Track which of block / target are local. + localAccountIDs := make([]string, 0, 2) + + // If origin is local (or uncertain), also invalidate + // results for them as mute / visibility result requester. + if block.Account == nil || block.Account.IsLocal() { + localAccountIDs = append(localAccountIDs, block.AccountID) + } + + // If target is local (or uncertain), also invalidate + // results for them as mute / visibility result requester. + if block.TargetAccount == nil || block.TargetAccount.IsLocal() { + localAccountIDs = append(localAccountIDs, block.TargetAccountID) + } + + // Now perform local mute / visibility result invalidations. + c.Visibility.InvalidateIDs("RequesterID", localAccountIDs) + c.Mutes.InvalidateIDs("RequesterID", localAccountIDs) // Invalidate source account's block lists. c.DB.BlockIDs.Invalidate(block.AccountID) @@ -92,13 +116,31 @@ func (c *Caches) OnInvalidateFollow(follow *gtsmodel.Follow) { // Invalidate follow request with this same ID. c.DB.FollowRequest.Invalidate("ID", follow.ID) - // Invalidate follow origin account ID cached visibility. - c.Visibility.Invalidate("ItemID", follow.AccountID) - c.Visibility.Invalidate("RequesterID", follow.AccountID) + // Invalidate both follow origin and target as + // possible lookup targets for visibility results. + c.Visibility.InvalidateIDs("ItemID", []string{ + follow.TargetAccountID, + follow.AccountID, + }) - // Invalidate follow target account ID cached visibility. - c.Visibility.Invalidate("ItemID", follow.TargetAccountID) - c.Visibility.Invalidate("RequesterID", follow.TargetAccountID) + // Track which of follow / target are local. + localAccountIDs := make([]string, 0, 2) + + // If origin is local (or uncertain), also invalidate + // results for them as mute / visibility result requester. + if follow.Account == nil || follow.Account.IsLocal() { + localAccountIDs = append(localAccountIDs, follow.AccountID) + } + + // If target is local (or uncertain), also invalidate + // results for them as mute / visibility result requester. + if follow.TargetAccount == nil || follow.TargetAccount.IsLocal() { + localAccountIDs = append(localAccountIDs, follow.TargetAccountID) + } + + // Now perform local mute / visibility result invalidations. + c.Visibility.InvalidateIDs("RequesterID", localAccountIDs) + c.Mutes.InvalidateIDs("RequesterID", localAccountIDs) // Invalidate ID slice cache. c.DB.FollowIDs.Invalidate( @@ -227,12 +269,16 @@ func (c *Caches) OnInvalidatePollVote(vote *gtsmodel.PollVote) { } func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) { - // Invalidate stats for this account. + // Invalidate cached stats objects for this account. c.DB.AccountStats.Invalidate("AccountID", status.AccountID) // Invalidate status ID cached visibility. c.Visibility.Invalidate("ItemID", status.ID) + // Invalidate mute results involving status. + c.Mutes.Invalidate("StatusID", status.ID) + c.Mutes.Invalidate("ThreadID", status.ThreadID) + // Invalidate each media by the IDs we're aware of. // This must be done as the status table is aware of // the media IDs in use before the media table is @@ -277,6 +323,11 @@ func (c *Caches) OnInvalidateStatusFave(fave *gtsmodel.StatusFave) { c.DB.StatusFaveIDs.Invalidate(fave.StatusID) } +func (c *Caches) OnInvalidateThreadMute(mute *gtsmodel.ThreadMute) { + // Invalidate cached mute ressults encapsulating this thread and account. + c.Mutes.Invalidate("RequesterID,ThreadID", mute.AccountID, mute.ThreadID) +} + func (c *Caches) OnInvalidateToken(token *gtsmodel.Token) { // Invalidate token's push subscription. c.DB.WebPushSubscription.Invalidate("ID", token.ID) @@ -294,6 +345,9 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) { func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) { // Invalidate source account's user mute lists. c.DB.UserMuteIDs.Invalidate(mute.AccountID) + + // Invalidate source account's cached mute results. + c.Mutes.Invalidate("RequesterID", mute.AccountID) } func (c *Caches) OnInvalidateWebPushSubscription(subscription *gtsmodel.WebPushSubscription) { diff --git a/internal/cache/mutes.go b/internal/cache/mutes.go new file mode 100644 index 000000000..9ad7736a0 --- /dev/null +++ b/internal/cache/mutes.go @@ -0,0 +1,109 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cache + +import ( + "time" + + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/log" + "codeberg.org/gruf/go-structr" +) + +type MutesCache struct { + StructCache[*CachedMute] +} + +func (c *Caches) initMutes() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofMute(), // model in-mem size. + config.GetCacheMutesMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(m1 *CachedMute) *CachedMute { + m2 := new(CachedMute) + *m2 = *m1 + return m2 + } + + c.Mutes.Init(structr.CacheConfig[*CachedMute]{ + Indices: []structr.IndexConfig{ + {Fields: "RequesterID,StatusID"}, + {Fields: "RequesterID,ThreadID", Multiple: true}, + {Fields: "StatusID", Multiple: true}, + {Fields: "ThreadID", Multiple: true}, + {Fields: "RequesterID", Multiple: true}, + }, + MaxSize: cap, + IgnoreErr: func(err error) bool { + // don't cache any errors, + // it gets a little too tricky + // otherwise with ensuring + // errors are cleared out + return true + }, + Copy: copyF, + }) +} + +// CachedMute contains the details +// of a cached mute lookup. +type CachedMute struct { + + // StatusID is the ID of the + // status this is a result for. + StatusID string + + // ThreadID is the ID of the + // thread status is a part of. + ThreadID string + + // RequesterID is the ID of the requesting + // account for this user mute lookup. + RequesterID string + + // Mute indicates whether ItemID + // is muted by RequesterID. + Mute bool + + // MuteExpiry stores the time at which + // (if any) the stored mute value expires. + MuteExpiry time.Time + + // Notifications indicates whether + // this mute should prevent notifications + // being shown for ItemID to RequesterID. + Notifications bool + + // NotificationExpiry stores the time at which + // (if any) the stored notification value expires. + NotificationExpiry time.Time +} + +// MuteExpired returns whether the mute value has expired. +func (m *CachedMute) MuteExpired(now time.Time) bool { + return !m.MuteExpiry.IsZero() && !m.MuteExpiry.After(now) +} + +// NotificationExpired returns whether the notification mute value has expired. +func (m *CachedMute) NotificationExpired(now time.Time) bool { + return !m.NotificationExpiry.IsZero() && !m.NotificationExpiry.After(now) +} diff --git a/internal/cache/size.go b/internal/cache/size.go index 7898f9dfd..ef9259f88 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -150,7 +150,7 @@ func calculateCacheMax(keySz, valSz uintptr, ratio float64) int { // The inputted memory ratio does not take into account the // total of all ratios, so divide it here to get perc. ratio. - totalRatio := ratio / totalOfRatios() + totalRatio := ratio / config.GetTotalOfMemRatios() // TODO: we should also further weight this ratio depending // on the combined keySz + valSz as a ratio of all available @@ -172,65 +172,6 @@ func calculateCacheMax(keySz, valSz uintptr, ratio float64) int { return int(fMaxMem / (fKeySz + fValSz + emptyBucketOverhead + float64(cacheElemOverhead))) } -// totalOfRatios returns the total of all cache ratios added together. -func totalOfRatios() float64 { - - // NOTE: this is not performant calculating - // this every damn time (mainly the mutex unlocks - // required to access each config var). fortunately - // we only do this on init so fuck it :D - return 0 + - config.GetCacheAccountMemRatio() + - config.GetCacheAccountNoteMemRatio() + - config.GetCacheAccountSettingsMemRatio() + - config.GetCacheAccountStatsMemRatio() + - config.GetCacheApplicationMemRatio() + - config.GetCacheBlockMemRatio() + - config.GetCacheBlockIDsMemRatio() + - config.GetCacheBoostOfIDsMemRatio() + - config.GetCacheClientMemRatio() + - config.GetCacheEmojiMemRatio() + - config.GetCacheEmojiCategoryMemRatio() + - config.GetCacheFilterMemRatio() + - config.GetCacheFilterKeywordMemRatio() + - config.GetCacheFilterStatusMemRatio() + - config.GetCacheFollowMemRatio() + - config.GetCacheFollowIDsMemRatio() + - config.GetCacheFollowRequestMemRatio() + - config.GetCacheFollowRequestIDsMemRatio() + - config.GetCacheFollowingTagIDsMemRatio() + - config.GetCacheInReplyToIDsMemRatio() + - config.GetCacheInstanceMemRatio() + - config.GetCacheInteractionRequestMemRatio() + - config.GetCacheListMemRatio() + - config.GetCacheListIDsMemRatio() + - config.GetCacheListedIDsMemRatio() + - config.GetCacheMarkerMemRatio() + - config.GetCacheMediaMemRatio() + - config.GetCacheMentionMemRatio() + - config.GetCacheMoveMemRatio() + - config.GetCacheNotificationMemRatio() + - config.GetCachePollMemRatio() + - config.GetCachePollVoteMemRatio() + - config.GetCachePollVoteIDsMemRatio() + - config.GetCacheReportMemRatio() + - config.GetCacheSinBinStatusMemRatio() + - config.GetCacheStatusMemRatio() + - config.GetCacheStatusBookmarkMemRatio() + - config.GetCacheStatusBookmarkIDsMemRatio() + - config.GetCacheStatusFaveMemRatio() + - config.GetCacheStatusFaveIDsMemRatio() + - config.GetCacheTagMemRatio() + - config.GetCacheThreadMuteMemRatio() + - config.GetCacheTokenMemRatio() + - config.GetCacheTombstoneMemRatio() + - config.GetCacheUserMemRatio() + - config.GetCacheUserMuteMemRatio() + - config.GetCacheUserMuteIDsMemRatio() + - config.GetCacheWebfingerMemRatio() + - config.GetCacheVisibilityMemRatio() -} - func sizeofAccount() uintptr { return uintptr(size.Of(>smodel.Account{ ID: exampleID, @@ -769,6 +710,18 @@ func sizeofTombstone() uintptr { })) } +func sizeofMute() uintptr { + return uintptr(size.Of(&CachedMute{ + StatusID: exampleID, + ThreadID: exampleID, + RequesterID: exampleID, + Mute: true, + MuteExpiry: exampleTime, + Notifications: true, + NotificationExpiry: exampleTime, + })) +} + func sizeofVisibility() uintptr { return uintptr(size.Of(&CachedVisibility{ ItemID: exampleID, diff --git a/internal/cache/visibility.go b/internal/cache/visibility.go index 63927cf08..3797ab701 100644 --- a/internal/cache/visibility.go +++ b/internal/cache/visibility.go @@ -34,7 +34,7 @@ func (c *Caches) initVisibility() { config.GetCacheVisibilityMemRatio(), ) - log.Infof(nil, "Visibility cache size = %d", cap) + log.Infof(nil, "cache size = %d", cap) copyF := func(v1 *CachedVisibility) *CachedVisibility { v2 := new(CachedVisibility) @@ -73,12 +73,16 @@ const ( VisibilityTypePublic = VisibilityType('p') ) -// CachedVisibility represents a cached visibility lookup value. +// CachedVisibility represents a +// cached visibility lookup value. type CachedVisibility struct { - // ItemID is the ID of the item in question (status / account). + + // ItemID is the ID of the item + // in question (status / account). ItemID string - // RequesterID is the ID of the requesting account for this visibility lookup. + // RequesterID is the ID of the requesting + // account for this visibility lookup. RequesterID string // Type is the visibility lookup type. diff --git a/internal/config/config.go b/internal/config/config.go index d9293e062..303bf8266 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,6 +257,7 @@ type CacheConfiguration struct { WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` WebPushSubscriptionMemRatio float64 `name:"web-push-subscription-mem-ratio"` WebPushSubscriptionIDsMemRatio float64 `name:"web-push-subscription-ids-mem-ratio"` + MutesMemRatio float64 `name:"mutes-mem-ratio"` VisibilityMemRatio float64 `name:"visibility-mem-ratio"` } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index ad124e90f..43168a471 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -229,6 +229,7 @@ var Defaults = Configuration{ WebPushSubscriptionMemRatio: 1, WebPushSubscriptionIDsMemRatio: 1, VisibilityMemRatio: 2, + MutesMemRatio: 2, }, HTTPClient: HTTPClientConfiguration{ diff --git a/internal/config/gen/gen.go b/internal/config/gen/gen.go index b3532caf8..faede7987 100644 --- a/internal/config/gen/gen.go +++ b/internal/config/gen/gen.go @@ -24,6 +24,7 @@ import ( "os" "os/exec" "reflect" + "slices" "strings" "time" @@ -485,6 +486,24 @@ func generateGetSetters(out io.Writer, fields []ConfigField) { fprintf(out, "// Set%s safely sets the value for global configuration '%s' field\n", name, field.Path) fprintf(out, "func Set%[1]s(v %[2]s) { global.Set%[1]s(v) }\n\n", name, fieldType) } + + // Separate out the config fields (from a clone!!!) to get only the 'mem-ratio' members. + memFields := slices.DeleteFunc(slices.Clone(fields), func(field ConfigField) bool { + return !strings.Contains(field.Path, "MemRatio") + }) + + fprintf(out, "// GetTotalOfMemRatios safely fetches the combined value for all the state's mem ratio fields\n") + fprintf(out, "func (st *ConfigState) GetTotalOfMemRatios() (total float64) {\n") + fprintf(out, "\tst.mutex.RLock()\n") + for _, field := range memFields { + fprintf(out, "\ttotal += st.config.%s\n", field.Path) + } + fprintf(out, "\tst.mutex.RUnlock()\n") + fprintf(out, "\treturn\n") + fprintf(out, "}\n\n") + + fprintf(out, "// GetTotalOfMemRatios safely fetches the combined value for all the global state's mem ratio fields\n") + fprintf(out, "func GetTotalOfMemRatios() (total float64) { return global.GetTotalOfMemRatios() }\n\n") } func generateMapFlattener(out io.Writer, fields []ConfigField) { diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 0803bab08..e710a9dc2 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -145,7 +145,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) { flags.Int("advanced-throttling-multiplier", cfg.Advanced.Throttling.Multiplier, "Multiplier to use per cpu for http request throttling. 0 or less turns throttling off.") flags.Duration("advanced-throttling-retry-after", cfg.Advanced.Throttling.RetryAfter, "Retry-After duration response to send for throttled requests.") flags.Bool("advanced-scraper-deterrence-enabled", cfg.Advanced.ScraperDeterrence.Enabled, "Enable proof-of-work based scraper deterrence on profile / status pages") - flags.Uint32("advanced-scraper-deterrence-difficulty", cfg.Advanced.ScraperDeterrence.Difficulty, "The proof-of-work difficulty, which determines how many leading zeros to try solve in hash solutions.") + flags.Uint32("advanced-scraper-deterrence-difficulty", cfg.Advanced.ScraperDeterrence.Difficulty, "The proof-of-work difficulty, which determines roughly how many hash-encode rounds required of each client.") flags.StringSlice("http-client-allow-ips", cfg.HTTPClient.AllowIPs, "") flags.StringSlice("http-client-block-ips", cfg.HTTPClient.BlockIPs, "") flags.Duration("http-client-timeout", cfg.HTTPClient.Timeout, "") @@ -206,11 +206,12 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) { flags.Float64("cache-webfinger-mem-ratio", cfg.Cache.WebfingerMemRatio, "") flags.Float64("cache-web-push-subscription-mem-ratio", cfg.Cache.WebPushSubscriptionMemRatio, "") flags.Float64("cache-web-push-subscription-ids-mem-ratio", cfg.Cache.WebPushSubscriptionIDsMemRatio, "") + flags.Float64("cache-mutes-mem-ratio", cfg.Cache.MutesMemRatio, "") flags.Float64("cache-visibility-mem-ratio", cfg.Cache.VisibilityMemRatio, "") } func (cfg *Configuration) MarshalMap() map[string]any { - cfgmap := make(map[string]any, 184) + cfgmap := make(map[string]any, 186) cfgmap["log-level"] = cfg.LogLevel cfgmap["log-timestamp-format"] = cfg.LogTimestampFormat cfgmap["log-db-queries"] = cfg.LogDbQueries @@ -388,6 +389,7 @@ func (cfg *Configuration) MarshalMap() map[string]any { cfgmap["cache-webfinger-mem-ratio"] = cfg.Cache.WebfingerMemRatio cfgmap["cache-web-push-subscription-mem-ratio"] = cfg.Cache.WebPushSubscriptionMemRatio cfgmap["cache-web-push-subscription-ids-mem-ratio"] = cfg.Cache.WebPushSubscriptionIDsMemRatio + cfgmap["cache-mutes-mem-ratio"] = cfg.Cache.MutesMemRatio cfgmap["cache-visibility-mem-ratio"] = cfg.Cache.VisibilityMemRatio cfgmap["username"] = cfg.AdminAccountUsername cfgmap["email"] = cfg.AdminAccountEmail @@ -1855,6 +1857,14 @@ func (cfg *Configuration) UnmarshalMap(cfgmap map[string]any) error { } } + if ival, ok := cfgmap["cache-mutes-mem-ratio"]; ok { + var err error + cfg.Cache.MutesMemRatio, err = cast.ToFloat64E(ival) + if err != nil { + return fmt.Errorf("error casting %#v -> float64 for 'cache-mutes-mem-ratio': %w", ival, err) + } + } + if ival, ok := cfgmap["cache-visibility-mem-ratio"]; ok { var err error cfg.Cache.VisibilityMemRatio, err = cast.ToFloat64E(ival) @@ -6385,6 +6395,31 @@ func SetCacheWebPushSubscriptionIDsMemRatio(v float64) { global.SetCacheWebPushSubscriptionIDsMemRatio(v) } +// CacheMutesMemRatioFlag returns the flag name for the 'Cache.MutesMemRatio' field +func CacheMutesMemRatioFlag() string { return "cache-mutes-mem-ratio" } + +// GetCacheMutesMemRatio safely fetches the Configuration value for state's 'Cache.MutesMemRatio' field +func (st *ConfigState) GetCacheMutesMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.MutesMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheMutesMemRatio safely sets the Configuration value for state's 'Cache.MutesMemRatio' field +func (st *ConfigState) SetCacheMutesMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.MutesMemRatio = v + st.reloadToViper() +} + +// GetCacheMutesMemRatio safely fetches the value for global configuration 'Cache.MutesMemRatio' field +func GetCacheMutesMemRatio() float64 { return global.GetCacheMutesMemRatio() } + +// SetCacheMutesMemRatio safely sets the value for global configuration 'Cache.MutesMemRatio' field +func SetCacheMutesMemRatio(v float64) { global.SetCacheMutesMemRatio(v) } + // CacheVisibilityMemRatioFlag returns the flag name for the 'Cache.VisibilityMemRatio' field func CacheVisibilityMemRatioFlag() string { return "cache-visibility-mem-ratio" } @@ -6585,6 +6620,73 @@ func GetAdminMediaListRemoteOnly() bool { return global.GetAdminMediaListRemoteO // SetAdminMediaListRemoteOnly safely sets the value for global configuration 'AdminMediaListRemoteOnly' field func SetAdminMediaListRemoteOnly(v bool) { global.SetAdminMediaListRemoteOnly(v) } +// GetTotalOfMemRatios safely fetches the combined value for all the state's mem ratio fields +func (st *ConfigState) GetTotalOfMemRatios() (total float64) { + st.mutex.RLock() + total += st.config.Cache.AccountMemRatio + total += st.config.Cache.AccountNoteMemRatio + total += st.config.Cache.AccountSettingsMemRatio + total += st.config.Cache.AccountStatsMemRatio + total += st.config.Cache.ApplicationMemRatio + total += st.config.Cache.BlockMemRatio + total += st.config.Cache.BlockIDsMemRatio + total += st.config.Cache.BoostOfIDsMemRatio + total += st.config.Cache.ClientMemRatio + total += st.config.Cache.ConversationMemRatio + total += st.config.Cache.ConversationLastStatusIDsMemRatio + total += st.config.Cache.DomainPermissionDraftMemRation + total += st.config.Cache.DomainPermissionSubscriptionMemRation + total += st.config.Cache.EmojiMemRatio + total += st.config.Cache.EmojiCategoryMemRatio + total += st.config.Cache.FilterMemRatio + total += st.config.Cache.FilterKeywordMemRatio + total += st.config.Cache.FilterStatusMemRatio + total += st.config.Cache.FollowMemRatio + total += st.config.Cache.FollowIDsMemRatio + total += st.config.Cache.FollowRequestMemRatio + total += st.config.Cache.FollowRequestIDsMemRatio + total += st.config.Cache.FollowingTagIDsMemRatio + total += st.config.Cache.InReplyToIDsMemRatio + total += st.config.Cache.InstanceMemRatio + total += st.config.Cache.InteractionRequestMemRatio + total += st.config.Cache.ListMemRatio + total += st.config.Cache.ListIDsMemRatio + total += st.config.Cache.ListedIDsMemRatio + total += st.config.Cache.MarkerMemRatio + total += st.config.Cache.MediaMemRatio + total += st.config.Cache.MentionMemRatio + total += st.config.Cache.MoveMemRatio + total += st.config.Cache.NotificationMemRatio + total += st.config.Cache.PollMemRatio + total += st.config.Cache.PollVoteMemRatio + total += st.config.Cache.PollVoteIDsMemRatio + total += st.config.Cache.ReportMemRatio + total += st.config.Cache.SinBinStatusMemRatio + total += st.config.Cache.StatusMemRatio + total += st.config.Cache.StatusBookmarkMemRatio + total += st.config.Cache.StatusBookmarkIDsMemRatio + total += st.config.Cache.StatusEditMemRatio + total += st.config.Cache.StatusFaveMemRatio + total += st.config.Cache.StatusFaveIDsMemRatio + total += st.config.Cache.TagMemRatio + total += st.config.Cache.ThreadMuteMemRatio + total += st.config.Cache.TokenMemRatio + total += st.config.Cache.TombstoneMemRatio + total += st.config.Cache.UserMemRatio + total += st.config.Cache.UserMuteMemRatio + total += st.config.Cache.UserMuteIDsMemRatio + total += st.config.Cache.WebfingerMemRatio + total += st.config.Cache.WebPushSubscriptionMemRatio + total += st.config.Cache.WebPushSubscriptionIDsMemRatio + total += st.config.Cache.MutesMemRatio + total += st.config.Cache.VisibilityMemRatio + st.mutex.RUnlock() + return +} + +// GetTotalOfMemRatios safely fetches the combined value for all the global state's mem ratio fields +func GetTotalOfMemRatios() (total float64) { return global.GetTotalOfMemRatios() } + func flattenConfigMap(cfgmap map[string]any) { nestedKeys := make(map[string]struct{}) for _, key := range [][]string{ @@ -7363,6 +7465,17 @@ func flattenConfigMap(cfgmap map[string]any) { } } + for _, key := range [][]string{ + {"cache", "mutes-mem-ratio"}, + } { + ival, ok := mapGet(cfgmap, key...) + if ok { + cfgmap["cache-mutes-mem-ratio"] = ival + nestedKeys[key[0]] = struct{}{} + break + } + } + for _, key := range [][]string{ {"cache", "visibility-mem-ratio"}, } { diff --git a/internal/filter/mutes/account.go b/internal/filter/mutes/account.go new file mode 100644 index 000000000..ecf4ffa4e --- /dev/null +++ b/internal/filter/mutes/account.go @@ -0,0 +1,85 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package mutes + +import ( + "context" + "errors" + "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" +) + +// NOTE: +// we don't bother using the Mutes cache for any +// of the accounts functions below, as there's only +// a single cache load required of any UserMute. + +// AccountMuted returns whether given target account is muted by requester. +func (f *Filter) AccountMuted(ctx context.Context, requester *gtsmodel.Account, account *gtsmodel.Account) (bool, error) { + mute, expired, err := f.getUserMute(ctx, requester, account) + if err != nil { + return false, err + } else if mute == nil { + return false, nil + } + return !expired, nil +} + +// AccountNotificationsMuted returns whether notifications are muted for requester when incoming from given target account. +func (f *Filter) AccountNotificationsMuted(ctx context.Context, requester *gtsmodel.Account, account *gtsmodel.Account) (bool, error) { + mute, expired, err := f.getUserMute(ctx, requester, account) + if err != nil { + return false, err + } else if mute == nil { + return false, nil + } + return *mute.Notifications && !expired, nil +} + +func (f *Filter) getUserMute(ctx context.Context, requester *gtsmodel.Account, account *gtsmodel.Account) (*gtsmodel.UserMute, bool, error) { + if requester == nil { + // Un-authed so no account + // is possible to be muted. + return nil, false, nil + } + + // Look for mute against target. + mute, err := f.state.DB.GetMute( + gtscontext.SetBarebones(ctx), + requester.ID, + account.ID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, false, gtserror.Newf("db error getting user mute: %w", err) + } + + if mute == nil { + // No user mute exists! + return nil, false, nil + } + + // Get current time. + now := time.Now() + + // Return whether mute is expired. + return mute, mute.Expired(now), nil +} diff --git a/internal/filter/mutes/filter.go b/internal/filter/mutes/filter.go new file mode 100644 index 000000000..20adc3daf --- /dev/null +++ b/internal/filter/mutes/filter.go @@ -0,0 +1,45 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package mutes + +import ( + "time" + + "code.superseriousbusiness.org/gotosocial/internal/state" +) + +type muteDetails struct { + // mute flags. + mute bool + notif bool + + // mute expiry times. + muteExpiry time.Time + notifExpiry time.Time +} + +// noauth is a placeholder ID used in cache lookups +// when there is no authorized account ID to use. +const noauth = "noauth" + +// Filter packages up a bunch of logic for checking whether +// given statuses or accounts are muted by a requester (user). +type Filter struct{ state *state.State } + +// NewFilter returns a new Filter interface that will use the provided database. +func NewFilter(state *state.State) *Filter { return &Filter{state: state} } diff --git a/internal/filter/mutes/filter_test.go b/internal/filter/mutes/filter_test.go new file mode 100644 index 000000000..260f6cff0 --- /dev/null +++ b/internal/filter/mutes/filter_test.go @@ -0,0 +1,75 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package mutes_test + +import ( + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/testrig" + "github.com/stretchr/testify/suite" +) + +type FilterStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + state state.State + + // standard suite models + testTokens map[string]*gtsmodel.Token + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testTags map[string]*gtsmodel.Tag + testMentions map[string]*gtsmodel.Mention + testFollows map[string]*gtsmodel.Follow + + filter *mutes.Filter +} + +func (suite *FilterStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() + suite.testTags = testrig.NewTestTags() + suite.testMentions = testrig.NewTestMentions() + suite.testFollows = testrig.NewTestFollows() +} + +func (suite *FilterStandardTestSuite) SetupTest() { + suite.state.Caches.Init() + + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.filter = mutes.NewFilter(&suite.state) + + testrig.StandardDBSetup(suite.db, nil) +} + +func (suite *FilterStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} diff --git a/internal/filter/mutes/status.go b/internal/filter/mutes/status.go new file mode 100644 index 000000000..e2ef1e5a5 --- /dev/null +++ b/internal/filter/mutes/status.go @@ -0,0 +1,302 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package mutes + +import ( + "context" + "errors" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/cache" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" +) + +// StatusMuted returns whether given target status is muted for requester in the context of timeline visibility. +func (f *Filter) StatusMuted(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (muted bool, err error) { + details, err := f.StatusMuteDetails(ctx, requester, status) + if err != nil { + return false, gtserror.Newf("error getting status mute details: %w", err) + } + return details.Mute && !details.MuteExpired(time.Now()), nil +} + +// StatusNotificationsMuted returns whether notifications are muted for requester when regarding given target status. +func (f *Filter) StatusNotificationsMuted(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (muted bool, err error) { + details, err := f.StatusMuteDetails(ctx, requester, status) + if err != nil { + return false, gtserror.Newf("error getting status mute details: %w", err) + } + return details.Notifications && !details.NotificationExpired(time.Now()), nil +} + +// StatusMuteDetails returns mute details about the given status for the given requesting account. +func (f *Filter) StatusMuteDetails(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (*cache.CachedMute, error) { + + // For requester ID use a + // fallback 'noauth' string + // by default for lookups. + requesterID := noauth + if requester != nil { + requesterID = requester.ID + } + + // Load mute details for this requesting account about status from cache, using load callback if needed. + details, err := f.state.Caches.Mutes.LoadOne("RequesterID,StatusID", func() (*cache.CachedMute, error) { + + // Load the mute details for given status. + details, err := f.getStatusMuteDetails(ctx, + requester, + status, + ) + if err != nil { + if err == cache.SentinelError { + // Filter-out our temporary + // race-condition error. + return &cache.CachedMute{}, nil + } + + return nil, err + } + + // Convert to cache details. + return &cache.CachedMute{ + StatusID: status.ID, + ThreadID: status.ThreadID, + RequesterID: requester.ID, + Mute: details.mute, + MuteExpiry: details.muteExpiry, + Notifications: details.notif, + NotificationExpiry: details.notifExpiry, + }, nil + }, requesterID, status.ID) + if err != nil { + return nil, err + } + + return details, err +} + +// getStatusMuteDetails loads muteDetails{} for the given +// status and the thread it is a part of, including any +// relevant muted parent status authors / mentions. +func (f *Filter) getStatusMuteDetails( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) ( + muteDetails, + error, +) { + var details muteDetails + + if requester == nil { + // Without auth, there will be no possible + // mute to exist. Always return as 'unmuted'. + return details, nil + } + + // Look for a stored mute from account against thread. + threadMute, err := f.state.DB.GetThreadMutedByAccount(ctx, + status.ThreadID, + requester.ID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return details, gtserror.Newf("db error checking thread mute: %w", err) + } + + // Set notif mute on thread mute. + details.notif = (threadMute != nil) + + for next := status; ; { + // Load the mute details for 'next' status + // in current thread, into our details obj. + if err = f.loadOneStatusMuteDetails(ctx, + requester, + next, + &details, + ); err != nil { + return details, err + } + + if next.InReplyToURI == "" { + // Reached the top + // of the thread. + break + } + + if next.InReplyToID == "" { + // Parent is not yet dereferenced. + return details, cache.SentinelError + } + + // Check if parent is set. + inReplyTo := next.InReplyTo + if inReplyTo == nil { + + // Fetch next parent in conversation. + inReplyTo, err = f.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + next.InReplyToID, + ) + if err != nil { + return details, gtserror.Newf("error getting status parent %s: %w", next.InReplyToURI, err) + } + } + + // Set next status. + next = inReplyTo + } + + return details, nil +} + +// loadOneStatusMuteDetails loads the mute details for +// any relevant accounts to given status to the requesting +// account into the passed muteDetails object pointer. +func (f *Filter) loadOneStatusMuteDetails( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, + details *muteDetails, +) error { + // Look for mutes against related status accounts + // by requester (e.g. author, mention targets etc). + userMutes, err := f.getStatusRelatedUserMutes(ctx, + requester, + status, + ) + if err != nil { + return err + } + + for _, mute := range userMutes { + // Set as muted! + details.mute = true + + // Set notifications as + // muted if flag is set. + if *mute.Notifications { + details.notif = true + } + + // Check for expiry data given. + if !mute.ExpiresAt.IsZero() { + + // Update mute details expiry time if later. + if mute.ExpiresAt.After(details.muteExpiry) { + details.muteExpiry = mute.ExpiresAt + } + + // Update notif details expiry time if later. + if mute.ExpiresAt.After(details.notifExpiry) { + details.notifExpiry = mute.ExpiresAt + } + } + } + + return nil +} + +// getStatusRelatedUserMutes fetches user mutes for any +// of the possible related accounts regarding this status, +// i.e. the author and any account mentioned. +func (f *Filter) getStatusRelatedUserMutes( + ctx context.Context, + requester *gtsmodel.Account, + status *gtsmodel.Status, +) ( + []*gtsmodel.UserMute, + error, +) { + if status.AccountID == requester.ID { + // Status is by requester, we don't take + // into account related attached user mutes. + return nil, nil + } + + if !status.MentionsPopulated() { + var err error + + // Populate status mention objects before further mention checks. + status.Mentions, err = f.state.DB.GetMentions(ctx, status.MentionIDs) + if err != nil { + return nil, gtserror.Newf("error populating status %s mentions: %w", status.URI, err) + } + } + + // Preallocate a slice of worst possible case no. user mutes. + mutes := make([]*gtsmodel.UserMute, 0, 2+len(status.Mentions)) + + // Look for mute against author. + mute, err := f.state.DB.GetMute( + gtscontext.SetBarebones(ctx), + requester.ID, + status.AccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("db error getting status author mute: %w", err) + } + + if mute != nil { + // Append author mute to total. + mutes = append(mutes, mute) + } + + if status.BoostOfAccountID != "" { + // Look for mute against boost author. + mute, err := f.state.DB.GetMute( + gtscontext.SetBarebones(ctx), + requester.ID, + status.AccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("db error getting boost author mute: %w", err) + } + + if mute != nil { + // Append author mute to total. + mutes = append(mutes, mute) + } + } + + for _, mention := range status.Mentions { + // Look for mute against any target mentions. + if mention.TargetAccountID != requester.ID { + + // Look for mute against target. + mute, err := f.state.DB.GetMute( + gtscontext.SetBarebones(ctx), + requester.ID, + mention.TargetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("db error getting mention target mute: %w", err) + } + + if mute != nil { + // Append target mute to total. + mutes = append(mutes, mute) + } + } + } + + return mutes, nil +} diff --git a/internal/filter/mutes/status_test.go b/internal/filter/mutes/status_test.go new file mode 100644 index 000000000..917900adf --- /dev/null +++ b/internal/filter/mutes/status_test.go @@ -0,0 +1,283 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package mutes_test + +import ( + "testing" + + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/util" + "github.com/stretchr/testify/suite" +) + +type StatusMuteTestSuite struct { + FilterStandardTestSuite +} + +func (suite *StatusMuteTestSuite) TestMutedStatusAuthor() { + ctx := suite.T().Context() + + status := suite.testStatuses["admin_account_status_1"] + requester := suite.testAccounts["local_account_1"] + replyer := suite.testAccounts["local_account_2"] + + // Generate a new reply + // to the above status. + replyID := id.NewULID() + reply := >smodel.Status{ + ID: replyID, + URI: replyer.URI + "/statuses/" + replyID, + ThreadID: status.ThreadID, + AccountID: replyer.ID, + AccountURI: replyer.URI, + InReplyToID: status.ID, + InReplyToURI: status.URI, + InReplyToAccountID: status.AccountID, + Local: util.Ptr(false), + Federated: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + + // And insert reply into the database. + err := suite.db.PutStatus(ctx, reply) + suite.NoError(err) + + // Ensure that neither status nor reply are muted to requester. + muted1, err1 := suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 := suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Ensure notifications for neither status nor reply are muted to requester. + muted1, err = suite.filter.StatusNotificationsMuted(ctx, requester, status) + muted2, err = suite.filter.StatusNotificationsMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Insert new user mute targetting first status author. + err = suite.state.DB.PutMute(ctx, >smodel.UserMute{ + ID: id.NewULID(), + AccountID: requester.ID, + TargetAccountID: status.AccountID, + Notifications: util.Ptr(false), + }) + suite.NoError(err) + + // Now ensure that both status and reply are muted to requester. + muted1, err1 = suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 = suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.True(muted1) + suite.True(muted2) + + // Though neither status nor reply should have notifications muted to requester. + muted1, err = suite.filter.StatusNotificationsMuted(ctx, requester, status) + muted2, err = suite.filter.StatusNotificationsMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Now delete account mutes to / from requesting account. + err = suite.state.DB.DeleteAccountMutes(ctx, requester.ID) + suite.NoError(err) + + // Now ensure that both status and reply are unmuted again. + muted1, err1 = suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 = suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) +} + +func (suite *StatusMuteTestSuite) TestMutedStatusMentionee() { + ctx := suite.T().Context() + + status := suite.testStatuses["admin_account_status_5"] + requester := suite.testAccounts["local_account_1"] + mentionee := suite.testAccounts["local_account_2"] + replyer := suite.testAccounts["local_account_3"] + + // Generate a new reply + // to the above status. + replyID := id.NewULID() + reply := >smodel.Status{ + ID: replyID, + URI: replyer.URI + "/statuses/" + replyID, + ThreadID: status.ThreadID, + AccountID: replyer.ID, + AccountURI: replyer.URI, + InReplyToID: status.ID, + InReplyToURI: status.URI, + InReplyToAccountID: status.AccountID, + Local: util.Ptr(false), + Federated: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + + // And insert reply into the database. + err := suite.db.PutStatus(ctx, reply) + suite.NoError(err) + + // Ensure that neither status nor reply are muted to requester. + muted1, err1 := suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 := suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Ensure notifications for neither status nor reply are muted to requester. + muted1, err = suite.filter.StatusNotificationsMuted(ctx, requester, status) + muted2, err = suite.filter.StatusNotificationsMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Insert user visibility mute targetting status author. + err = suite.state.DB.PutMute(ctx, >smodel.UserMute{ + ID: id.NewULID(), + AccountID: requester.ID, + TargetAccountID: mentionee.ID, + Notifications: util.Ptr(false), + }) + suite.NoError(err) + + // Now ensure that both status and reply are muted to requester. + muted1, err1 = suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 = suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.True(muted1) + suite.True(muted2) + + // Though neither status nor reply should have notifications muted to requester. + muted1, err = suite.filter.StatusNotificationsMuted(ctx, requester, status) + muted2, err = suite.filter.StatusNotificationsMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Now delete account mutes to / from requesting account. + err = suite.state.DB.DeleteAccountMutes(ctx, requester.ID) + suite.NoError(err) + + // Now ensure that both status and reply are unmuted again. + muted1, err1 = suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 = suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) +} + +func (suite *StatusMuteTestSuite) TestMutedStatusThread() { + ctx := suite.T().Context() + + status := suite.testStatuses["admin_account_status_1"] + requester := suite.testAccounts["local_account_1"] + replyer := suite.testAccounts["local_account_2"] + + // Generate a new reply + // to the above status. + replyID := id.NewULID() + reply := >smodel.Status{ + ID: replyID, + URI: replyer.URI + "/statuses/" + replyID, + ThreadID: status.ThreadID, + AccountID: replyer.ID, + AccountURI: replyer.URI, + InReplyToID: status.ID, + InReplyToURI: status.URI, + InReplyToAccountID: status.AccountID, + Local: util.Ptr(false), + Federated: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + } + + // And insert reply into the database. + err := suite.db.PutStatus(ctx, reply) + suite.NoError(err) + + // Ensure that neither status nor reply are muted to requester. + muted1, err1 := suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 := suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Ensure notifications for neither status nor reply are muted to requester. + muted1, err = suite.filter.StatusNotificationsMuted(ctx, requester, status) + muted2, err = suite.filter.StatusNotificationsMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + threadMuteID := id.NewULID() + + // Insert new notification mute targetting status thread. + err = suite.db.PutThreadMute(ctx, >smodel.ThreadMute{ + ID: threadMuteID, + AccountID: requester.ID, + ThreadID: status.ThreadID, + }) + + // Ensure status and reply are still not muted to requester. + muted1, err1 = suite.filter.StatusMuted(ctx, requester, status) + muted2, err2 = suite.filter.StatusMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) + + // Though now ensure notifications for both ARE muted to requester. + muted1, err = suite.filter.StatusNotificationsMuted(ctx, requester, status) + muted2, err = suite.filter.StatusNotificationsMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.True(muted1) + suite.True(muted2) + + // Now delete the mute from requester targetting thread. + err = suite.state.DB.DeleteThreadMute(ctx, threadMuteID) + suite.NoError(err) + + // Andf ensure notifications for both are unmuted to the requester again. + muted1, err = suite.filter.StatusNotificationsMuted(ctx, requester, status) + muted2, err = suite.filter.StatusNotificationsMuted(ctx, requester, reply) + suite.NoError(err1) + suite.NoError(err2) + suite.False(muted1) + suite.False(muted2) +} + +func TestStatusMuteTestSuite(t *testing.T) { + suite.Run(t, new(StatusMuteTestSuite)) +} diff --git a/internal/filter/usermute/usermute.go b/internal/filter/usermute/usermute.go deleted file mode 100644 index d8d1aae46..000000000 --- a/internal/filter/usermute/usermute.go +++ /dev/null @@ -1,80 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package usermute - -import ( - "time" - - statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" - "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" -) - -type compiledUserMuteListEntry struct { - ExpiresAt time.Time - Notifications bool -} - -func (e *compiledUserMuteListEntry) appliesInContext(filterContext statusfilter.FilterContext) bool { - switch filterContext { - case statusfilter.FilterContextHome: - return true - case statusfilter.FilterContextNotifications: - return e.Notifications - case statusfilter.FilterContextPublic: - return true - case statusfilter.FilterContextThread: - return true - case statusfilter.FilterContextAccount: - return false - } - return false -} - -func (e *compiledUserMuteListEntry) expired(now time.Time) bool { - return !e.ExpiresAt.IsZero() && !e.ExpiresAt.After(now) -} - -type CompiledUserMuteList struct { - byTargetAccountID map[string]compiledUserMuteListEntry -} - -func NewCompiledUserMuteList(mutes []*gtsmodel.UserMute) (c *CompiledUserMuteList) { - c = &CompiledUserMuteList{byTargetAccountID: make(map[string]compiledUserMuteListEntry, len(mutes))} - for _, mute := range mutes { - c.byTargetAccountID[mute.TargetAccountID] = compiledUserMuteListEntry{ - ExpiresAt: mute.ExpiresAt, - Notifications: *mute.Notifications, - } - } - return -} - -func (c *CompiledUserMuteList) Len() int { - if c == nil { - return 0 - } - return len(c.byTargetAccountID) -} - -func (c *CompiledUserMuteList) Matches(accountID string, filterContext statusfilter.FilterContext, now time.Time) bool { - if c == nil { - return false - } - e, found := c.byTargetAccountID[accountID] - return found && e.appliesInContext(filterContext) && !e.expired(now) -} diff --git a/internal/filter/visibility/home_timeline.go b/internal/filter/visibility/home_timeline.go index 03a3b62c3..fbb6ea3da 100644 --- a/internal/filter/visibility/home_timeline.go +++ b/internal/filter/visibility/home_timeline.go @@ -161,15 +161,22 @@ func (f *Filter) isStatusHomeTimelineable(ctx context.Context, owner *gtsmodel.A return false, cache.SentinelError } - // Fetch next parent in conversation. - inReplyToID := next.InReplyToID - next, err = f.state.DB.GetStatusByID( - gtscontext.SetBarebones(ctx), - inReplyToID, - ) - if err != nil { - return false, gtserror.Newf("error getting status parent %s: %w", inReplyToID, err) + // Check if parent is set. + inReplyTo := next.InReplyTo + if inReplyTo == nil { + + // Fetch next parent in conversation. + inReplyTo, err = f.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + next.InReplyToID, + ) + if err != nil { + return false, gtserror.Newf("error getting status parent %s: %w", next.InReplyToURI, err) + } } + + // Set next status. + next = inReplyTo } if next != status && !oneAuthor && !visible { diff --git a/internal/filter/visibility/status.go b/internal/filter/visibility/status.go index 6edb32ec0..24fa6f2e6 100644 --- a/internal/filter/visibility/status.go +++ b/internal/filter/visibility/status.go @@ -316,7 +316,8 @@ func (f *Filter) areStatusAccountsVisible(ctx context.Context, requester *gtsmod // This is a boosted status. if status.AccountID == status.BoostOfAccountID { - // Some clout-chaser boosted their own status, tch. + // Some clout-chaser boosted + // their own status, tch. return true, nil } diff --git a/internal/processing/account/account_test.go b/internal/processing/account/account_test.go index 4af63d1b3..d4fb6ddfb 100644 --- a/internal/processing/account/account_test.go +++ b/internal/processing/account/account_test.go @@ -25,6 +25,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/media" @@ -104,9 +105,10 @@ func (suite *AccountStandardTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) - filter := visibility.NewFilter(&suite.state) - common := common.New(&suite.state, suite.mediaManager, suite.tc, suite.federator, filter) - suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, filter, processing.GetParseMentionFunc(&suite.state, suite.federator)) + visFilter := visibility.NewFilter(&suite.state) + mutesFilter := mutes.NewFilter(&suite.state) + common := common.New(&suite.state, suite.mediaManager, suite.tc, suite.federator, visFilter, mutesFilter) + suite.accountProcessor = account.New(&common, &suite.state, suite.tc, suite.mediaManager, suite.federator, visFilter, processing.GetParseMentionFunc(&suite.state, suite.federator)) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") } diff --git a/internal/processing/account/bookmarks.go b/internal/processing/account/bookmarks.go index 7c7f7cb07..329bcf30c 100644 --- a/internal/processing/account/bookmarks.go +++ b/internal/processing/account/bookmarks.go @@ -75,7 +75,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode } // Convert the status. - item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil) + item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) if err != nil { log.Errorf(ctx, "error converting bookmarked status to api: %s", err) continue diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 191fdcb5f..0ff9ef7e1 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -105,7 +105,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, nil) + item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/admin/admin_test.go b/internal/processing/admin/admin_test.go index db46fb17f..8f2eb23f2 100644 --- a/internal/processing/admin/admin_test.go +++ b/internal/processing/admin/admin_test.go @@ -24,6 +24,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/media" @@ -113,6 +114,7 @@ func (suite *AdminStandardTestSuite) SetupTest() { suite.emailSender, testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), interaction.NewFilter(&suite.state), ) diff --git a/internal/processing/common/common.go b/internal/processing/common/common.go index 9f4753147..bebbdffea 100644 --- a/internal/processing/common/common.go +++ b/internal/processing/common/common.go @@ -19,6 +19,7 @@ package common import ( "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/media" "code.superseriousbusiness.org/gotosocial/internal/state" @@ -29,11 +30,12 @@ import ( // common to multiple logical domains of the // processing subsection of the codebase. type Processor struct { - state *state.State - media *media.Manager - converter *typeutils.Converter - federator *federation.Federator - visFilter *visibility.Filter + state *state.State + media *media.Manager + converter *typeutils.Converter + federator *federation.Federator + visFilter *visibility.Filter + muteFilter *mutes.Filter } // New returns a new Processor instance. @@ -43,12 +45,14 @@ func New( converter *typeutils.Converter, federator *federation.Federator, visFilter *visibility.Filter, + muteFilter *mutes.Filter, ) Processor { return Processor{ - state: state, - media: media, - converter: converter, - federator: federator, - visFilter: visFilter, + state: state, + media: media, + converter: converter, + federator: federator, + visFilter: visFilter, + muteFilter: muteFilter, } } diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index 14245f88a..441a58384 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -25,7 +25,6 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/federation/dereferencing" statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -216,7 +215,6 @@ func (p *Processor) GetAPIStatus( requester, statusfilter.FilterContextNone, nil, - nil, ) if err != nil { err := gtserror.Newf("error converting: %w", err) @@ -238,7 +236,6 @@ func (p *Processor) GetVisibleAPIStatuses( statuses []*gtsmodel.Status, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, - userMutes []*gtsmodel.UserMute, ) []apimodel.Status { // Start new log entry with @@ -247,9 +244,6 @@ func (p *Processor) GetVisibleAPIStatuses( l := log.WithContext(ctx). WithField("caller", log.Caller(3)) - // Compile mutes to useable user mutes for type converter. - compUserMutes := usermute.NewCompiledUserMuteList(userMutes) - // Iterate filtered statuses for conversion to API model. apiStatuses := make([]apimodel.Status, 0, len(statuses)) for _, status := range statuses { @@ -268,13 +262,23 @@ func (p *Processor) GetVisibleAPIStatuses( continue } + // Check whether this status is muted by requesting account. + muted, err := p.muteFilter.StatusMuted(ctx, requester, status) + if err != nil { + log.Errorf(ctx, "error checking mute: %v", err) + continue + } + + if muted { + continue + } + // Convert to API status, taking mute / filter into account. apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requester, filterContext, filters, - compUserMutes, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { l.Errorf("error converting: %v", err) diff --git a/internal/processing/conversations/conversations.go b/internal/processing/conversations/conversations.go index a4b8b7234..e31f60500 100644 --- a/internal/processing/conversations/conversations.go +++ b/internal/processing/conversations/conversations.go @@ -22,9 +22,8 @@ import ( "errors" "code.superseriousbusiness.org/gotosocial/internal/db" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/state" @@ -32,20 +31,23 @@ import ( ) type Processor struct { - state *state.State - converter *typeutils.Converter - filter *visibility.Filter + state *state.State + converter *typeutils.Converter + visFilter *visibility.Filter + muteFilter *mutes.Filter } func New( state *state.State, converter *typeutils.Converter, - filter *visibility.Filter, + visFilter *visibility.Filter, + muteFilter *mutes.Filter, ) Processor { return Processor{ - state: state, - converter: converter, - filter: filter, + state: state, + converter: converter, + visFilter: visFilter, + muteFilter: muteFilter, } } @@ -95,13 +97,13 @@ func (p *Processor) getConversationOwnedBy( } // getFiltersAndMutes gets the given account's filters and compiled mute list. -func (p *Processor) getFiltersAndMutes( +func (p *Processor) getFilters( ctx context.Context, requestingAccount *gtsmodel.Account, -) ([]*gtsmodel.Filter, *usermute.CompiledUserMuteList, gtserror.WithCode) { +) ([]*gtsmodel.Filter, gtserror.WithCode) { filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID) if err != nil { - return nil, nil, gtserror.NewErrorInternalError( + return nil, gtserror.NewErrorInternalError( gtserror.Newf( "DB error getting filters for account %s: %w", requestingAccount.ID, @@ -109,18 +111,5 @@ func (p *Processor) getFiltersAndMutes( ), ) } - - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) - if err != nil { - return nil, nil, gtserror.NewErrorInternalError( - gtserror.Newf( - "DB error getting mutes for account %s: %w", - requestingAccount.ID, - err, - ), - ) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - return filters, compiledMutes, nil + return filters, nil } diff --git a/internal/processing/conversations/conversations_test.go b/internal/processing/conversations/conversations_test.go index 40145c2fb..383938564 100644 --- a/internal/processing/conversations/conversations_test.go +++ b/internal/processing/conversations/conversations_test.go @@ -27,6 +27,7 @@ import ( dbtest "code.superseriousbusiness.org/gotosocial/internal/db/test" "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -53,7 +54,8 @@ type ConversationsTestSuite struct { federator *federation.Federator emailSender email.Sender sentEmails map[string]string - filter *visibility.Filter + visFilter *visibility.Filter + muteFilter *mutes.Filter // standard suite models testTokens map[string]*gtsmodel.Token @@ -104,7 +106,8 @@ func (suite *ConversationsTestSuite) SetupTest() { suite.state.DB = suite.db suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers) suite.tc = typeutils.NewConverter(&suite.state) - suite.filter = visibility.NewFilter(&suite.state) + suite.visFilter = visibility.NewFilter(&suite.state) + suite.muteFilter = mutes.NewFilter(&suite.state) suite.storage = testrig.NewInMemoryStorage() suite.state.Storage = suite.storage @@ -115,7 +118,7 @@ func (suite *ConversationsTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails) - suite.conversationsProcessor = conversations.New(&suite.state, suite.tc, suite.filter) + suite.conversationsProcessor = conversations.New(&suite.state, suite.tc, suite.visFilter, suite.muteFilter) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") diff --git a/internal/processing/conversations/get.go b/internal/processing/conversations/get.go index 9218942bf..5324466c9 100644 --- a/internal/processing/conversations/get.go +++ b/internal/processing/conversations/get.go @@ -64,23 +64,20 @@ func (p *Processor) GetAll( items := make([]interface{}, 0, count) - filters, mutes, errWithCode := p.getFiltersAndMutes(ctx, requestingAccount) + filters, errWithCode := p.getFilters(ctx, requestingAccount) if errWithCode != nil { return nil, errWithCode } for _, conversation := range conversations { // Convert conversation to frontend API model. - apiConversation, err := p.converter.ConversationToAPIConversation( - ctx, + apiConversation, err := p.converter.ConversationToAPIConversation(ctx, conversation, requestingAccount, filters, - mutes, ) if err != nil { - log.Errorf( - ctx, + log.Errorf(ctx, "error converting conversation %s to API representation: %v", conversation.ID, err, diff --git a/internal/processing/conversations/read.go b/internal/processing/conversations/read.go index 42f369582..4d16a4eeb 100644 --- a/internal/processing/conversations/read.go +++ b/internal/processing/conversations/read.go @@ -44,17 +44,15 @@ func (p *Processor) Read( return nil, gtserror.NewErrorInternalError(err) } - filters, mutes, errWithCode := p.getFiltersAndMutes(ctx, requestingAccount) + filters, errWithCode := p.getFilters(ctx, requestingAccount) if errWithCode != nil { return nil, errWithCode } - apiConversation, err := p.converter.ConversationToAPIConversation( - ctx, + apiConversation, err := p.converter.ConversationToAPIConversation(ctx, conversation, requestingAccount, filters, - mutes, ) if err != nil { err = gtserror.Newf("error converting conversation %s to API representation: %w", id, err) diff --git a/internal/processing/conversations/update.go b/internal/processing/conversations/update.go index 1651d5367..e4024a24a 100644 --- a/internal/processing/conversations/update.go +++ b/internal/processing/conversations/update.go @@ -31,10 +31,14 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/util" ) -// ConversationNotification carries the arguments to processing/stream.Processor.Conversation. +// ConversationNotification carries the arguments +// to processing/stream.Processor.Conversation. type ConversationNotification struct { - // AccountID of a local account to deliver the notification to. + + // AccountID of a local account to + // deliver the notification to. AccountID string + // Conversation as the notification payload. Conversation *apimodel.Conversation } @@ -46,11 +50,13 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt // Only DMs are considered part of conversations. return nil, nil } + if status.BoostOfID != "" { // Boosts can't be part of conversations. // FUTURE: This may change if we ever implement quote posts. return nil, nil } + if status.ThreadID == "" { // If the status doesn't have a thread ID, it didn't mention a local account, // and thus can't be part of a conversation. @@ -77,51 +83,15 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt } localAccount := participant - // If the status is not visible to this account, skip processing it for this account. - visible, err := p.filter.StatusVisible(ctx, localAccount, status) + // If status not visible to this account, skip further processing. + visible, err := p.visFilter.StatusVisible(ctx, localAccount, status) if err != nil { - log.Errorf( - ctx, - "error checking status %s visibility for account %s: %v", - status.ID, - localAccount.ID, - err, - ) + log.Errorf(ctx, "error checking status %s visibility for account %s: %v", status.URI, localAccount.URI, err) continue } else if !visible { continue } - // Is the status filtered or muted for this user? - // Converting the status to an API status runs the filter/mute checks. - filters, mutes, errWithCode := p.getFiltersAndMutes(ctx, localAccount) - if errWithCode != nil { - log.Error(ctx, errWithCode) - continue - } - _, err = p.converter.StatusToAPIStatus( - ctx, - status, - localAccount, - statusfilter.FilterContextNotifications, - filters, - mutes, - ) - if err != nil { - // If the status matched a hide filter, skip processing it for this account. - // If there was another kind of error, log that and skip it anyway. - if !errors.Is(err, statusfilter.ErrHideStatus) { - log.Errorf( - ctx, - "error checking status %s filtering/muting for account %s: %v", - status.ID, - localAccount.ID, - err, - ) - } - continue - } - // Collect other accounts participating in the conversation. otherAccounts := make([]*gtsmodel.Account, 0, len(allParticipantsSet)-1) otherAccountIDs := make([]string, 0, len(allParticipantsSet)-1) @@ -133,20 +103,14 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt } // Check for a previously existing conversation, if there is one. - conversation, err := p.state.DB.GetConversationByThreadAndAccountIDs( - ctx, + conversation, err := p.state.DB.GetConversationByThreadAndAccountIDs(ctx, status.ThreadID, localAccount.ID, otherAccountIDs, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - log.Errorf( - ctx, - "error trying to find a previous conversation for status %s and account %s: %v", - status.ID, - localAccount.ID, - err, - ) + log.Errorf(ctx, "error finding previous conversation for status %s and account %s: %v", + status.URI, localAccount.URI, err) continue } @@ -172,6 +136,7 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt conversation.LastStatusID = status.ID conversation.LastStatus = status } + // If the conversation is unread, leave it marked as unread. // If the conversation is read but this status might not have been, mark the conversation as unread. if !statusAuthoredByConversationOwner { @@ -181,43 +146,29 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt // Create or update the conversation. err = p.state.DB.UpsertConversation(ctx, conversation) if err != nil { - log.Errorf( - ctx, - "error creating or updating conversation %s for status %s and account %s: %v", - conversation.ID, - status.ID, - localAccount.ID, - err, - ) + log.Errorf(ctx, "error creating or updating conversation %s for status %s and account %s: %v", + conversation.ID, status.URI, localAccount.URI, err) continue } // Link the conversation to the status. if err := p.state.DB.LinkConversationToStatus(ctx, conversation.ID, status.ID); err != nil { - log.Errorf( - ctx, - "error linking conversation %s to status %s: %v", - conversation.ID, - status.ID, - err, - ) + log.Errorf(ctx, "error linking conversation %s to status %s: %v", + conversation.ID, status.URI, err) continue } // Convert the conversation to API representation. - apiConversation, err := p.converter.ConversationToAPIConversation( - ctx, + apiConversation, err := p.converter.ConversationToAPIConversation(ctx, conversation, localAccount, - filters, - mutes, + nil, ) if err != nil { // If the conversation's last status matched a hide filter, skip it. // If there was another kind of error, log that and skip it anyway. if !errors.Is(err, statusfilter.ErrHideStatus) { - log.Errorf( - ctx, + log.Errorf(ctx, "error converting conversation %s to API representation for account %s: %v", status.ID, localAccount.ID, @@ -227,15 +178,31 @@ func (p *Processor) UpdateConversationsForStatus(ctx context.Context, status *gt continue } - // Generate a notification, - // unless the status was authored by the user who would be notified, - // in which case they already know. - if status.AccountID != localAccount.ID { - notifications = append(notifications, ConversationNotification{ - AccountID: localAccount.ID, - Conversation: apiConversation, - }) + // If status was authored by this participant, + // don't bother notifying, they already know! + if status.AccountID == localAccount.ID { + continue } + + // Check whether status is muted to local participant. + muted, err := p.muteFilter.StatusNotificationsMuted(ctx, + localAccount, + status, + ) + if err != nil { + log.Errorf(ctx, "error checking status mute: %v", err) + continue + } + + if muted { + continue + } + + // Generate a notification, + notifications = append(notifications, ConversationNotification{ + AccountID: localAccount.ID, + Conversation: apiConversation, + }) } return notifications, nil diff --git a/internal/processing/media/media_test.go b/internal/processing/media/media_test.go index b074ae768..f2462a972 100644 --- a/internal/processing/media/media_test.go +++ b/internal/processing/media/media_test.go @@ -20,6 +20,7 @@ package media_test import ( "code.superseriousbusiness.org/gotosocial/internal/admin" "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/media" @@ -82,8 +83,9 @@ func (suite *MediaStandardTestSuite) SetupTest() { suite.transportController = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media")) federator := testrig.NewTestFederator(&suite.state, suite.transportController, suite.mediaManager) - filter := visibility.NewFilter(&suite.state) - common := common.New(&suite.state, suite.mediaManager, suite.tc, federator, filter) + visFilter := visibility.NewFilter(&suite.state) + muteFilter := mutes.NewFilter(&suite.state) + common := common.New(&suite.state, suite.mediaManager, suite.tc, federator, visFilter, muteFilter) suite.mediaProcessor = mediaprocessing.New(&common, &suite.state, suite.tc, federator, suite.mediaManager, suite.transportController) testrig.StandardDBSetup(suite.db, nil) diff --git a/internal/processing/polls/poll_test.go b/internal/processing/polls/poll_test.go index f7c0f9f02..848c3f169 100644 --- a/internal/processing/polls/poll_test.go +++ b/internal/processing/polls/poll_test.go @@ -24,6 +24,7 @@ import ( "testing" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -38,9 +39,10 @@ import ( type PollTestSuite struct { suite.Suite - state state.State - filter *visibility.Filter - polls polls.Processor + state state.State + visFilter *visibility.Filter + muteFilter *mutes.Filter + polls polls.Processor testAccounts map[string]*gtsmodel.Account testPolls map[string]*gtsmodel.Poll @@ -56,8 +58,9 @@ func (suite *PollTestSuite) SetupTest() { controller := testrig.NewTestTransportController(&suite.state, nil) mediaMgr := media.NewManager(&suite.state) federator := testrig.NewTestFederator(&suite.state, controller, mediaMgr) - suite.filter = visibility.NewFilter(&suite.state) - common := common.New(&suite.state, mediaMgr, converter, federator, suite.filter) + suite.visFilter = visibility.NewFilter(&suite.state) + suite.muteFilter = mutes.NewFilter(&suite.state) + common := common.New(&suite.state, mediaMgr, converter, federator, suite.visFilter, suite.muteFilter) suite.polls = polls.New(&common, &suite.state, converter) } @@ -88,7 +91,7 @@ func (suite *PollTestSuite) testPollGet(ctx context.Context, requester *gtsmodel var check func(*apimodel.Poll, gtserror.WithCode) bool switch { - case !pollIsVisible(suite.filter, ctx, requester, poll): + case !pollIsVisible(suite.visFilter, ctx, requester, poll): // Poll should not be visible to requester, this should // return an error code 404 (to prevent info leak). check = func(poll *apimodel.Poll, err gtserror.WithCode) bool { @@ -188,7 +191,7 @@ func (suite *PollTestSuite) testPollVote(ctx context.Context, requester *gtsmode return poll == nil && err.Code() == http.StatusUnprocessableEntity } - case !pollIsVisible(suite.filter, ctx, requester, poll): + case !pollIsVisible(suite.visFilter, ctx, requester, poll): // Poll should not be visible to requester, this should // return an error code 404 (to prevent info leak). check = func(poll *apimodel.Poll, err gtserror.WithCode) bool { diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 63e996b38..b8adb9bb8 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -22,6 +22,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" mm "code.superseriousbusiness.org/gotosocial/internal/media" @@ -203,6 +204,7 @@ func NewProcessor( emailSender email.Sender, webPushSender webpush.Sender, visFilter *visibility.Filter, + muteFilter *mutes.Filter, intFilter *interaction.Filter, ) *Processor { parseMentionFunc := GetParseMentionFunc(state, federator) @@ -218,7 +220,7 @@ func NewProcessor( // // Start with sub processors that will // be required by the workers processor. - common := common.New(state, mediaManager, converter, federator, visFilter) + common := common.New(state, mediaManager, converter, federator, visFilter, muteFilter) 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) @@ -228,7 +230,7 @@ func NewProcessor( processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, parseMentionFunc) processor.admin = admin.New(&common, state, cleaner, subscriptions, federator, converter, mediaManager, federator.TransportController(), emailSender) processor.application = application.New(state, converter) - processor.conversations = conversations.New(state, converter, visFilter) + 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) @@ -239,7 +241,7 @@ func NewProcessor( processor.push = push.New(state, converter) processor.report = report.New(state, converter) processor.tags = tags.New(state, converter) - processor.timeline = timeline.New(state, converter, visFilter) + processor.timeline = timeline.New(state, converter, visFilter, muteFilter) processor.search = search.New(state, federator, converter, visFilter) processor.status = status.New(state, &common, &processor.polls, &processor.interactionRequests, federator, converter, visFilter, intFilter, parseMentionFunc) processor.user = user.New(state, converter, oauthServer, emailSender) @@ -256,6 +258,7 @@ func NewProcessor( federator, converter, visFilter, + muteFilter, emailSender, webPushSender, &processor.account, diff --git a/internal/processing/processor_test.go b/internal/processing/processor_test.go index 6f5e79afd..847de29cf 100644 --- a/internal/processing/processor_test.go +++ b/internal/processing/processor_test.go @@ -27,6 +27,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/media" @@ -130,6 +131,7 @@ func (suite *ProcessingStandardTestSuite) SetupTest() { suite.emailSender, testrig.NewNoopWebPushSender(), visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), interaction.NewFilter(&suite.state), ) testrig.StartWorkers(&suite.state, suite.processor.Workers()) diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go index 5d8e34960..97eb813db 100644 --- a/internal/processing/search/util.go +++ b/internal/processing/search/util.go @@ -114,7 +114,7 @@ func (p *Processor) packageStatuses( continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) if err != nil { log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err) continue diff --git a/internal/processing/status/context.go b/internal/processing/status/context.go index c18a4f7bd..6f3e7a4fd 100644 --- a/internal/processing/status/context.go +++ b/internal/processing/status/context.go @@ -25,7 +25,6 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" 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" ) @@ -291,26 +290,8 @@ func (p *Processor) ContextGet( return nil, gtserror.NewErrorInternalError(err) } - // Retrieve mutes as they affect - // what should be shown to requester. - mutes, err := p.state.DB.GetAccountMutes( - // No need to populate mutes, - // IDs are enough here. - gtscontext.SetBarebones(ctx), - requester.ID, - nil, // No paging - get all. - ) - if err != nil { - err = gtserror.Newf( - "couldn't retrieve mutes for account %s: %w", - requester.ID, err, - ) - return nil, gtserror.NewErrorInternalError(err) - } - // Retrieve the full thread context. - threadContext, errWithCode := p.contextGet( - ctx, + threadContext, errWithCode := p.contextGet(ctx, requester, targetStatusID, ) @@ -326,7 +307,6 @@ func (p *Processor) ContextGet( threadContext.ancestors, statusfilter.FilterContextThread, filters, - mutes, ) // Convert and filter the thread context descendants @@ -335,7 +315,6 @@ func (p *Processor) ContextGet( threadContext.descendants, statusfilter.FilterContextThread, filters, - mutes, ) return &apiContext, nil @@ -352,8 +331,8 @@ func (p *Processor) WebContextGet( targetStatusID string, ) (*apimodel.WebThreadContext, gtserror.WithCode) { // Retrieve the internal thread context. - iCtx, errWithCode := p.contextGet( - ctx, + iCtx, errWithCode := p.contextGet(ctx, + nil, // No authed requester. targetStatusID, ) diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index 75775cf8a..091d9716b 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -22,6 +22,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/federation" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/media" @@ -92,9 +93,10 @@ func (suite *StatusStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(&suite.state, suite.tc, suite.mediaManager) visFilter := visibility.NewFilter(&suite.state) + muteFilter := mutes.NewFilter(&suite.state) intFilter := interaction.NewFilter(&suite.state) - common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter) + common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter, muteFilter) polls := polls.New(&common, &suite.state, suite.typeConverter) intReqs := interactionrequests.New(&common, &suite.state, suite.typeConverter) diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go index 483388823..8fc4bcfe8 100644 --- a/internal/processing/stream/statusupdate_test.go +++ b/internal/processing/stream/statusupdate_test.go @@ -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, nil) + apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(suite.T().Context(), editedStatus, account, statusfilter.FilterContextNotifications, nil) suite.NoError(err) suite.streamProcessor.StatusUpdate(suite.T().Context(), account, apiStatus, stream.TimelineHome) diff --git a/internal/processing/timeline/faved.go b/internal/processing/timeline/faved.go index 4cb3c30a5..84788a8fa 100644 --- a/internal/processing/timeline/faved.go +++ b/internal/processing/timeline/faved.go @@ -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, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go index bcb63fcff..ba74b770c 100644 --- a/internal/processing/timeline/home.go +++ b/internal/processing/timeline/home.go @@ -91,9 +91,22 @@ func (p *Processor) HomeTimelineGet( // Check the visibility of passed status to requesting user. ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s) if err != nil { - log.Errorf(ctx, "error filtering status %s: %v", s.URI, err) + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true } - return !ok + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true + } + + return false }, // Post filtering funtion, diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go index dbf07cdd4..c8e6bc5f1 100644 --- a/internal/processing/timeline/list.go +++ b/internal/processing/timeline/list.go @@ -102,9 +102,22 @@ func (p *Processor) ListTimelineGet( // Check the visibility of passed status to requesting user. ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s) if err != nil { - log.Errorf(ctx, "error filtering status %s: %v", s.URI, err) + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true } - return !ok + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true + } + + return false }, // Post filtering funtion, diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go index 0da9fb55e..ad60fd90c 100644 --- a/internal/processing/timeline/notification.go +++ b/internal/processing/timeline/notification.go @@ -21,14 +21,13 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/filter/status" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" - "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -39,14 +38,13 @@ import ( // NotificationsGet ... func (p *Processor) NotificationsGet( ctx context.Context, - authed *apiutil.Auth, + requester *gtsmodel.Account, page *paging.Page, types []gtsmodel.NotificationType, excludeTypes []gtsmodel.NotificationType, ) (*apimodel.PageableResponse, gtserror.WithCode) { - notifs, err := p.state.DB.GetAccountNotifications( - ctx, - authed.Account.ID, + notifs, err := p.state.DB.GetAccountNotifications(ctx, + requester.ID, page, types, excludeTypes, @@ -61,19 +59,12 @@ func (p *Processor) NotificationsGet( return util.EmptyPageableResponse(), nil } - filters, err := p.state.DB.GetFiltersForAccountID(ctx, authed.Account.ID) + filters, err := p.state.DB.GetFiltersForAccountID(ctx, requester.ID) if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", authed.Account.ID, err) + err = gtserror.Newf("error getting account %s filters: %w", requester.ID, err) return nil, gtserror.NewErrorInternalError(err) } - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), authed.Account.ID, nil) - if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", authed.Account.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - var ( items = make([]interface{}, 0, count) @@ -84,7 +75,7 @@ func (p *Processor) NotificationsGet( ) for _, n := range notifs { - visible, err := p.notifVisible(ctx, n, authed.Account) + visible, err := p.notifVisible(ctx, n, requester) if err != nil { log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %v", n.ID, err) continue @@ -94,7 +85,37 @@ func (p *Processor) NotificationsGet( continue } - item, err := p.converter.NotificationToAPINotification(ctx, n, filters, compiledMutes) + // Check whether notification origin account is muted. + muted, err := p.muteFilter.AccountNotificationsMuted(ctx, + requester, + n.OriginAccount, + ) + if err != nil { + log.Errorf(ctx, "error checking account mute: %v", err) + continue + } + + if muted { + continue + } + + if n.Status != nil { + // A status is attached, check whether status muted. + muted, err = p.muteFilter.StatusNotificationsMuted(ctx, + requester, + n.Status, + ) + if err != nil { + log.Errorf(ctx, "error checking status mute: %v", err) + continue + } + + if muted { + continue + } + } + + item, err := p.converter.NotificationToAPINotification(ctx, n, filters) if err != nil { if !errors.Is(err, status.ErrHideStatus) { log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err) @@ -125,41 +146,24 @@ func (p *Processor) NotificationsGet( func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Account, targetNotifID string) (*apimodel.Notification, gtserror.WithCode) { notif, err := p.state.DB.GetNotificationByID(ctx, targetNotifID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - - // Real error. + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting from db: %w", err) return nil, gtserror.NewErrorInternalError(err) } - if notifTargetAccountID := notif.TargetAccountID; notifTargetAccountID != account.ID { - err = fmt.Errorf("account %s does not have permission to view notification belong to account %s", account.ID, notifTargetAccountID) + if notif.TargetAccountID != account.ID { + err := gtserror.New("requester does not match notification target") return nil, gtserror.NewErrorNotFound(err) } - filters, err := p.state.DB.GetFiltersForAccountID(ctx, account.ID) - if err != nil { - err = gtserror.Newf("couldn't retrieve filters for account %s: %w", account.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } + // NOTE: we specifically don't do any filtering + // or mute checking for a notification directly + // fetched by ID. only from timelines etc. - mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), account.ID, nil) + apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, nil) if err != nil { - err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", account.ID, err) - return nil, gtserror.NewErrorInternalError(err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - return nil, gtserror.NewErrorNotFound(err) - } - - // Real error. - return nil, gtserror.NewErrorInternalError(err) + err := gtserror.Newf("error converting to api model: %w", err) + return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err) } return apiNotif, nil diff --git a/internal/processing/timeline/public.go b/internal/processing/timeline/public.go index 527000166..cfb58201d 100644 --- a/internal/processing/timeline/public.go +++ b/internal/processing/timeline/public.go @@ -93,9 +93,22 @@ func (p *Processor) publicTimelineGet( // Check the visibility of passed status to requesting user. ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s) if err != nil { - log.Errorf(ctx, "error filtering status %s: %v", s.URI, err) + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true } - return !ok + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true + } + + return false }, // Post filtering funtion, @@ -149,9 +162,20 @@ func (p *Processor) localTimelineGet( // Check the visibility of passed status to requesting user. ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s) if err != nil { - log.Errorf(ctx, "error filtering status %s: %v", s.URI, err) + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + } else if !ok { + return true } - return !ok + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + } else if muted { + return true + } + + return false }, // Post filtering funtion, diff --git a/internal/processing/timeline/tag.go b/internal/processing/timeline/tag.go index f48f89049..88333d343 100644 --- a/internal/processing/timeline/tag.go +++ b/internal/processing/timeline/tag.go @@ -101,9 +101,22 @@ func (p *Processor) TagTimelineGet( // Check the visibility of passed status to requesting user. ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s) if err != nil { - log.Errorf(ctx, "error filtering status %s: %v", s.URI, err) + log.Errorf(ctx, "error checking status %s visibility: %v", s.URI, err) + return true // default assume not visible + } else if !ok { + return true } - return !ok + + // Check if status been muted by requester from timelines. + muted, err := p.muteFilter.StatusMuted(ctx, requester, s) + if err != nil { + log.Errorf(ctx, "error checking status %s mutes: %v", s.URI, err) + return true // default assume muted + } else if muted { + return true + } + + return false }, // Post filtering funtion, diff --git a/internal/processing/timeline/timeline.go b/internal/processing/timeline/timeline.go index 2eab57195..a86702d42 100644 --- a/internal/processing/timeline/timeline.go +++ b/internal/processing/timeline/timeline.go @@ -26,8 +26,8 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" timelinepkg "code.superseriousbusiness.org/gotosocial/internal/cache/timeline" "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -48,16 +48,18 @@ var ( ) type Processor struct { - state *state.State - converter *typeutils.Converter - visFilter *visibility.Filter + state *state.State + converter *typeutils.Converter + visFilter *visibility.Filter + muteFilter *mutes.Filter } -func New(state *state.State, converter *typeutils.Converter, visFilter *visibility.Filter) Processor { +func New(state *state.State, converter *typeutils.Converter, visFilter *visibility.Filter, muteFilter *mutes.Filter) Processor { return Processor{ - state: state, - converter: converter, - visFilter: visFilter, + state: state, + converter: converter, + visFilter: visFilter, + muteFilter: muteFilter, } } @@ -78,7 +80,6 @@ func (p *Processor) getStatusTimeline( ) { var err error var filters []*gtsmodel.Filter - var mutes *usermute.CompiledUserMuteList if requester != nil { // Fetch all filters relevant for requesting account. @@ -89,19 +90,6 @@ func (p *Processor) getStatusTimeline( err := gtserror.Newf("error getting account filters: %w", err) return nil, gtserror.NewErrorInternalError(err) } - - // Get a list of all account mutes for requester. - allMutes, err := p.state.DB.GetAccountMutes(ctx, - requester.ID, - nil, // i.e. all - ) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("error getting account mutes: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Compile all account mutes to useable form. - mutes = usermute.NewCompiledUserMuteList(allMutes) } // Ensure we have valid @@ -148,7 +136,6 @@ func (p *Processor) getStatusTimeline( requester, filterCtx, filters, - mutes, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { return nil, err diff --git a/internal/processing/timeline/timeline_test.go b/internal/processing/timeline/timeline_test.go index 5afbb2353..01197b767 100644 --- a/internal/processing/timeline/timeline_test.go +++ b/internal/processing/timeline/timeline_test.go @@ -20,6 +20,7 @@ package timeline_test import ( "code.superseriousbusiness.org/gotosocial/internal/admin" "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/processing/timeline" @@ -62,6 +63,7 @@ func (suite *TimelineStandardTestSuite) SetupTest() { &suite.state, typeutils.NewConverter(&suite.state), visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), ) testrig.StandardDBSetup(suite.db, suite.testAccounts) diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index 5d9ebf41a..04ad4152c 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -748,10 +748,17 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA } } - // Notify of the latest edit. - if editsLen := len(status.EditIDs); editsLen != 0 { - editID := status.EditIDs[editsLen-1] - if err := p.surface.notifyStatusEdit(ctx, status, editID); err != nil { + if len(status.EditIDs) > 0 { + // Ensure edits are fully populated for this status before anything. + if err := p.surface.State.DB.PopulateStatusEdits(ctx, status); err != nil { + log.Error(ctx, "error populating updated status edits: %v") + + // Then send notifications of a status edit + // to any local interactors of the status. + } else if err := p.surface.notifyStatusEdit(ctx, + status, + status.Edits[len(status.Edits)-1], // latest + ); err != nil { log.Errorf(ctx, "error notifying status edit: %v", err) } } diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index 3abd05295..3f6964259 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -215,7 +215,6 @@ func (suite *FromClientAPITestSuite) statusJSON( requestingAccount, statusfilter.FilterContextNone, nil, - nil, ) if err != nil { suite.FailNow(err.Error()) @@ -240,7 +239,6 @@ func (suite *FromClientAPITestSuite) conversationJSON( conversation, requestingAccount, nil, - nil, ) if err != nil { suite.FailNow(err.Error()) @@ -348,7 +346,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { suite.FailNow("timed out waiting for new status notification") } - apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil) + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil) if err != nil { suite.FailNow(err.Error()) } @@ -2035,7 +2033,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiv suite.FailNow("timed out waiting for new status notification") } - apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil) + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil) if err != nil { suite.FailNow(err.Error()) } @@ -2220,7 +2218,7 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusInteractedWith() { suite.FailNow("timed out waiting for edited status notification") } - apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil) + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index d1e5bb2f7..5dbb8ba2e 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -1010,10 +1010,17 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) } } - // Notify of the latest edit. - if editsLen := len(status.EditIDs); editsLen != 0 { - editID := status.EditIDs[editsLen-1] - if err := p.surface.notifyStatusEdit(ctx, status, editID); err != nil { + if len(status.EditIDs) > 0 { + // Ensure edits are fully populated for this status before anything. + if err := p.surface.State.DB.PopulateStatusEdits(ctx, status); err != nil { + log.Error(ctx, "error populating updated status edits: %v") + + // Then send notifications of a status edit + // to any local interactors of the status. + } else if err := p.surface.notifyStatusEdit(ctx, + status, + status.Edits[len(status.Edits)-1], // latest + ); err != nil { log.Errorf(ctx, "error notifying status edit: %v", err) } } diff --git a/internal/processing/workers/surface.go b/internal/processing/workers/surface.go index 5604ad71d..69758692f 100644 --- a/internal/processing/workers/surface.go +++ b/internal/processing/workers/surface.go @@ -19,6 +19,7 @@ package workers import ( "code.superseriousbusiness.org/gotosocial/internal/email" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/processing/conversations" "code.superseriousbusiness.org/gotosocial/internal/processing/stream" @@ -38,6 +39,7 @@ type Surface struct { Converter *typeutils.Converter Stream *stream.Processor VisFilter *visibility.Filter + MuteFilter *mutes.Filter EmailSender email.Sender WebPushSender webpush.Sender Conversations *conversations.Processor diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index 11c3fd059..044315349 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -23,8 +23,7 @@ import ( "strings" "code.superseriousbusiness.org/gotosocial/internal/db" - "code.superseriousbusiness.org/gotosocial/internal/filter/status" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" + 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" @@ -59,8 +58,7 @@ func (s *Surface) notifyPendingReply( // Ensure thread not muted // by replied-to account. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, status.ThreadID, status.InReplyToAccountID, ) @@ -81,7 +79,8 @@ func (s *Surface) notifyPendingReply( gtsmodel.NotificationPendingReply, status.InReplyToAccount, status.Account, - status.ID, + status, + nil, ); err != nil { return gtserror.Newf("error notifying replied-to account %s: %w", status.InReplyToAccountID, err) } @@ -135,8 +134,7 @@ func (s *Surface) notifyMention( // Ensure thread not muted // by mentioned account. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, mention.Status.ThreadID, mention.TargetAccountID, ) @@ -160,7 +158,8 @@ func (s *Surface) notifyMention( gtsmodel.NotificationMention, mention.TargetAccount, mention.OriginAccount, - mention.StatusID, + mention.Status, + nil, ); err != nil { return gtserror.Newf( "error notifying mention target %s: %w", @@ -193,7 +192,8 @@ func (s *Surface) notifyFollowRequest( gtsmodel.NotificationFollowRequest, followReq.TargetAccount, followReq.Account, - "", + nil, + nil, ); err != nil { return gtserror.Newf("error notifying follow target %s: %w", followReq.TargetAccountID, err) } @@ -245,7 +245,8 @@ func (s *Surface) notifyFollow( gtsmodel.NotificationFollow, follow.TargetAccount, follow.Account, - "", + nil, + nil, ); err != nil { return gtserror.Newf("error notifying follow target %s: %w", follow.TargetAccountID, err) } @@ -275,7 +276,8 @@ func (s *Surface) notifyFave( gtsmodel.NotificationFavourite, fave.TargetAccount, fave.Account, - fave.StatusID, + fave.Status, + nil, ); err != nil { return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) } @@ -306,7 +308,8 @@ func (s *Surface) notifyPendingFave( gtsmodel.NotificationPendingFave, fave.TargetAccount, fave.Account, - fave.StatusID, + fave.Status, + nil, ); err != nil { return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) } @@ -339,8 +342,7 @@ func (s *Surface) notifyableFave( // Ensure favee hasn't // muted the thread. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, fave.Status.ThreadID, fave.TargetAccountID, ) @@ -379,7 +381,8 @@ func (s *Surface) notifyAnnounce( gtsmodel.NotificationReblog, boost.BoostOfAccount, boost.Account, - boost.ID, + boost, + nil, ); err != nil { return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) } @@ -410,7 +413,8 @@ func (s *Surface) notifyPendingAnnounce( gtsmodel.NotificationPendingReblog, boost.BoostOfAccount, boost.Account, - boost.ID, + boost, + nil, ); err != nil { return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) } @@ -448,8 +452,7 @@ func (s *Surface) notifyableAnnounce( // Ensure boostee hasn't // muted the thread. - muted, err := s.State.DB.IsThreadMutedByAccount( - ctx, + muted, err := s.State.DB.IsThreadMutedByAccount(ctx, status.BoostOf.ThreadID, status.BoostOfAccountID, ) @@ -488,7 +491,8 @@ func (s *Surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status) gtsmodel.NotificationPoll, status.Account, status.Account, - status.ID, + status, + nil, ); err != nil { errs.Appendf("error notifying poll author: %w", err) } @@ -507,7 +511,8 @@ func (s *Surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status) gtsmodel.NotificationPoll, vote.Account, status.Account, - status.ID, + status, + nil, ); err != nil { errs.Appendf("error notifying poll voter %s: %w", vote.AccountID, err) continue @@ -546,7 +551,8 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro gtsmodel.NotificationAdminSignup, mod, newUser.Account, - "", + nil, + nil, ); err != nil { errs.Appendf("error notifying moderator %s: %w", mod.ID, err) continue @@ -559,7 +565,7 @@ func (s *Surface) notifySignup(ctx context.Context, newUser *gtsmodel.User) erro func (s *Surface) notifyStatusEdit( ctx context.Context, status *gtsmodel.Status, - editID string, + edit *gtsmodel.StatusEdit, ) error { // Get local-only interactions (we can't/don't notify remotes). interactions, err := s.State.DB.GetStatusInteractions(ctx, status.ID, true) @@ -594,7 +600,8 @@ func (s *Surface) notifyStatusEdit( gtsmodel.NotificationUpdate, targetAcct, status.Account, - editID, + status, + edit, ); err != nil { errs.Appendf("error notifying status edit: %w", err) continue @@ -637,22 +644,32 @@ func (s *Surface) Notify( notificationType gtsmodel.NotificationType, targetAccount *gtsmodel.Account, originAccount *gtsmodel.Account, - statusOrEditID string, + status *gtsmodel.Status, + edit *gtsmodel.StatusEdit, ) error { if targetAccount.IsRemote() { // nothing to do. return nil } + // Get status / edit ID + // if either was provided. + // (prefer edit though!) + var statusOrEditID string + if edit != nil { + statusOrEditID = edit.ID + } else if status != nil { + statusOrEditID = status.ID + } + // We're doing state-y stuff so get a // lock on this combo of notif params. - lockURI := getNotifyLockURI( + unlock := s.State.ProcessingLocks.Lock(getNotifyLockURI( notificationType, targetAccount, originAccount, statusOrEditID, - ) - unlock := s.State.ProcessingLocks.Lock(lockURI) + )) // Wrap the unlock so we // can do granular unlocking. @@ -696,29 +713,57 @@ func (s *Surface) Notify( // with the state-y stuff. unlock() - // Stream notification to the user. + // Check whether origin account is muted by target account. + muted, err := s.MuteFilter.AccountNotificationsMuted(ctx, + targetAccount, + originAccount, + ) + if err != nil { + return gtserror.Newf("error checking account mute: %w", err) + } + + if muted { + // Don't notify. + return nil + } + + if status != nil { + // Check whether status is muted by the target account. + muted, err := s.MuteFilter.StatusNotificationsMuted(ctx, + targetAccount, + status, + ) + if err != nil { + return gtserror.Newf("error checking status mute: %w", err) + } + + if muted { + // Don't notify. + return nil + } + } + filters, err := s.State.DB.GetFiltersForAccountID(ctx, targetAccount.ID) if err != nil { return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err) } - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), targetAccount.ID, nil) - if err != nil { - return gtserror.Newf("couldn't retrieve mutes for account %s: %w", targetAccount.ID, err) - } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - - apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes) - if err != nil { - if errors.Is(err, status.ErrHideStatus) { - return nil - } + // Convert the notification to frontend API model for streaming / push. + apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters) + if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { return gtserror.Newf("error converting notification to api representation: %w", err) } + + if apiNotif == nil { + // Filtered. + return nil + } + + // Stream notification to the user. s.Stream.Notify(ctx, targetAccount, apiNotif) // Send Web Push notification to the user. - if err = s.WebPushSender.Send(ctx, notif, filters, compiledMutes); err != nil { + if err = s.WebPushSender.Send(ctx, notif, apiNotif); err != nil { return gtserror.Newf("error sending Web Push notifications: %w", err) } diff --git a/internal/processing/workers/surfacenotify_test.go b/internal/processing/workers/surfacenotify_test.go index 459b3e125..a5124a3af 100644 --- a/internal/processing/workers/surfacenotify_test.go +++ b/internal/processing/workers/surfacenotify_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -43,6 +44,7 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() { Converter: testStructs.TypeConverter, Stream: testStructs.Processor.Stream(), VisFilter: visibility.NewFilter(testStructs.State), + MuteFilter: mutes.NewFilter(testStructs.State), EmailSender: testStructs.EmailSender, WebPushSender: testStructs.WebPushSender, Conversations: testStructs.Processor.Conversations(), @@ -74,7 +76,8 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() { notificationType, targetAccount, originAccount, - "", + nil, + nil, ); err != nil { suite.FailNow(err.Error()) } diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index 018ef976e..7ef5fee87 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -23,7 +23,6 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/cache/timeline" statusfilter "code.superseriousbusiness.org/gotosocial/internal/filter/status" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" "code.superseriousbusiness.org/gotosocial/internal/gtscontext" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" @@ -119,11 +118,12 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // if something is hometimelineable according to this filter, // it's also eligible to appear in exclusive lists, // even if it ultimately doesn't appear on the home timeline. - timelineable, err := s.VisFilter.StatusHomeTimelineable( - ctx, follow.Account, status, + timelineable, err := s.VisFilter.StatusHomeTimelineable(ctx, + follow.Account, + status, ) if err != nil { - log.Errorf(ctx, "error checking status home visibility for follow: %v", err) + log.Errorf(ctx, "error checking status home visibility: %v", err) continue } @@ -132,9 +132,24 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( continue } - // Get relevant filters and mutes for this follow's account. + // Check if the status is muted by this follower. + muted, err := s.MuteFilter.StatusMuted(ctx, + follow.Account, + status, + ) + if err != nil { + log.Errorf(ctx, "error checking status mute: %v", err) + continue + } + + if muted { + // Nothing to do. + continue + } + + // Get relevant filters for this follow's account. // (note the origin account of the follow is receiver of status). - filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) + filters, err := s.getFilters(ctx, follow.AccountID) if err != nil { log.Error(ctx, err) continue @@ -145,7 +160,6 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( status, follow, filters, - mutes, ) if err != nil { log.Errorf(ctx, "error list timelining status: %v", err) @@ -168,7 +182,6 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( stream.TimelineHome, statusfilter.FilterContextHome, filters, - mutes, ); homeTimelined { // If hometimelined, add to list of returned account IDs. @@ -205,7 +218,8 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( gtsmodel.NotificationStatus, follow.Account, status.Account, - status.ID, + status, + nil, ); err != nil { log.Errorf(ctx, "error notifying status for account: %v", err) continue @@ -226,7 +240,6 @@ func (s *Surface) listTimelineStatusForFollow( status *gtsmodel.Status, follow *gtsmodel.Follow, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (timelined bool, exclusive bool, err error) { // Get all lists that contain this given follow. @@ -264,7 +277,6 @@ func (s *Surface) listTimelineStatusForFollow( stream.TimelineList+":"+list.ID, // key streamType to this specific list statusfilter.FilterContextHome, filters, - mutes, ) // Update flag based on if timelined. @@ -275,19 +287,12 @@ func (s *Surface) listTimelineStatusForFollow( } // getFiltersAndMutes returns an account's filters and mutes. -func (s *Surface) getFiltersAndMutes(ctx context.Context, accountID string) ([]*gtsmodel.Filter, *usermute.CompiledUserMuteList, error) { +func (s *Surface) getFilters(ctx context.Context, accountID string) ([]*gtsmodel.Filter, error) { filters, err := s.State.DB.GetFiltersForAccountID(ctx, accountID) if err != nil { - return nil, nil, gtserror.Newf("couldn't retrieve filters for account %s: %w", accountID, err) + return nil, gtserror.Newf("couldn't retrieve filters for account %s: %w", accountID, err) } - - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), accountID, nil) - if err != nil { - return nil, nil, gtserror.Newf("couldn't retrieve mutes for account %s: %w", accountID, err) - } - - compiledMutes := usermute.NewCompiledUserMuteList(mutes) - return filters, compiledMutes, err + return filters, err } // listEligible checks if the given status is eligible @@ -366,7 +371,6 @@ func (s *Surface) timelineStatus( streamType string, filterCtx statusfilter.FilterContext, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) bool { // Attempt to convert status to frontend API representation, @@ -376,7 +380,6 @@ func (s *Surface) timelineStatus( account, filterCtx, filters, - mutes, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { log.Error(ctx, "error converting status %s to frontend: %v", status.URI, err) @@ -388,7 +391,7 @@ func (s *Surface) timelineStatus( if apiModel == nil { // Status was - // filtered / muted. + // filtered. return false } @@ -422,7 +425,7 @@ func (s *Surface) timelineAndNotifyStatusForTagFollowers( // Insert the status into the home timeline of each tag follower. errs := gtserror.MultiError{} for _, tagFollowerAccount := range tagFollowerAccounts { - filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) + filters, err := s.getFilters(ctx, tagFollowerAccount.ID) if err != nil { errs.Append(err) continue @@ -435,7 +438,6 @@ func (s *Surface) timelineAndNotifyStatusForTagFollowers( stream.TimelineHome, statusfilter.FilterContextHome, filters, - mutes, ) } @@ -605,7 +607,7 @@ func (s *Surface) timelineStatusUpdateForFollowers( // Get relevant filters and mutes for this follow's account. // (note the origin account of the follow is receiver of status). - filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) + filters, err := s.getFilters(ctx, follow.AccountID) if err != nil { log.Error(ctx, err) continue @@ -616,7 +618,6 @@ func (s *Surface) timelineStatusUpdateForFollowers( status, follow, filters, - mutes, ) if err != nil { log.Errorf(ctx, "error list timelining status: %v", err) @@ -637,7 +638,6 @@ func (s *Surface) timelineStatusUpdateForFollowers( status, stream.TimelineHome, filters, - mutes, ) if err != nil { log.Errorf(ctx, "error home timelining status: %v", err) @@ -663,7 +663,6 @@ func (s *Surface) listTimelineStatusUpdateForFollow( status *gtsmodel.Status, follow *gtsmodel.Follow, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (bool, bool, error) { // Get all lists that contain this given follow. @@ -703,7 +702,6 @@ func (s *Surface) listTimelineStatusUpdateForFollow( status, stream.TimelineList+":"+list.ID, // key streamType to this specific list filters, - mutes, ) if err != nil { log.Errorf(ctx, "error adding status to list timeline: %v", err) @@ -727,7 +725,6 @@ func (s *Surface) timelineStreamStatusUpdate( status *gtsmodel.Status, streamType string, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (bool, error) { // Convert updated database model to frontend model. @@ -736,7 +733,6 @@ func (s *Surface) timelineStreamStatusUpdate( account, statusfilter.FilterContextHome, filters, - mutes, ) switch { @@ -778,7 +774,7 @@ func (s *Surface) timelineStatusUpdateForTagFollowers( // Stream the update to the home timeline of each tag follower. errs := gtserror.MultiError{} for _, tagFollowerAccount := range tagFollowerAccounts { - filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) + filters, err := s.getFilters(ctx, tagFollowerAccount.ID) if err != nil { errs.Append(err) continue @@ -790,7 +786,6 @@ func (s *Surface) timelineStatusUpdateForTagFollowers( status, stream.TimelineHome, filters, - mutes, ); err != nil { errs.Appendf( "error updating status %s on home timeline for account %s: %w", diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go index c5b5f6ce2..1f4ef465f 100644 --- a/internal/processing/workers/workers.go +++ b/internal/processing/workers/workers.go @@ -20,6 +20,7 @@ package workers import ( "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/processing/account" "code.superseriousbusiness.org/gotosocial/internal/processing/common" @@ -44,6 +45,7 @@ func New( federator *federation.Federator, converter *typeutils.Converter, visFilter *visibility.Filter, + muteFilter *mutes.Filter, emailSender email.Sender, webPushSender webpush.Sender, account *account.Processor, @@ -66,6 +68,7 @@ func New( Converter: converter, Stream: stream, VisFilter: visFilter, + MuteFilter: muteFilter, EmailSender: emailSender, WebPushSender: webPushSender, Conversations: conversations, diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index bef44e32f..ecf817c66 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -32,7 +32,6 @@ import ( "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/filter/usermute" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/id" @@ -851,7 +850,6 @@ func (c *Converter) StatusToAPIStatus( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (*apimodel.Status, error) { return c.statusToAPIStatus( ctx, @@ -859,7 +857,6 @@ func (c *Converter) StatusToAPIStatus( requestingAccount, filterContext, filters, - mutes, true, true, ) @@ -875,7 +872,6 @@ func (c *Converter) statusToAPIStatus( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, placeholdAttachments bool, addPendingNote bool, ) (*apimodel.Status, error) { @@ -885,7 +881,6 @@ func (c *Converter) statusToAPIStatus( requestingAccount, // Can be nil. filterContext, // Can be empty. filters, - mutes, ) if err != nil { return nil, err @@ -952,77 +947,18 @@ func (c *Converter) statusToAPIFilterResults( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) ([]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 && mutes.Len() == 0) || s.AccountID == requestingAccount.ID { + if filterContext == "" || (len(filters) == 0) || s.AccountID == requestingAccount.ID { return nil, nil } - // Both mutes and filters can expire. + // Both mutes and + // filters can expire. now := time.Now() - // If requesting account mutes the author (taking boosts into account), hide the status. - if (s.BoostOfAccountID != "" && mutes.Matches(s.BoostOfAccountID, filterContext, now)) || - mutes.Matches(s.AccountID, filterContext, now) { - return nil, statusfilter.ErrHideStatus - } - - // If this status is part of a multi-account discussion, - // and all of the accounts replied to or mentioned are invisible to the requesting account - // (due to blocks, domain blocks, moderation, etc.), - // or are muted, hide the status. - // First, collect the accounts we have to check. - otherAccounts := make([]*gtsmodel.Account, 0, 1+len(s.Mentions)) - if s.InReplyToAccount != nil { - otherAccounts = append(otherAccounts, s.InReplyToAccount) - } - for _, mention := range s.Mentions { - otherAccounts = append(otherAccounts, mention.TargetAccount) - } - - // If there are no other accounts, skip this check. - if len(otherAccounts) > 0 { - // Start by assuming that they're all invisible or muted. - allOtherAccountsInvisibleOrMuted := true - - for _, account := range otherAccounts { - // Is this account visible? - visible, err := c.visFilter.AccountVisible(ctx, requestingAccount, account) - if err != nil { - return nil, err - } - if !visible { - // It's invisible. Check the next account. - continue - } - - // If visible, is it muted? - if mutes.Matches(account.ID, filterContext, now) { - // It's muted. Check the next account. - continue - } - - // If we get here, the account is visible and not muted. - // We should show this status, and don't have to check any more accounts. - allOtherAccountsInvisibleOrMuted = false - break - } - - // If we didn't find any visible non-muted accounts, hide the status. - if allOtherAccountsInvisibleOrMuted { - return nil, statusfilter.ErrHideStatus - } - } - - // At this point, the status isn't muted, but might still be filtered. - if len(filters) == 0 { - // If it can't be filtered because there are no filters, we're done. - return nil, nil - } - // Key this status based on ID + last updated time, // to ensure we always filter on latest version. statusKey := s.ID + strconv.FormatInt(s.UpdatedAt().Unix(), 10) @@ -1130,7 +1066,6 @@ func (c *Converter) StatusToWebStatus( nil, // No authed requester. statusfilter.FilterContextNone, // No filters. nil, // No filters. - nil, // No mutes. ) if err != nil { return nil, err @@ -1301,7 +1236,6 @@ func (c *Converter) statusToFrontend( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) ( *apimodel.Status, error, @@ -1311,7 +1245,6 @@ func (c *Converter) statusToFrontend( requestingAccount, filterContext, filters, - mutes, ) if err != nil { return nil, err @@ -1323,7 +1256,6 @@ func (c *Converter) statusToFrontend( requestingAccount, filterContext, filters, - mutes, ) if errors.Is(err, statusfilter.ErrHideStatus) { // If we'd hide the original status, hide the boost. @@ -1357,7 +1289,6 @@ func (c *Converter) baseStatusToFrontend( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) ( *apimodel.Status, error, @@ -1535,7 +1466,7 @@ func (c *Converter) baseStatusToFrontend( } // Apply filters. - filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters, mutes) + filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters) if err != nil { if errors.Is(err, statusfilter.ErrHideStatus) { return nil, err @@ -2052,7 +1983,6 @@ func (c *Converter) NotificationToAPINotification( ctx context.Context, n *gtsmodel.Notification, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (*apimodel.Notification, error) { // Ensure notif populated. if err := c.state.DB.PopulateNotification(ctx, n); err != nil { @@ -2072,7 +2002,7 @@ func (c *Converter) NotificationToAPINotification( ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, - filters, mutes, + filters, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { return nil, gtserror.Newf("error converting status to api: %w", err) @@ -2108,7 +2038,6 @@ func (c *Converter) ConversationToAPIConversation( conversation *gtsmodel.Conversation, requester *gtsmodel.Account, filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, ) (*apimodel.Conversation, error) { apiConversation := &apimodel.Conversation{ ID: conversation.ID, @@ -2125,7 +2054,6 @@ func (c *Converter) ConversationToAPIConversation( requester, statusfilter.FilterContextNotifications, filters, - mutes, ) if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) { return nil, gtserror.Newf( @@ -2394,7 +2322,6 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo requestingAccount, statusfilter.FilterContextNone, nil, // No filters. - nil, // No mutes. true, // Placehold unknown attachments. // Don't add note about @@ -3057,7 +2984,6 @@ func (c *Converter) InteractionReqToAPIInteractionReq( requestingAcct, statusfilter.FilterContextNone, nil, // No filters. - nil, // No mutes. ) if err != nil { err := gtserror.Newf("error converting interacted status: %w", err) @@ -3072,7 +2998,6 @@ func (c *Converter) InteractionReqToAPIInteractionReq( requestingAcct, statusfilter.FilterContextNone, nil, // No filters. - nil, // No mutes. true, // Placehold unknown attachments. // Don't add note about pending; diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index c1b29ad21..d19bd6a2f 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -28,7 +28,6 @@ import ( "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/filter/usermute" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/util" "code.superseriousbusiness.org/gotosocial/testrig" @@ -466,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, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -629,7 +628,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendHTMLContentWarning testStatus.ContentWarning = `

First paragraph of content warning

Here's the title!

Big boobs
Tee hee!

Some more text
And a bunch more

Hasta la victoria siempre!

` requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -795,7 +794,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendApplicationDeleted } requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(ctx, testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(ctx, testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -984,7 +983,6 @@ func (suite *InternalToFrontendTestSuite) filteredStatusToFrontend(action gtsmod requestingAccount, statusfilter.FilterContextHome, requestingAccountFilters, - nil, ) } @@ -1538,7 +1536,6 @@ func (suite *InternalToFrontendTestSuite) testHashtagFilteredStatusToFrontend(wh requestingAccount, statusfilter.FilterContextHome, []*gtsmodel.Filter{filter}, - nil, ) if err != nil { suite.FailNow(err.Error()) @@ -1563,102 +1560,11 @@ func (suite *InternalToFrontendTestSuite) TestHashtagAnywhereFilteredBoostToFron suite.testHashtagFilteredStatusToFrontend(false, true) } -// Test that a status from a user muted by the requesting user results in the ErrHideStatus error. -func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() { - testStatus := suite.testStatuses["admin_account_status_1"] - requestingAccount := suite.testAccounts["local_account_1"] - - mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{ - { - AccountID: requestingAccount.ID, - TargetAccountID: testStatus.AccountID, - Notifications: util.Ptr(false), - }, - }) - - _, err := suite.typeconverter.StatusToAPIStatus( - suite.T().Context(), - testStatus, - requestingAccount, - statusfilter.FilterContextHome, - nil, - mutes, - ) - suite.ErrorIs(err, statusfilter.ErrHideStatus) -} - -// Test that a status replying to a user muted by the requesting user results in the ErrHideStatus error. -func (suite *InternalToFrontendTestSuite) TestMutedReplyStatusToFrontend() { - mutedAccount := suite.testAccounts["local_account_2"] - testStatus := suite.testStatuses["admin_account_status_1"] - testStatus.InReplyToID = suite.testStatuses["local_account_2_status_1"].ID - testStatus.InReplyToAccountID = mutedAccount.ID - requestingAccount := suite.testAccounts["local_account_1"] - - mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{ - { - AccountID: requestingAccount.ID, - TargetAccountID: mutedAccount.ID, - Notifications: util.Ptr(false), - }, - }) - - // Populate status so the converter has the account objects it needs for muting. - err := suite.db.PopulateStatus(suite.T().Context(), testStatus) - if err != nil { - suite.FailNow(err.Error()) - } - - // Convert the status to API format, which should fail. - _, err = suite.typeconverter.StatusToAPIStatus( - suite.T().Context(), - testStatus, - requestingAccount, - statusfilter.FilterContextHome, - nil, - mutes, - ) - suite.ErrorIs(err, statusfilter.ErrHideStatus) -} - -func (suite *InternalToFrontendTestSuite) TestMutedBoostStatusToFrontend() { - mutedAccount := suite.testAccounts["local_account_2"] - testStatus := suite.testStatuses["admin_account_status_1"] - testStatus.BoostOfID = suite.testStatuses["local_account_2_status_1"].ID - testStatus.BoostOfAccountID = mutedAccount.ID - requestingAccount := suite.testAccounts["local_account_1"] - - mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{ - { - AccountID: requestingAccount.ID, - TargetAccountID: mutedAccount.ID, - Notifications: util.Ptr(false), - }, - }) - - // Populate status so the converter has the account objects it needs for muting. - err := suite.db.PopulateStatus(suite.T().Context(), testStatus) - if err != nil { - suite.FailNow(err.Error()) - } - - // Convert the status to API format, which should fail. - _, err = suite.typeconverter.StatusToAPIStatus( - suite.T().Context(), - testStatus, - requestingAccount, - statusfilter.FilterContextHome, - nil, - mutes, - ) - suite.ErrorIs(err, statusfilter.ErrHideStatus) -} - 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, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -1985,7 +1891,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, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -2146,7 +2052,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, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(suite.T().Context(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -2261,7 +2167,6 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() requestingAccount, statusfilter.FilterContextNone, nil, - nil, ) if err != nil { suite.FailNow(err.Error()) @@ -4020,11 +3925,10 @@ func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() { func (suite *InternalToFrontendTestSuite) TestConversationToAPISelfConvo() { var ( - ctx = suite.T().Context() - requester = suite.testAccounts["local_account_1"] - lastStatus = suite.testStatuses["local_account_1_status_1"] - filters []*gtsmodel.Filter = nil - mutes *usermute.CompiledUserMuteList = nil + ctx = suite.T().Context() + requester = suite.testAccounts["local_account_1"] + lastStatus = suite.testStatuses["local_account_1_status_1"] + filters []*gtsmodel.Filter = nil ) convo := >smodel.Conversation{ @@ -4043,7 +3947,6 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPISelfConvo() { convo, requester, filters, - mutes, ) if err != nil { suite.FailNow(err.Error()) @@ -4195,11 +4098,10 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPISelfConvo() { func (suite *InternalToFrontendTestSuite) TestConversationToAPI() { var ( - ctx = suite.T().Context() - requester = suite.testAccounts["local_account_1"] - lastStatus = suite.testStatuses["local_account_1_status_1"] - filters []*gtsmodel.Filter = nil - mutes *usermute.CompiledUserMuteList = nil + ctx = suite.T().Context() + requester = suite.testAccounts["local_account_1"] + lastStatus = suite.testStatuses["local_account_1_status_1"] + filters []*gtsmodel.Filter = nil ) convo := >smodel.Conversation{ @@ -4220,7 +4122,6 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() { convo, requester, filters, - mutes, ) if err != nil { suite.FailNow(err.Error()) diff --git a/internal/webpush/realsender.go b/internal/webpush/realsender.go index 075927095..99472c815 100644 --- a/internal/webpush/realsender.go +++ b/internal/webpush/realsender.go @@ -29,7 +29,6 @@ import ( apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/config" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/log" @@ -49,25 +48,23 @@ type realSender struct { func (r *realSender) Send( ctx context.Context, - notification *gtsmodel.Notification, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, + notif *gtsmodel.Notification, + apiNotif *apimodel.Notification, ) error { + // Get notification target. + target := notif.TargetAccount + // Load subscriptions. - subscriptions, err := r.state.DB.GetWebPushSubscriptionsByAccountID(ctx, notification.TargetAccountID) + subscriptions, err := r.state.DB.GetWebPushSubscriptionsByAccountID(ctx, target.ID) if err != nil { - return gtserror.Newf( - "error getting Web Push subscriptions for account %s: %w", - notification.TargetAccountID, - err, - ) + return gtserror.Newf("error getting Web Push subscriptions for account %s: %w", target.URI, err) } // Subscriptions we're actually going to send to. relevantSubscriptions := slices.DeleteFunc( subscriptions, func(subscription *gtsmodel.WebPushSubscription) bool { - return r.shouldSkipSubscription(ctx, notification, subscription) + return r.shouldSkipSubscription(ctx, notif, subscription) }, ) if len(relevantSubscriptions) == 0 { @@ -80,31 +77,28 @@ func (r *realSender) Send( return gtserror.Newf("error getting VAPID key pair: %w", err) } - // Get target account settings. - targetAccountSettings, err := r.state.DB.GetAccountSettings(ctx, notification.TargetAccountID) - if err != nil { - return gtserror.Newf("error getting settings for account %s: %w", notification.TargetAccountID, err) - } + if target.Settings == nil { + // Ensure the target account's settings are populated. + settings, err := r.state.DB.GetAccountSettings(ctx, target.ID) + if err != nil { + return gtserror.Newf("error getting settings for account %s: %w", target.URI, err) + } - // Get API representations of notification and accounts involved. - apiNotification, err := r.converter.NotificationToAPINotification(ctx, notification, filters, mutes) - if err != nil { - return gtserror.Newf("error converting notification %s to API representation: %w", notification.ID, err) + // Set target's settings. + target.Settings = settings } // Queue up a .Send() call for each relevant subscription. for _, subscription := range relevantSubscriptions { r.state.Workers.WebPush.Queue.Push(func(ctx context.Context) { - if err := r.sendToSubscription( - ctx, + if err := r.sendToSubscription(ctx, vapidKeyPair, - targetAccountSettings, + target.Settings, subscription, - notification, - apiNotification, + notif, + apiNotif, ); err != nil { - log.Errorf( - ctx, + log.Errorf(ctx, "error sending Web Push notification for subscription with token ID %s: %v", subscription.TokenID, err, @@ -137,8 +131,7 @@ func (r *realSender) shouldSkipSubscription( // Allow if the subscription account follows the notifying account. isFollowing, err := r.state.DB.IsFollowing(ctx, subscription.AccountID, notification.OriginAccountID) if err != nil { - log.Errorf( - ctx, + log.Errorf(ctx, "error checking whether account %s follows account %s: %v", subscription.AccountID, notification.OriginAccountID, @@ -152,8 +145,7 @@ func (r *realSender) shouldSkipSubscription( // Allow if the notifying account follows the subscription account. isFollowing, err := r.state.DB.IsFollowing(ctx, notification.OriginAccountID, subscription.AccountID) if err != nil { - log.Errorf( - ctx, + log.Errorf(ctx, "error checking whether account %s follows account %s: %v", notification.OriginAccountID, subscription.AccountID, @@ -168,8 +160,7 @@ func (r *realSender) shouldSkipSubscription( return true default: - log.Errorf( - ctx, + log.Errorf(ctx, "unknown Web Push notification policy for subscription with token ID %s: %d", subscription.TokenID, subscription.Policy, diff --git a/internal/webpush/realsender_test.go b/internal/webpush/realsender_test.go index bddcbbc28..a404c166f 100644 --- a/internal/webpush/realsender_test.go +++ b/internal/webpush/realsender_test.go @@ -32,6 +32,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/media" @@ -123,6 +124,7 @@ func (suite *RealSenderStandardTestSuite) SetupTest() { suite.emailSender, suite.webPushSender, visibility.NewFilter(&suite.state), + mutes.NewFilter(&suite.state), interaction.NewFilter(&suite.state), ) testrig.StartWorkers(&suite.state, suite.processor.Workers()) @@ -188,8 +190,14 @@ func (suite *RealSenderStandardTestSuite) simulatePushNotification( }, nil } + apiNotif, err := suite.typeconverter.NotificationToAPINotification(ctx, notification, nil) + suite.NoError(err) + // Send the push notification. - sendError := suite.webPushSender.Send(ctx, notification, nil, nil) + sendError := suite.webPushSender.Send(ctx, + notification, + apiNotif, + ) // Wait for it to be sent or for the context to time out. bodyClosed := false diff --git a/internal/webpush/sender.go b/internal/webpush/sender.go index b7bb75d41..bdc00db1b 100644 --- a/internal/webpush/sender.go +++ b/internal/webpush/sender.go @@ -21,7 +21,7 @@ import ( "context" "net/http" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/httpclient" "code.superseriousbusiness.org/gotosocial/internal/state" @@ -31,14 +31,8 @@ import ( // Sender can send Web Push notifications. type Sender interface { - // Send queues up a notification for delivery to - // all of an account's Web Push subscriptions. - Send( - ctx context.Context, - notification *gtsmodel.Notification, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, - ) error + // Send queues up a notification for delivery to all of an account's Web Push subscriptions. + Send(ctx context.Context, notif *gtsmodel.Notification, apiNotif *apimodel.Notification) error } // NewSender creates a new sender from an HTTP client, DB, and worker pool. diff --git a/test/envparsing.sh b/test/envparsing.sh index 3f8a55fda..807f5831a 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -61,6 +61,7 @@ EXPECT=$(cat << "EOF" "cache-memory-target": "100MiB", "cache-mention-mem-ratio": 2, "cache-move-mem-ratio": 0.1, + "cache-mutes-mem-ratio": 2, "cache-notification-mem-ratio": 2, "cache-poll-mem-ratio": 1, "cache-poll-vote-ids-mem-ratio": 2, diff --git a/testrig/processor.go b/testrig/processor.go index d2405a6f0..4acb7c648 100644 --- a/testrig/processor.go +++ b/testrig/processor.go @@ -22,6 +22,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/federation" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/media" "code.superseriousbusiness.org/gotosocial/internal/processing" @@ -41,7 +42,6 @@ func NewTestProcessor( webPushSender webpush.Sender, mediaManager *media.Manager, ) *processing.Processor { - return processing.NewProcessor( cleaner.New(state), subscriptions.New( @@ -57,6 +57,7 @@ func NewTestProcessor( emailSender, webPushSender, visibility.NewFilter(state), + mutes.NewFilter(state), interaction.NewFilter(state), ) } diff --git a/testrig/teststructs.go b/testrig/teststructs.go index 3ca45e94e..f119bd113 100644 --- a/testrig/teststructs.go +++ b/testrig/teststructs.go @@ -22,6 +22,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/cleaner" "code.superseriousbusiness.org/gotosocial/internal/email" "code.superseriousbusiness.org/gotosocial/internal/filter/interaction" + "code.superseriousbusiness.org/gotosocial/internal/filter/mutes" "code.superseriousbusiness.org/gotosocial/internal/filter/visibility" "code.superseriousbusiness.org/gotosocial/internal/processing" "code.superseriousbusiness.org/gotosocial/internal/processing/common" @@ -67,6 +68,7 @@ func SetupTestStructs( state.Storage = storage typeconverter := typeutils.NewConverter(&state) visFilter := visibility.NewFilter(&state) + muteFilter := mutes.NewFilter(&state) intFilter := interaction.NewFilter(&state) httpClient := NewMockHTTPClient(nil, rMediaPath) @@ -86,6 +88,7 @@ func SetupTestStructs( typeconverter, federator, visFilter, + muteFilter, ) processor := processing.NewProcessor( @@ -99,6 +102,7 @@ func SetupTestStructs( emailSender, webPushSender, visFilter, + muteFilter, intFilter, ) diff --git a/testrig/webpush.go b/testrig/webpush.go index d4752ae90..b9ca9611d 100644 --- a/testrig/webpush.go +++ b/testrig/webpush.go @@ -20,7 +20,7 @@ package testrig import ( "context" - "code.superseriousbusiness.org/gotosocial/internal/filter/usermute" + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" "code.superseriousbusiness.org/gotosocial/internal/webpush" ) @@ -39,11 +39,10 @@ func NewWebPushMockSender() *WebPushMockSender { func (m *WebPushMockSender) Send( ctx context.Context, - notification *gtsmodel.Notification, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, + notif *gtsmodel.Notification, + apiNotif *apimodel.Notification, ) error { - m.Sent[notification.TargetAccountID] = append(m.Sent[notification.TargetAccountID], notification) + m.Sent[notif.TargetAccountID] = append(m.Sent[notif.TargetAccountID], notif) return nil } @@ -57,9 +56,8 @@ func NewNoopWebPushSender() webpush.Sender { func (n *noopWebPushSender) Send( ctx context.Context, - notification *gtsmodel.Notification, - filters []*gtsmodel.Filter, - mutes *usermute.CompiledUserMuteList, + notif *gtsmodel.Notification, + apiNotif *apimodel.Notification, ) error { return nil }