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/account.rb101
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/favourite.rb2
-rw-r--r--app/models/follow.rb4
-rw-r--r--app/models/import.rb14
-rw-r--r--app/models/media_attachment.rb73
-rw-r--r--app/models/mute.rb11
-rw-r--r--app/models/setting.rb1
-rw-r--r--app/models/status.rb48
-rw-r--r--app/models/tag.rb22
-rw-r--r--app/models/user.rb7
11 files changed, 225 insertions, 60 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index ed5c46197..6968607a2 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -2,9 +2,8 @@
 
 class Account < ApplicationRecord
   include Targetable
-  include PgSearch
 
-  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
+  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
 
   # Local users
@@ -46,15 +45,16 @@ class Account < ApplicationRecord
   has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
   has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
 
+  # Mute relationships
+  has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
+  has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
   # PuSH subscriptions
   has_many :subscriptions, dependent: :destroy
 
-  pg_search_scope :search_for, against: { username: 'A', domain: 'B' },
-                               using: { tsearch: { prefix: true } }
-
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) = 0') }
@@ -73,6 +73,10 @@ class Account < ApplicationRecord
     block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
   end
 
+  def mute!(other_account)
+    mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
+  end
+
   def unfollow!(other_account)
     follow = active_relationships.find_by(target_account: other_account)
     follow&.destroy
@@ -83,6 +87,11 @@ class Account < ApplicationRecord
     block&.destroy
   end
 
+  def unmute!(other_account)
+    mute = mute_relationships.find_by(target_account: other_account)
+    mute&.destroy
+  end
+
   def following?(other_account)
     following.include?(other_account)
   end
@@ -91,6 +100,10 @@ class Account < ApplicationRecord
     blocking.include?(other_account)
   end
 
+  def muting?(other_account)
+    muting.include?(other_account)
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
@@ -131,14 +144,16 @@ class Account < ApplicationRecord
     save!
   rescue ActiveRecord::RecordInvalid
     self.avatar              = nil
+    self.header              = nil
     self[:avatar_remote_url] = ''
+    self[:header_remote_url] = ''
     save!
   end
 
   def avatar_remote_url=(url)
     parsed_url = URI.parse(url)
 
-    return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url
+    return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[:avatar_remote_url] == url
 
     self.avatar              = parsed_url
     self[:avatar_remote_url] = url
@@ -146,6 +161,17 @@ class Account < ApplicationRecord
     Rails.logger.debug "Error fetching remote avatar: #{e}"
   end
 
+  def header_remote_url=(url)
+    parsed_url = URI.parse(url)
+
+    return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[:header_remote_url] == url
+
+    self.header              = parsed_url
+    self[:header_remote_url] = url
+  rescue OpenURI::HTTPError => e
+    Rails.logger.debug "Error fetching remote header: #{e}"
+  end
+
   def object_type
     :person
   end
@@ -161,7 +187,7 @@ class Account < ApplicationRecord
 
     def find_remote!(username, domain)
       return if username.blank?
-      where(arel_table[:username].matches(username.gsub(/[%_]/, '\\\\\0'))).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain.gsub(/[%_]/, '\\\\\0'))).take!
+      where('lower(accounts.username) = ?', username.downcase).where(domain.nil? ? { domain: nil } : 'lower(accounts.domain) = ?', domain&.downcase).take!
     end
 
     def find_local(username)
@@ -176,6 +202,63 @@ class Account < ApplicationRecord
       nil
     end
 
+    def triadic_closures(account, limit = 5)
+      sql = <<SQL
+        WITH first_degree AS (
+            SELECT target_account_id
+            FROM follows
+            WHERE account_id = ?
+          )
+        SELECT accounts.*
+        FROM follows
+        INNER JOIN accounts ON follows.target_account_id = accounts.id
+        WHERE account_id IN (SELECT * FROM first_degree) AND target_account_id NOT IN (SELECT * FROM first_degree) AND target_account_id <> ?
+        GROUP BY target_account_id, accounts.id
+        ORDER BY count(account_id) DESC
+        LIMIT ?
+SQL
+
+      Account.find_by_sql([sql, account.id, account.id, limit])
+    end
+
+    def search_for(terms, limit = 10)
+      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
+      textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))'
+      query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')'
+
+      sql = <<SQL
+        SELECT
+          accounts.*,
+          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
+        FROM accounts
+        WHERE #{query} @@ #{textsearch}
+        ORDER BY rank DESC
+        LIMIT ?
+SQL
+
+      Account.find_by_sql([sql, limit])
+    end
+
+    def advanced_search_for(terms, account, limit = 10)
+      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
+      textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))'
+      query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')'
+
+      sql = <<SQL
+        SELECT
+          accounts.*,
+          (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
+        FROM accounts
+        LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
+        WHERE #{query} @@ #{textsearch}
+        GROUP BY accounts.id
+        ORDER BY rank DESC
+        LIMIT ?
+SQL
+
+      Account.find_by_sql([sql, account.id, account.id, limit])
+    end
+
     def following_map(target_account_ids, account_id)
       follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
@@ -188,6 +271,10 @@ class Account < ApplicationRecord
       follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
+    def muting_map(target_account_ids, account_id)
+      follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+    end
+
     def requested_map(target_account_ids, account_id)
       follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index b4606da60..3548ccd69 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -6,6 +6,6 @@ class DomainBlock < ApplicationRecord
   validates :domain, presence: true, uniqueness: true
 
   def self.blocked?(domain)
-    where(domain: domain).exists?
+    where(domain: domain, severity: :suspend).exists?
   end
 end
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 67a293888..41d06e734 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -4,7 +4,7 @@ class Favourite < ApplicationRecord
   include Paginable
 
   belongs_to :account, inverse_of: :favourites
-  belongs_to :status,  inverse_of: :favourites
+  belongs_to :status,  inverse_of: :favourites, counter_cache: true
 
   has_one :notification, as: :activity, dependent: :destroy
 
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 57db8c462..8bfe8b2f6 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -3,8 +3,8 @@
 class Follow < ApplicationRecord
   include Paginable
 
-  belongs_to :account
-  belongs_to :target_account, class_name: 'Account'
+  belongs_to :account, counter_cache: :following_count
+  belongs_to :target_account, class_name: 'Account', counter_cache: :followers_count
 
   has_one :notification, as: :activity, dependent: :destroy
 
diff --git a/app/models/import.rb b/app/models/import.rb
new file mode 100644
index 000000000..5384986d8
--- /dev/null
+++ b/app/models/import.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Import < ApplicationRecord
+  self.inheritance_column = false
+
+  enum type: [:following, :blocking]
+
+  belongs_to :account
+
+  FILE_TYPES = ['text/plain', 'text/csv'].freeze
+
+  has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
+  validates_attachment_content_type :data, content_type: FILE_TYPES
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6925f9b0d..818190214 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -1,15 +1,32 @@
 # frozen_string_literal: true
 
 class MediaAttachment < ApplicationRecord
+  self.inheritance_column = nil
+
+  enum type: [:image, :gifv, :video]
+
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
 
+  IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  VIDEO_STYLES = {
+    small: {
+      convert_options: {
+        output: {
+          vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+        },
+      },
+      format: 'png',
+      time: 0,
+    },
+  }.freeze
+
   belongs_to :account, inverse_of: :media_attachments
   belongs_to :status,  inverse_of: :media_attachments
 
   has_attached_file :file,
-                    styles: -> (f) { file_styles f },
-                    processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
+                    styles: ->(f) { file_styles f },
+                    processors: ->(f) { file_processors f },
                     convert_options: { all: '-quality 90 -strip' }
   validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
   validates_attachment_size :file, less_than: 8.megabytes
@@ -27,45 +44,49 @@ class MediaAttachment < ApplicationRecord
     self.file = URI.parse(url)
   end
 
-  def image?
-    IMAGE_MIME_TYPES.include? file_content_type
-  end
-
-  def video?
-    VIDEO_MIME_TYPES.include? file_content_type
-  end
-
-  def type
-    image? ? 'image' : 'video'
-  end
-
   def to_param
     shortcode
   end
 
   before_create :set_shortcode
+  before_post_process :set_type
 
   class << self
     private
 
     def file_styles(f)
-      if f.instance.image?
+      if f.instance.file_content_type == 'image/gif'
         {
-          original: '1280x1280>',
-          small: '400x400>',
-        }
-      else
-        {
-          small: {
+          small: IMAGE_STYLES[:small],
+          original: {
+            format: 'mp4',
             convert_options: {
               output: {
-                vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+                'movflags' => 'faststart',
+                'pix_fmt'  => 'yuv420p',
+                'vf'       => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
+                'vsync'    => 'cfr',
+                'b:v'      => '1300K',
+                'maxrate'  => '500K',
+                'crf'      => 6,
               },
             },
-            format: 'png',
-            time: 1,
           },
         }
+      elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
+        IMAGE_STYLES
+      else
+        VIDEO_STYLES
+      end
+    end
+
+    def file_processors(f)
+      if f.file_content_type == 'image/gif'
+        [:gif_transcoder]
+      elsif VIDEO_MIME_TYPES.include? f.file_content_type
+        [:video_transcoder]
+      else
+        [:thumbnail]
       end
     end
   end
@@ -80,4 +101,8 @@ class MediaAttachment < ApplicationRecord
       break if MediaAttachment.find_by(shortcode: shortcode).nil?
     end
   end
+
+  def set_type
+    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
+  end
 end
diff --git a/app/models/mute.rb b/app/models/mute.rb
new file mode 100644
index 000000000..a5b334c85
--- /dev/null
+++ b/app/models/mute.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Mute < ApplicationRecord
+  include Paginable
+
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  validates :account, :target_account, presence: true
+  validates :account_id, uniqueness: { scope: :target_account_id }
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 3796253d4..31e1ee198 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -2,7 +2,6 @@
 
 class Setting < RailsSettings::Base
   source Rails.root.join('config/settings.yml')
-  namespace Rails.env
 
   def to_param
     var
diff --git a/app/models/status.rb b/app/models/status.rb
index 46d92ea33..81b26fd14 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -6,15 +6,15 @@ class Status < ApplicationRecord
   include Streamable
   include Cacheable
 
-  enum visibility: [:public, :unlisted, :private], _suffix: :visibility
+  enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
 
   belongs_to :application, class_name: 'Doorkeeper::Application'
 
-  belongs_to :account, inverse_of: :statuses
+  belongs_to :account, inverse_of: :statuses, counter_cache: true
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account'
 
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
-  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
+  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count
 
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
@@ -37,6 +37,9 @@ class Status < ApplicationRecord
   scope :remote, -> { where.not(uri: nil) }
   scope :local, -> { where(uri: nil) }
 
+  scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
+  scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
+
   cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
 
   def reply?
@@ -72,12 +75,14 @@ class Status < ApplicationRecord
   end
 
   def hidden?
-    private_visibility?
+    private_visibility? || direct_visibility?
   end
 
   def permitted?(other_account = nil)
-    if private_visibility?
-      (account.id == other_account&.id || other_account&.following?(account) || mentions.where(account: other_account).exists?)
+    if direct_visibility?
+      account.id == other_account&.id || mentions.where(account: other_account).exists?
+    elsif private_visibility?
+      account.id == other_account&.id || other_account&.following?(account) || mentions.where(account: other_account).exists?
     else
       other_account.nil? || !account.blocking?(other_account)
     end
@@ -109,8 +114,8 @@ class Status < ApplicationRecord
     def as_public_timeline(account = nil, local_only = false)
       query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
               .where(visibility: :public)
-              .where('(statuses.reply = false OR statuses.in_reply_to_account_id = statuses.account_id)')
-              .where('statuses.reblog_of_id IS NULL')
+              .without_replies
+              .without_reblogs
 
       query = query.where('accounts.domain IS NULL') if local_only
 
@@ -121,7 +126,7 @@ class Status < ApplicationRecord
       query = tag.statuses
                  .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
                  .where(visibility: :public)
-                 .where('statuses.reblog_of_id IS NULL')
+                 .without_reblogs
 
       query = query.where('accounts.domain IS NULL') if local_only
 
@@ -153,24 +158,27 @@ class Status < ApplicationRecord
     end
 
     def permitted_for(target_account, account)
-      if account&.id == target_account.id || account&.following?(target_account)
-        where('1 = 1')
-      elsif !account.nil? && target_account.blocking?(account)
+      return where.not(visibility: [:private, :direct]) if account.nil?
+
+      if target_account.blocking?(account) # get rid of blocked peeps
         where('1 = 0')
-      elsif !account.nil?
+      elsif account.id == target_account.id # author can see own stuff
+        where('1 = 1')
+      elsif account.following?(target_account) # followers can see followers-only stuff, but also things they are mentioned in
+        joins('LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = ' + account.id.to_s)
+          .where('statuses.visibility != ? OR mentions.id IS NOT NULL', Status.visibilities[:direct])
+      else # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in
         joins('LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = ' + account.id.to_s)
-          .where('statuses.visibility != ? OR mentions.id IS NOT NULL', Status.visibilities[:private])
-      else
-        where.not(visibility: :private)
+          .where('statuses.visibility NOT IN (?) OR mentions.id IS NOT NULL', [Status.visibilities[:direct], Status.visibilities[:private]])
       end
     end
 
     private
 
     def filter_timeline(query, account)
-      blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id)
-      query   = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?
-      query   = query.where('accounts.silenced = TRUE') if account.silenced?
+      blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) + Mute.where(account: account).pluck(:target_account_id)
+      query   = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?  # Only give us statuses from people we haven't blocked, or muted, or that have blocked us
+      query   = query.where('accounts.silenced = TRUE') if account.silenced?                  # and if we're hellbanned, only people who are also hellbanned
       query
     end
 
@@ -192,6 +200,6 @@ class Status < ApplicationRecord
   private
 
   def filter_from_context?(status, account)
-    account&.blocking?(status.account_id) || !status.permitted?(account)
+    account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
   end
 end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 77a73cce8..15625ca43 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -3,11 +3,31 @@
 class Tag < ApplicationRecord
   has_and_belongs_to_many :statuses
 
-  HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
+  HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
 
   validates :name, presence: true, uniqueness: true
 
   def to_param
     name
   end
+
+  class << self
+    def search_for(terms, limit = 5)
+      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
+      textsearch = 'to_tsvector(\'simple\', tags.name)'
+      query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')'
+
+      sql = <<SQL
+        SELECT
+          tags.*,
+          ts_rank_cd(#{textsearch}, #{query}) AS rank
+        FROM tags
+        WHERE #{query} @@ #{textsearch}
+        ORDER BY rank DESC
+        LIMIT ?
+SQL
+
+      Tag.find_by_sql([sql, limit])
+    end
+  end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 08aac2679..bf2916d90 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,9 +14,10 @@ class User < ApplicationRecord
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?'
   validates :email, email: true
 
-  scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
-  scope :recent,   -> { order('id desc') }
-  scope :admins,   -> { where(admin: true) }
+  scope :prolific,  -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
+  scope :recent,    -> { order('id desc') }
+  scope :admins,    -> { where(admin: true) }
+  scope :confirmed, -> { where.not(confirmed_at: nil) }
 
   def send_devise_notification(notification, *args)
     devise_mailer.send(notification, self, *args).deliver_later