about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/concerns/account_interactions.rb11
-rw-r--r--app/models/concerns/remotable.rb39
-rw-r--r--app/models/concerns/status_threading_concern.rb10
-rw-r--r--app/models/favourite.rb25
-rw-r--r--app/models/glitch/keyword_mute.rb72
-rw-r--r--app/models/status.rb64
-rw-r--r--app/models/trending_tags.rb23
7 files changed, 170 insertions, 74 deletions
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index a064248d9..d067415fd 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -187,4 +187,15 @@ module AccountInteractions
   def pinned?(status)
     status_pins.where(status: status).exists?
   end
+
+  def followers_for_local_distribution
+    followers.local
+             .joins(:user)
+             .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
+  end
+
+  def lists_for_local_distribution
+    lists.joins(account: :user)
+         .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
+  end
 end
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index c17f04776..c17f19a60 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -24,14 +24,17 @@ module Remotable
           Request.new(:get, url).perform do |response|
             next if response.code != 200
 
-            matches  = response.headers['content-disposition']&.match(/filename="([^"]*)"/)
-            filename = matches.nil? ? parsed_url.path.split('/').last : matches[1]
+            content_type = parse_content_type(response.headers.get('content-type').last)
+            extname      = detect_extname_from_content_type(content_type)
+
+            if extname.nil?
+              disposition = response.headers.get('content-disposition').last
+              matches     = disposition&.match(/filename="([^"]*)"/)
+              filename    = matches.nil? ? parsed_url.path.split('/').last : matches[1]
+              extname     = filename.nil? ? '' : File.extname(filename)
+            end
+
             basename = SecureRandom.hex(8)
-            extname = if filename.nil?
-                        ''
-                      else
-                        File.extname(filename)
-                      end
 
             send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
             send("#{attachment_name}_file_name=", basename + extname)
@@ -57,4 +60,26 @@ module Remotable
       end
     end
   end
+
+  private
+
+  def detect_extname_from_content_type(content_type)
+    return if content_type.nil?
+
+    type = MIME::Types[content_type].first
+
+    return if type.nil?
+
+    extname = type.extensions.first
+
+    return if extname.nil?
+
+    ".#{extname}"
+  end
+
+  def parse_content_type(content_type)
+    return if content_type.nil?
+
+    content_type.split(/\s*;\s*/).first
+  end
 end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index 1ba8fc693..fa441469c 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -51,12 +51,16 @@ module StatusThreadingConcern
   end
 
   def descendant_statuses(limit, max_child_id, since_child_id, depth)
-    Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, max_child_id: max_child_id, since_child_id: since_child_id, depth: depth])
+    # use limit + 1 and depth + 1 because 'self' is included
+    depth += 1 if depth.present?
+    limit += 1 if limit.present?
+
+    descendants_with_self = Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, max_child_id: max_child_id, since_child_id: since_child_id, depth: depth])
       WITH RECURSIVE search_tree(id, path)
       AS (
         SELECT id, ARRAY[id]
         FROM statuses
-        WHERE in_reply_to_id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE)
+        WHERE id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE)
         UNION ALL
         SELECT statuses.id, path || statuses.id
         FROM search_tree
@@ -68,6 +72,8 @@ module StatusThreadingConcern
       ORDER BY path
       LIMIT :limit
     SQL
+
+    descendants_with_self - [self]
   end
 
   def find_statuses_from_tree_path(ids, account)
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index c998a67eb..0fce82f6f 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -16,7 +16,7 @@ class Favourite < ApplicationRecord
   update_index('statuses#status', :status) if Chewy.enabled?
 
   belongs_to :account, inverse_of: :favourites
-  belongs_to :status,  inverse_of: :favourites, counter_cache: true
+  belongs_to :status,  inverse_of: :favourites
 
   has_one :notification, as: :activity, dependent: :destroy
 
@@ -25,4 +25,27 @@ class Favourite < ApplicationRecord
   before_validation do
     self.status = status.reblog if status&.reblog?
   end
+
+  after_create :increment_cache_counters
+  after_destroy :decrement_cache_counters
+
+  private
+
+  def increment_cache_counters
+    if association(:status).loaded?
+      status.update_attribute(:favourites_count, status.favourites_count + 1)
+    else
+      Status.where(id: status_id).update_all('favourites_count = COALESCE(favourites_count, 0) + 1')
+    end
+  end
+
+  def decrement_cache_counters
+    return if association(:status).loaded? && (status.marked_for_destruction? || status.marked_for_mass_destruction?)
+
+    if association(:status).loaded?
+      status.update_attribute(:favourites_count, [status.favourites_count - 1, 0].max)
+    else
+      Status.where(id: status_id).update_all('favourites_count = GREATEST(COALESCE(favourites_count, 0) - 1, 0)')
+    end
+  end
 end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
index 17ebc5b5e..7d0002afd 100644
--- a/app/models/glitch/keyword_mute.rb
+++ b/app/models/glitch/keyword_mute.rb
@@ -33,68 +33,74 @@ class Glitch::KeywordMute < ApplicationRecord
     Rails.cache.delete(TagMatcher.cache_key(account_id))
   end
 
-  class RegexpMatcher
+  class CachedKeywordMute
+    attr_reader :keyword
+    attr_reader :whole_word
+
+    def initialize(keyword, whole_word)
+      @keyword = keyword
+      @whole_word = whole_word
+    end
+
+    def boundary_regex_for_keyword
+      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+
+      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    end
+
+    def matches?(str)
+      str =~ (whole_word ? boundary_regex_for_keyword : /#{Regexp.escape(keyword)}/i)
+    end
+  end
+
+  class Matcher
     attr_reader :account_id
-    attr_reader :regex
+    attr_reader :words
 
     def initialize(account_id)
       @account_id = account_id
-      regex_text = Rails.cache.fetch(self.class.cache_key(account_id)) { make_regex_text }
-      @regex = /#{regex_text}/
+      @words = Rails.cache.fetch(self.class.cache_key(account_id)) { fetch_keywords }
     end
 
     protected
 
-    def keywords
-      Glitch::KeywordMute.where(account_id: account_id).pluck(:whole_word, :keyword)
+    def fetch_keywords
+      Glitch::KeywordMute.where(account_id: account_id).pluck(:whole_word, :keyword).map do |whole_word, keyword|
+        CachedKeywordMute.new(transform_keyword(keyword), whole_word)
+      end
     end
 
-    def boundary_regex_for_keyword(keyword)
-      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
-      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
-
-      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    def transform_keyword(keyword)
+      keyword
     end
   end
 
-  class TextMatcher < RegexpMatcher
+  class TextMatcher < Matcher
     def self.cache_key(account_id)
       format('keyword_mutes:regex:text:%s', account_id)
     end
 
     def matches?(str)
-      !!(regex =~ str)
-    end
-
-    private
-
-    def make_regex_text
-      kws = keywords.map! do |whole_word, keyword|
-        whole_word ? boundary_regex_for_keyword(keyword) : /(?i:#{Regexp.escape(keyword)})/
-      end
-
-      Regexp.union(kws).source
+      words.any? { |w| w.matches?(str) }
     end
   end
 
-  class TagMatcher < RegexpMatcher
+  class TagMatcher < Matcher
     def self.cache_key(account_id)
       format('keyword_mutes:regex:tag:%s', account_id)
     end
 
     def matches?(tags)
-      tags.pluck(:name).any? { |n| regex =~ n }
+      tags.pluck(:name).any? do |n|
+        words.any? { |w| w.matches?(n) }
+      end
     end
 
-    private
-
-    def make_regex_text
-      kws = keywords.map! do |whole_word, keyword|
-        term = (Tag::HASHTAG_RE =~ keyword) ? $1 : keyword
-        whole_word ? boundary_regex_for_keyword(term) : term
-      end
+    protected
 
-      Regexp.union(kws).source
+    def transform_keyword(kw)
+      Tag::HASHTAG_RE =~ kw ? $1 : kw
     end
   end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index c6d6453df..c0e241ddd 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -43,19 +43,19 @@ class Status < ApplicationRecord
 
   belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
 
-  belongs_to :account, inverse_of: :statuses, counter_cache: true
+  belongs_to :account, inverse_of: :statuses
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true
   belongs_to :conversation, optional: true
 
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
-  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count, optional: true
+  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
 
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :bookmarks, inverse_of: :status, dependent: :destroy
   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
   has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy
-  has_many :media_attachments, dependent: :destroy
+  has_many :media_attachments, dependent: :nullify
 
   has_and_belongs_to_many :tags
   has_and_belongs_to_many :preview_cards
@@ -172,6 +172,17 @@ class Status < ApplicationRecord
     @emojis ||= CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
   end
 
+  def mark_for_mass_destruction!
+    @marked_for_mass_destruction = true
+  end
+
+  def marked_for_mass_destruction?
+    @marked_for_mass_destruction
+  end
+
+  after_create  :increment_counter_caches
+  after_destroy :decrement_counter_caches
+
   after_create_commit :store_uri, if: :local?
   after_create_commit :update_statistics, if: :local?
 
@@ -183,7 +194,6 @@ class Status < ApplicationRecord
   before_validation :set_reblog
   before_validation :set_visibility
   before_validation :set_conversation
-  before_validation :set_sensitivity
   before_validation :set_local
 
   class << self
@@ -306,7 +316,11 @@ class Status < ApplicationRecord
         # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in.
         visibility.push(:private) if account.following?(target_account)
 
-        where(visibility: visibility).or(where(id: account.mentions.select(:status_id)))
+        scope = left_outer_joins(:reblog)
+
+        scope.where(visibility: visibility)
+             .or(scope.where(id: account.mentions.select(:status_id)))
+             .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids })))
       end
     end
 
@@ -377,10 +391,6 @@ class Status < ApplicationRecord
     self.sensitive  = false if sensitive.nil?
   end
 
-  def set_sensitivity
-    self.sensitive = sensitive || spoiler_text.present?
-  end
-
   def set_locality
     if account.domain.nil? && !attribute_changed?(:local_only)
       self.local_only = marked_local_only?
@@ -414,4 +424,40 @@ class Status < ApplicationRecord
     return unless public_visibility? || unlisted_visibility?
     ActivityTracker.increment('activity:statuses:local')
   end
+
+  def increment_counter_caches
+    return if direct_visibility?
+
+    if association(:account).loaded?
+      account.update_attribute(:statuses_count, account.statuses_count + 1)
+    else
+      Account.where(id: account_id).update_all('statuses_count = COALESCE(statuses_count, 0) + 1')
+    end
+
+    return unless reblog?
+
+    if association(:reblog).loaded?
+      reblog.update_attribute(:reblogs_count, reblog.reblogs_count + 1)
+    else
+      Status.where(id: reblog_of_id).update_all('reblogs_count = COALESCE(reblogs_count, 0) + 1')
+    end
+  end
+
+  def decrement_counter_caches
+    return if direct_visibility? || marked_for_mass_destruction?
+
+    if association(:account).loaded?
+      account.update_attribute(:statuses_count, [account.statuses_count - 1, 0].max)
+    else
+      Account.where(id: account_id).update_all('statuses_count = GREATEST(COALESCE(statuses_count, 0) - 1, 0)')
+    end
+
+    return unless reblog?
+
+    if association(:reblog).loaded?
+      reblog.update_attribute(:reblogs_count, [reblog.reblogs_count - 1, 0].max)
+    else
+      Status.where(id: reblog_of_id).update_all('reblogs_count = GREATEST(COALESCE(reblogs_count, 0) - 1, 0)')
+    end
+  end
 end
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
index eedd92644..c3641d7fd 100644
--- a/app/models/trending_tags.rb
+++ b/app/models/trending_tags.rb
@@ -1,33 +1,18 @@
 # frozen_string_literal: true
 
 class TrendingTags
-  KEY                  = 'trending_tags'
-  HALF_LIFE            = 1.day.to_i
-  MAX_ITEMS            = 500
   EXPIRE_HISTORY_AFTER = 7.days.seconds
 
   class << self
     def record_use!(tag, account, at_time = Time.now.utc)
-      return if disallowed_hashtags.include?(tag.name) || account.silenced?
+      return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
 
-      increment_vote!(tag.id, at_time)
       increment_historical_use!(tag.id, at_time)
       increment_unique_use!(tag.id, account.id, at_time)
     end
 
-    def get(limit)
-      tag_ids = redis.zrevrange(KEY, 0, limit).map(&:to_i)
-      tags    = Tag.where(id: tag_ids).to_a.map { |tag| [tag.id, tag] }.to_h
-      tag_ids.map { |tag_id| tags[tag_id] }.compact
-    end
-
     private
 
-    def increment_vote!(tag_id, at_time)
-      redis.zincrby(KEY, (2**((at_time.to_i - epoch) / HALF_LIFE)).to_f, tag_id.to_s)
-      redis.zremrangebyrank(KEY, 0, -MAX_ITEMS) if rand < (2.to_f / MAX_ITEMS)
-    end
-
     def increment_historical_use!(tag_id, at_time)
       key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
       redis.incrby(key, 1)
@@ -40,12 +25,6 @@ class TrendingTags
       redis.expire(key, EXPIRE_HISTORY_AFTER)
     end
 
-    # The epoch needs to be 2.5 years in the future if the half-life is one day
-    # While dynamic, it will always be the same within one year
-    def epoch
-      @epoch ||= Date.new(Date.current.year + 2.5, 10, 1).to_datetime.to_i
-    end
-
     def disallowed_hashtags
       return @disallowed_hashtags if defined?(@disallowed_hashtags)