summaryrefslogtreecommitdiff
path: root/internal/filter/status/status.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/filter/status/status.go')
-rw-r--r--internal/filter/status/status.go313
1 files changed, 309 insertions, 4 deletions
diff --git a/internal/filter/status/status.go b/internal/filter/status/status.go
index 1a611cdd1..5f997129d 100644
--- a/internal/filter/status/status.go
+++ b/internal/filter/status/status.go
@@ -15,12 +15,317 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
-// Package status represents status filters managed by the user through the API.
package status
import (
- "errors"
+ "context"
+ "regexp"
+ "slices"
+ "time"
+
+ apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
+ "code.superseriousbusiness.org/gotosocial/internal/cache"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
)
-// ErrHideStatus indicates that a status has been filtered and should not be returned at all.
-var ErrHideStatus = errors.New("hide status")
+// StatusFilterResultsInContext returns status filtering results, limited
+// to the given filtering context, about the given status for requester.
+// The hide flag is immediately returned if any filters match with the
+// HIDE action set, else API model filter results for the WARN action.
+func (f *Filter) StatusFilterResultsInContext(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ status *gtsmodel.Status,
+ context gtsmodel.FilterContext,
+) (
+ results []apimodel.FilterResult,
+ hidden bool,
+ err error,
+) {
+ if context == gtsmodel.FilterContextNone {
+ // fast-check any context.
+ return nil, false, nil
+ }
+
+ // Get cached filter results for status to requester in all contexts.
+ allResults, now, err := f.StatusFilterResults(ctx, requester, status)
+ if err != nil {
+ return nil, false, err
+ }
+
+ // Get results applicable to current context.
+ var forContext []cache.StatusFilterResult
+ switch context {
+ case gtsmodel.FilterContextHome:
+ forContext = allResults.Results[cache.KeyContextHome]
+ case gtsmodel.FilterContextPublic:
+ forContext = allResults.Results[cache.KeyContextPublic]
+ case gtsmodel.FilterContextNotifications:
+ forContext = allResults.Results[cache.KeyContextNotifs]
+ case gtsmodel.FilterContextThread:
+ forContext = allResults.Results[cache.KeyContextThread]
+ case gtsmodel.FilterContextAccount:
+ forContext = allResults.Results[cache.KeyContextAccount]
+ }
+
+ // Iterate results in context, gathering prepared API models.
+ results = make([]apimodel.FilterResult, 0, len(forContext))
+ for _, result := range forContext {
+
+ // Check if result expired.
+ if result.Expired(now) {
+ continue
+ }
+
+ // If the result indicates
+ // status should just be
+ // hidden then return here.
+ if result.Result == nil {
+ return nil, true, nil
+ }
+
+ // Append pre-prepared API model to slice.
+ results = append(results, *result.Result)
+ }
+
+ return
+}
+
+// StatusFilterResults returns status filtering results (in all contexts) about the given status for the given requesting account.
+func (f *Filter) StatusFilterResults(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (*cache.CachedStatusFilterResults, time.Time, error) {
+
+ // For requester ID use a
+ // fallback 'noauth' string
+ // by default for lookups.
+ requesterID := noauth
+ if requester != nil {
+ requesterID = requester.ID
+ }
+
+ // Get current time.
+ now := time.Now()
+
+ // Load status filtering results for this requesting account about status from cache, using load callback function if necessary.
+ results, err := f.state.Caches.StatusFilter.LoadOne("RequesterID,StatusID", func() (*cache.CachedStatusFilterResults, error) {
+
+ // Load status filter results for given status.
+ results, err := f.getStatusFilterResults(ctx,
+ requester,
+ status,
+ now,
+ )
+ if err != nil {
+ if err == cache.SentinelError {
+ // Filter-out our temporary
+ // race-condition error.
+ return &cache.CachedStatusFilterResults{}, nil
+ }
+
+ return nil, err
+ }
+
+ // Convert to cacheable results type.
+ return &cache.CachedStatusFilterResults{
+ StatusID: status.ID,
+ RequesterID: requesterID,
+ Results: results,
+ }, nil
+ }, requesterID, status.ID)
+ if err != nil {
+ return nil, now, err
+ }
+
+ return results, now, err
+}
+
+// getStatusFilterResults loads status filtering results for
+// the given status, given the current time (checking expiries).
+// this will load results for all possible filtering contexts.
+func (f *Filter) getStatusFilterResults(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ status *gtsmodel.Status,
+ now time.Time,
+) (
+ [5][]cache.StatusFilterResult,
+ error,
+) {
+ var results [5][]cache.StatusFilterResult
+
+ if requester == nil {
+ // Without auth, there will be no possible
+ // filters to exists, return as 'unfiltered'.
+ return results, nil
+ }
+
+ // Get the string fields status is
+ // filterable on for keyword matching.
+ fields := getFilterableFields(status)
+
+ // Get all status filters owned by the requesting account.
+ filters, err := f.state.DB.GetFiltersByAccountID(ctx, requester.ID)
+ if err != nil {
+ return results, gtserror.Newf("error getting account filters: %w", err)
+ }
+
+ // For proper status filtering we need all fields populated.
+ if err := f.state.DB.PopulateStatus(ctx, status); err != nil {
+ return results, gtserror.Newf("error populating status: %w", err)
+ }
+
+ // Generate result for each filter.
+ for _, filter := range filters {
+
+ // Skip already expired.
+ if filter.Expired(now) {
+ continue
+ }
+
+ // Later stored API result, if any.
+ // (for the HIDE action, it is unset).
+ var apiResult *apimodel.FilterResult
+
+ switch filter.Action {
+ case gtsmodel.FilterActionWarn:
+ // For filter action WARN get all possible filter matches against status.
+ keywordMatches, statusMatches := getFilterMatches(filter, status.ID, fields)
+ if len(keywordMatches) == 0 && len(statusMatches) == 0 {
+ continue
+ }
+
+ // Wrap matches in frontend API model.
+ apiResult = &apimodel.FilterResult{
+ Filter: toAPIFilterV2(filter),
+
+ KeywordMatches: keywordMatches,
+ StatusMatches: statusMatches,
+ }
+
+ // For filter action HIDE quickly
+ // look for first possible match
+ // against this status, or reloop.
+ case gtsmodel.FilterActionHide:
+ if !doesFilterMatch(filter, status.ID, fields) {
+ continue
+ }
+ }
+
+ // Wrap the filter result in our cache model.
+ // This model simply existing implies this
+ // status has been filtered, defaulting to
+ // action HIDE, or WARN on a non-nil result.
+ result := cache.StatusFilterResult{
+ Expiry: filter.ExpiresAt,
+ Result: apiResult,
+ }
+
+ // Append generated result if
+ // applies in 'home' context.
+ if filter.Contexts.Home() {
+ const key = cache.KeyContextHome
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'public' context.
+ if filter.Contexts.Public() {
+ const key = cache.KeyContextPublic
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'notifs' context.
+ if filter.Contexts.Notifications() {
+ const key = cache.KeyContextNotifs
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'thread' context.
+ if filter.Contexts.Thread() {
+ const key = cache.KeyContextThread
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'account' context.
+ if filter.Contexts.Account() {
+ const key = cache.KeyContextAccount
+ results[key] = append(results[key], result)
+ }
+ }
+
+ // Iterate all filter results.
+ for _, key := range [5]int{
+ cache.KeyContextHome,
+ cache.KeyContextPublic,
+ cache.KeyContextNotifs,
+ cache.KeyContextThread,
+ cache.KeyContextAccount,
+ } {
+ // Sort the slice of filter results by their expiry, soonest coming first.
+ slices.SortFunc(results[key], func(a, b cache.StatusFilterResult) int {
+ const k = +1
+ switch {
+ case a.Expiry.IsZero():
+ if b.Expiry.IsZero() {
+ return 0
+ }
+ return +k
+ case b.Expiry.IsZero():
+ return -k
+ case a.Expiry.Before(b.Expiry):
+ return -k
+ case b.Expiry.Before(a.Expiry):
+ return +k
+ default:
+ return 0
+ }
+ })
+ }
+
+ return results, nil
+}
+
+// getFilterMatches returns *all* the keyword and status matches of status ID and fields on given filter.
+func getFilterMatches(filter *gtsmodel.Filter, statusID string, fields []string) ([]string, []string) {
+ keywordMatches := make([]string, 0, len(filter.Keywords))
+ for _, keyword := range filter.Keywords {
+ if doesKeywordMatch(keyword.Regexp, fields) {
+ keywordMatches = append(keywordMatches, keyword.Keyword)
+ }
+ }
+ statusMatches := make([]string, 0, 1)
+ for _, status := range filter.Statuses {
+ if status.StatusID == statusID {
+ statusMatches = append(statusMatches, statusID)
+ }
+ }
+ return keywordMatches, statusMatches
+}
+
+// doesFilterMatch returns if any of fields or status ID match on the given filter.
+func doesFilterMatch(filter *gtsmodel.Filter, statusID string, fields []string) bool {
+ for _, status := range filter.Statuses {
+ if status.StatusID == statusID {
+ return true
+ }
+ }
+ for _, keyword := range filter.Keywords {
+ if doesKeywordMatch(keyword.Regexp, fields) {
+ return true
+ }
+ }
+ return false
+}
+
+// doesKeywordMatch returns if any of fields match given keyword regex.
+func doesKeywordMatch(rgx *regexp.Regexp, fields []string) bool {
+ for _, field := range fields {
+ if rgx.MatchString(field) {
+ return true
+ }
+ }
+ return false
+}