diff options
Diffstat (limited to 'app/models')
-rw-r--r-- | app/models/account.rb | 101 | ||||
-rw-r--r-- | app/models/domain_block.rb | 2 | ||||
-rw-r--r-- | app/models/favourite.rb | 2 | ||||
-rw-r--r-- | app/models/follow.rb | 4 | ||||
-rw-r--r-- | app/models/import.rb | 14 | ||||
-rw-r--r-- | app/models/media_attachment.rb | 73 | ||||
-rw-r--r-- | app/models/mute.rb | 11 | ||||
-rw-r--r-- | app/models/setting.rb | 1 | ||||
-rw-r--r-- | app/models/status.rb | 48 | ||||
-rw-r--r-- | app/models/tag.rb | 22 | ||||
-rw-r--r-- | app/models/user.rb | 7 |
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 |