Gab Social. All are welcome.
This commit is contained in:
67
app/models/concerns/account_associations.rb
Normal file
67
app/models/concerns/account_associations.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountAssociations
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Local users
|
||||
has_one :user, inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Identity proofs
|
||||
has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account
|
||||
|
||||
# Timelines
|
||||
has_many :stream_entries, inverse_of: :account, dependent: :destroy
|
||||
has_many :statuses, inverse_of: :account, dependent: :destroy
|
||||
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||
has_many :mentions, inverse_of: :account, dependent: :destroy
|
||||
has_many :notifications, inverse_of: :account, dependent: :destroy
|
||||
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account
|
||||
has_many :scheduled_statuses, inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Pinned statuses
|
||||
has_many :status_pins, inverse_of: :account, dependent: :destroy
|
||||
has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
|
||||
|
||||
# Endorsements
|
||||
has_many :account_pins, inverse_of: :account, dependent: :destroy
|
||||
has_many :endorsed_accounts, through: :account_pins, class_name: 'Account', source: :target_account
|
||||
|
||||
# Media
|
||||
has_many :media_attachments, dependent: :destroy
|
||||
has_many :polls, dependent: :destroy
|
||||
|
||||
# PuSH subscriptions
|
||||
has_many :subscriptions, dependent: :destroy
|
||||
|
||||
# Report relationships
|
||||
has_many :reports, dependent: :destroy, inverse_of: :account
|
||||
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
|
||||
|
||||
has_many :report_notes, dependent: :destroy
|
||||
has_many :custom_filters, inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Moderation notes
|
||||
has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
|
||||
has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
|
||||
has_many :account_warnings, dependent: :destroy, inverse_of: :account
|
||||
has_many :targeted_account_warnings, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
|
||||
|
||||
# Lists (that the account is on, not owned by the account)
|
||||
has_many :list_accounts, inverse_of: :account, dependent: :destroy
|
||||
has_many :lists, through: :list_accounts
|
||||
|
||||
# Lists (owned by the account)
|
||||
has_many :owned_lists, class_name: 'List', dependent: :destroy, inverse_of: :account
|
||||
|
||||
# Account migrations
|
||||
belongs_to :moved_to_account, class_name: 'Account', optional: true
|
||||
|
||||
# Hashtags
|
||||
has_and_belongs_to_many :tags
|
||||
has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
|
||||
|
||||
# Billing
|
||||
has_many :transactions, class_name: 'Transaction', dependent: :destroy, inverse_of: :account
|
||||
end
|
||||
end
|
||||
34
app/models/concerns/account_avatar.rb
Normal file
34
app/models/concerns/account_avatar.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountAvatar
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 2.megabytes
|
||||
|
||||
class_methods do
|
||||
def avatar_styles(file)
|
||||
styles = { original: { geometry: '400x400#', file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { geometry: '400x400#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
end
|
||||
|
||||
private :avatar_styles
|
||||
end
|
||||
|
||||
included do
|
||||
# Avatar upload
|
||||
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :avatar, less_than: LIMIT
|
||||
remotable_attachment :avatar, LIMIT
|
||||
end
|
||||
|
||||
def avatar_original_url
|
||||
avatar.url(:original)
|
||||
end
|
||||
|
||||
def avatar_static_url
|
||||
avatar_content_type == 'image/gif' ? avatar.url(:static) : avatar_original_url
|
||||
end
|
||||
end
|
||||
32
app/models/concerns/account_counters.rb
Normal file
32
app/models/concerns/account_counters.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountCounters
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :account_stat, inverse_of: :account
|
||||
after_save :save_account_stat
|
||||
end
|
||||
|
||||
delegate :statuses_count,
|
||||
:statuses_count=,
|
||||
:following_count,
|
||||
:following_count=,
|
||||
:followers_count,
|
||||
:followers_count=,
|
||||
:increment_count!,
|
||||
:decrement_count!,
|
||||
:last_status_at,
|
||||
to: :account_stat
|
||||
|
||||
def account_stat
|
||||
super || build_account_stat
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def save_account_stat
|
||||
return unless account_stat&.changed?
|
||||
account_stat.save
|
||||
end
|
||||
end
|
||||
66
app/models/concerns/account_finder_concern.rb
Normal file
66
app/models/concerns/account_finder_concern.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountFinderConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def find_local!(username)
|
||||
find_local(username) || raise(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
def find_remote!(username, domain)
|
||||
find_remote(username, domain) || raise(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
def representative
|
||||
find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) || Account.local.without_suspended.first
|
||||
end
|
||||
|
||||
def find_local(username)
|
||||
find_remote(username, nil)
|
||||
end
|
||||
|
||||
def find_remote(username, domain)
|
||||
AccountFinder.new(username, domain).account
|
||||
end
|
||||
end
|
||||
|
||||
class AccountFinder
|
||||
attr_reader :username, :domain
|
||||
|
||||
def initialize(username, domain)
|
||||
@username = username
|
||||
@domain = domain
|
||||
end
|
||||
|
||||
def account
|
||||
scoped_accounts.order(id: :asc).take
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scoped_accounts
|
||||
Account.unscoped.tap do |scope|
|
||||
scope.merge! with_usernames
|
||||
scope.merge! matching_username
|
||||
scope.merge! matching_domain
|
||||
end
|
||||
end
|
||||
|
||||
def with_usernames
|
||||
Account.where.not(username: '')
|
||||
end
|
||||
|
||||
def matching_username
|
||||
Account.where(Account.arel_table[:username].lower.eq username.to_s.downcase)
|
||||
end
|
||||
|
||||
def matching_domain
|
||||
if domain.nil?
|
||||
Account.where(domain: nil)
|
||||
else
|
||||
Account.where(Account.arel_table[:domain].lower.eq domain.to_s.downcase)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
35
app/models/concerns/account_header.rb
Normal file
35
app/models/concerns/account_header.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountHeader
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 2.megabytes
|
||||
MAX_PIXELS = 750_000 # 1500x500px
|
||||
|
||||
class_methods do
|
||||
def header_styles(file)
|
||||
styles = { original: { pixels: MAX_PIXELS, file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
end
|
||||
|
||||
private :header_styles
|
||||
end
|
||||
|
||||
included do
|
||||
# Header upload
|
||||
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :header, less_than: LIMIT
|
||||
remotable_attachment :header, LIMIT
|
||||
end
|
||||
|
||||
def header_original_url
|
||||
header.url(:original)
|
||||
end
|
||||
|
||||
def header_static_url
|
||||
header_content_type == 'image/gif' ? header.url(:static) : header_original_url
|
||||
end
|
||||
end
|
||||
218
app/models/concerns/account_interactions.rb
Normal file
218
app/models/concerns/account_interactions.rb
Normal file
@@ -0,0 +1,218 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountInteractions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def following_map(target_account_ids, account_id)
|
||||
Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping|
|
||||
mapping[follow.target_account_id] = {
|
||||
reblogs: follow.show_reblogs?,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def followed_by_map(target_account_ids, account_id)
|
||||
follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
|
||||
end
|
||||
|
||||
def blocking_map(target_account_ids, account_id)
|
||||
follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
|
||||
end
|
||||
|
||||
def blocked_by_map(target_account_ids, account_id)
|
||||
follow_mapping(Block.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
|
||||
end
|
||||
|
||||
def muting_map(target_account_ids, account_id)
|
||||
Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
|
||||
mapping[mute.target_account_id] = {
|
||||
notifications: mute.hide_notifications?,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def requested_map(target_account_ids, account_id)
|
||||
FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping|
|
||||
mapping[follow_request.target_account_id] = {
|
||||
reblogs: follow_request.show_reblogs?,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def endorsed_map(target_account_ids, account_id)
|
||||
follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
|
||||
end
|
||||
|
||||
def domain_blocking_map(target_account_ids, account_id)
|
||||
accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
|
||||
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
|
||||
accounts_map.reduce({}) { |h, (id, domain)| h.merge(id => blocked_domains[domain]) }
|
||||
end
|
||||
|
||||
def domain_blocking_map_by_domain(target_domains, account_id)
|
||||
follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def follow_mapping(query, field)
|
||||
query.pluck(field).each_with_object({}) { |id, mapping| mapping[id] = true }
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
# Follow relations
|
||||
has_many :follow_requests, dependent: :destroy
|
||||
|
||||
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
|
||||
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
|
||||
|
||||
has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
|
||||
has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
|
||||
|
||||
# Block relationships
|
||||
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
|
||||
has_many :blocked_by_relationships, class_name: 'Block', foreign_key: :target_account_id, dependent: :destroy
|
||||
has_many :blocked_by, -> { order('blocks.id desc') }, through: :blocked_by_relationships, source: :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
|
||||
has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy
|
||||
has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
|
||||
has_many :conversation_mutes, dependent: :destroy
|
||||
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
|
||||
end
|
||||
|
||||
def follow!(other_account, reblogs: nil, uri: nil)
|
||||
reblogs = true if reblogs.nil?
|
||||
|
||||
rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri)
|
||||
.find_or_create_by!(target_account: other_account)
|
||||
|
||||
rel.update!(show_reblogs: reblogs)
|
||||
remove_potential_friendship(other_account)
|
||||
|
||||
rel
|
||||
end
|
||||
|
||||
def block!(other_account, uri: nil)
|
||||
remove_potential_friendship(other_account)
|
||||
block_relationships.create_with(uri: uri)
|
||||
.find_or_create_by!(target_account: other_account)
|
||||
end
|
||||
|
||||
def mute!(other_account, notifications: nil)
|
||||
notifications = true if notifications.nil?
|
||||
mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
|
||||
remove_potential_friendship(other_account)
|
||||
|
||||
# When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
|
||||
if mute.hide_notifications? != notifications
|
||||
mute.update!(hide_notifications: notifications)
|
||||
end
|
||||
|
||||
mute
|
||||
end
|
||||
|
||||
def mute_conversation!(conversation)
|
||||
conversation_mutes.find_or_create_by!(conversation: conversation)
|
||||
end
|
||||
|
||||
def block_domain!(other_domain)
|
||||
domain_blocks.find_or_create_by!(domain: other_domain)
|
||||
end
|
||||
|
||||
def unfollow!(other_account)
|
||||
follow = active_relationships.find_by(target_account: other_account)
|
||||
follow&.destroy
|
||||
end
|
||||
|
||||
def unblock!(other_account)
|
||||
block = block_relationships.find_by(target_account: other_account)
|
||||
block&.destroy
|
||||
end
|
||||
|
||||
def unmute!(other_account)
|
||||
mute = mute_relationships.find_by(target_account: other_account)
|
||||
mute&.destroy
|
||||
end
|
||||
|
||||
def unmute_conversation!(conversation)
|
||||
mute = conversation_mutes.find_by(conversation: conversation)
|
||||
mute&.destroy!
|
||||
end
|
||||
|
||||
def unblock_domain!(other_domain)
|
||||
block = domain_blocks.find_by(domain: other_domain)
|
||||
block&.destroy
|
||||
end
|
||||
|
||||
def following?(other_account)
|
||||
active_relationships.where(target_account: other_account).exists?
|
||||
end
|
||||
|
||||
def blocking?(other_account)
|
||||
block_relationships.where(target_account: other_account).exists?
|
||||
end
|
||||
|
||||
def domain_blocking?(other_domain)
|
||||
domain_blocks.where(domain: other_domain).exists?
|
||||
end
|
||||
|
||||
def muting?(other_account)
|
||||
mute_relationships.where(target_account: other_account).exists?
|
||||
end
|
||||
|
||||
def muting_conversation?(conversation)
|
||||
conversation_mutes.where(conversation: conversation).exists?
|
||||
end
|
||||
|
||||
def muting_notifications?(other_account)
|
||||
mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
|
||||
end
|
||||
|
||||
def muting_reblogs?(other_account)
|
||||
active_relationships.where(target_account: other_account, show_reblogs: false).exists?
|
||||
end
|
||||
|
||||
def requested?(other_account)
|
||||
follow_requests.where(target_account: other_account).exists?
|
||||
end
|
||||
|
||||
def favourited?(status)
|
||||
status.proper.favourites.where(account: self).exists?
|
||||
end
|
||||
|
||||
def reblogged?(status)
|
||||
status.proper.reblogs.where(account: self).exists?
|
||||
end
|
||||
|
||||
def pinned?(status)
|
||||
status_pins.where(status: status).exists?
|
||||
end
|
||||
|
||||
def endorsed?(account)
|
||||
account_pins.where(target_account: account).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
|
||||
|
||||
private
|
||||
|
||||
def remove_potential_friendship(other_account, mutual = false)
|
||||
PotentialFriendshipTracker.remove(id, other_account.id)
|
||||
PotentialFriendshipTracker.remove(other_account.id, id) if mutual
|
||||
end
|
||||
end
|
||||
50
app/models/concerns/attachmentable.rb
Normal file
50
app/models/concerns/attachmentable.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'mime/types'
|
||||
|
||||
module Attachmentable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
MAX_MATRIX_LIMIT = 16_777_216 # 4096x4096px or approx. 16MB
|
||||
|
||||
included do
|
||||
before_post_process :set_file_extensions
|
||||
before_post_process :check_image_dimensions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_file_extensions
|
||||
self.class.attachment_definitions.each_key do |attachment_name|
|
||||
attachment = send(attachment_name)
|
||||
|
||||
next if attachment.blank?
|
||||
|
||||
attachment.instance_write :file_name, [Paperclip::Interpolations.basename(attachment, :original), appropriate_extension(attachment)].delete_if(&:blank?).join('.')
|
||||
end
|
||||
end
|
||||
|
||||
def check_image_dimensions
|
||||
self.class.attachment_definitions.each_key do |attachment_name|
|
||||
attachment = send(attachment_name)
|
||||
|
||||
next if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank?
|
||||
|
||||
width, height = FastImage.size(attachment.queued_for_write[:original].path)
|
||||
|
||||
raise GabSocial::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height >= MAX_MATRIX_LIMIT)
|
||||
end
|
||||
end
|
||||
|
||||
def appropriate_extension(attachment)
|
||||
mime_type = MIME::Types[attachment.content_type]
|
||||
|
||||
extensions_for_mime_type = mime_type.empty? ? [] : mime_type.first.extensions
|
||||
original_extension = Paperclip::Interpolations.extension(attachment, :original)
|
||||
proper_extension = extensions_for_mime_type.first.to_s
|
||||
extension = extensions_for_mime_type.include?(original_extension) ? original_extension : proper_extension
|
||||
extension = 'jpeg' if extension == 'jpe'
|
||||
|
||||
extension
|
||||
end
|
||||
end
|
||||
21
app/models/concerns/cacheable.rb
Normal file
21
app/models/concerns/cacheable.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Cacheable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
module ClassMethods
|
||||
@cache_associated = []
|
||||
|
||||
def cache_associated(*associations)
|
||||
@cache_associated = associations
|
||||
end
|
||||
|
||||
def with_includes
|
||||
includes(@cache_associated)
|
||||
end
|
||||
|
||||
def cache_ids
|
||||
select(:id, :updated_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
15
app/models/concerns/domain_normalizable.rb
Normal file
15
app/models/concerns/domain_normalizable.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DomainNormalizable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :normalize_domain
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_domain
|
||||
self.domain = TagManager.instance.normalize_domain(domain&.strip)
|
||||
end
|
||||
end
|
||||
28
app/models/concerns/expireable.rb
Normal file
28
app/models/concerns/expireable.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Expireable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
|
||||
|
||||
attr_reader :expires_in
|
||||
|
||||
def expires_in=(interval)
|
||||
self.expires_at = interval.to_i.seconds.from_now if interval.present?
|
||||
@expires_in = interval
|
||||
end
|
||||
|
||||
def expire!
|
||||
touch(:expires_at)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires? && expires_at < Time.now.utc
|
||||
end
|
||||
|
||||
def expires?
|
||||
!expires_at.nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
23
app/models/concerns/group_interactions.rb
Normal file
23
app/models/concerns/group_interactions.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module GroupInteractions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
|
||||
def member_map(target_group_ids, account_id)
|
||||
follow_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id), :group_id)
|
||||
end
|
||||
|
||||
def admin_map(target_group_ids, account_id)
|
||||
follow_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id, role: :admin), :group_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def follow_mapping(query, field)
|
||||
query.pluck(field).each_with_object({}) { |id, mapping| mapping[id] = true }
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
26
app/models/concerns/ldap_authenticable.rb
Normal file
26
app/models/concerns/ldap_authenticable.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LdapAuthenticable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def ldap_setup(_attributes)
|
||||
self.confirmed_at = Time.now.utc
|
||||
self.admin = false
|
||||
self.external = true
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def ldap_get_user(attributes = {})
|
||||
resource = joins(:account).find_by(accounts: { username: attributes[Devise.ldap_uid.to_sym].first })
|
||||
|
||||
if resource.blank?
|
||||
resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: attributes[Devise.ldap_uid.to_sym].first })
|
||||
resource.ldap_setup(attributes)
|
||||
end
|
||||
|
||||
resource
|
||||
end
|
||||
end
|
||||
end
|
||||
89
app/models/concerns/omniauthable.rb
Normal file
89
app/models/concerns/omniauthable.rb
Normal file
@@ -0,0 +1,89 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Omniauthable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TEMP_EMAIL_PREFIX = 'change@me'
|
||||
TEMP_EMAIL_REGEX = /\Achange@me/
|
||||
|
||||
included do
|
||||
devise :omniauthable
|
||||
|
||||
def omniauth_providers
|
||||
Devise.omniauth_configs.keys
|
||||
end
|
||||
|
||||
def email_verified?
|
||||
email && email !~ TEMP_EMAIL_REGEX
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def find_for_oauth(auth, signed_in_resource = nil)
|
||||
# EOLE-SSO Patch
|
||||
auth.uid = (auth.uid[0][:uid] || auth.uid[0][:user]) if auth.uid.is_a? Hashie::Array
|
||||
identity = Identity.find_for_oauth(auth)
|
||||
|
||||
# If a signed_in_resource is provided it always overrides the existing user
|
||||
# to prevent the identity being locked with accidentally created accounts.
|
||||
# Note that this may leave zombie accounts (with no associated identity) which
|
||||
# can be cleaned up at a later date.
|
||||
user = signed_in_resource || identity.user
|
||||
user = create_for_oauth(auth) if user.nil?
|
||||
|
||||
if identity.user.nil?
|
||||
identity.user = user
|
||||
identity.save!
|
||||
end
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def create_for_oauth(auth)
|
||||
# Check if the user exists with provided email if the provider gives us a
|
||||
# verified email. If no verified email was provided or the user already
|
||||
# exists, we assign a temporary email and ask the user to verify it on
|
||||
# the next step via Auth::ConfirmationsController.finish_signup
|
||||
|
||||
user = User.new(user_params_from_auth(auth))
|
||||
user.account.avatar_remote_url = auth.info.image if auth.info.image =~ /\A#{URI.regexp(%w(http https))}\z/
|
||||
user.skip_confirmation!
|
||||
user.save!
|
||||
user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params_from_auth(auth)
|
||||
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
|
||||
assume_verified = strategy.try(:security).try(:assume_email_is_verified)
|
||||
email_is_verified = auth.info.verified || auth.info.verified_email || assume_verified
|
||||
email = auth.info.verified_email || auth.info.email
|
||||
email = email_is_verified && !User.exists?(email: auth.info.email) && email
|
||||
display_name = auth.info.full_name || [auth.info.first_name, auth.info.last_name].join(' ')
|
||||
|
||||
{
|
||||
email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
|
||||
password: Devise.friendly_token[0, 20],
|
||||
agreement: true,
|
||||
external: true,
|
||||
account_attributes: {
|
||||
username: ensure_unique_username(auth.uid),
|
||||
display_name: display_name,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def ensure_unique_username(starting_username)
|
||||
username = starting_username
|
||||
i = 0
|
||||
|
||||
while Account.exists?(username: username)
|
||||
i += 1
|
||||
username = "#{starting_username}_#{i}"
|
||||
end
|
||||
|
||||
username
|
||||
end
|
||||
end
|
||||
end
|
||||
31
app/models/concerns/paginable.rb
Normal file
31
app/models/concerns/paginable.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Paginable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :paginate_by_max_id, ->(limit, max_id = nil, since_id = nil) {
|
||||
query = order(arel_table[:id].desc).limit(limit)
|
||||
query = query.where(arel_table[:id].lt(max_id)) if max_id.present?
|
||||
query = query.where(arel_table[:id].gt(since_id)) if since_id.present?
|
||||
query
|
||||
}
|
||||
|
||||
# Differs from :paginate_by_max_id in that it gives the results immediately following min_id,
|
||||
# whereas since_id gives the items with largest id, but with since_id as a cutoff.
|
||||
# Results will be in ascending order by id.
|
||||
scope :paginate_by_min_id, ->(limit, min_id = nil) {
|
||||
query = reorder(arel_table[:id]).limit(limit)
|
||||
query = query.where(arel_table[:id].gt(min_id)) if min_id.present?
|
||||
query
|
||||
}
|
||||
|
||||
scope :paginate_by_id, ->(limit, options = {}) {
|
||||
if options[:min_id].present?
|
||||
paginate_by_min_id(limit, options[:min_id]).reverse
|
||||
else
|
||||
paginate_by_max_id(limit, options[:max_id], options[:since_id])
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
69
app/models/concerns/pam_authenticable.rb
Normal file
69
app/models/concerns/pam_authenticable.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PamAuthenticable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
devise :pam_authenticatable if ENV['PAM_ENABLED'] == 'true'
|
||||
|
||||
def pam_conflict(_attributes)
|
||||
# Block pam login tries on traditional account
|
||||
end
|
||||
|
||||
def pam_conflict?
|
||||
if Devise.pam_authentication
|
||||
encrypted_password.present? && pam_managed_user?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def pam_get_name
|
||||
if account.present?
|
||||
account.username
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def pam_setup(_attributes)
|
||||
account = Account.new(username: pam_get_name)
|
||||
account.save!(validate: false)
|
||||
|
||||
self.email = "#{account.username}@#{find_pam_suffix}" if email.nil? && find_pam_suffix
|
||||
self.confirmed_at = Time.now.utc
|
||||
self.admin = false
|
||||
self.account = account
|
||||
self.external = true
|
||||
|
||||
account.destroy! unless save
|
||||
end
|
||||
|
||||
def self.pam_get_user(attributes = {})
|
||||
return nil unless attributes[:email]
|
||||
|
||||
resource = begin
|
||||
if Devise.check_at_sign && !attributes[:email].index('@')
|
||||
joins(:account).find_by(accounts: { username: attributes[:email] })
|
||||
else
|
||||
find_by(email: attributes[:email])
|
||||
end
|
||||
end
|
||||
|
||||
if resource.nil?
|
||||
resource = new(email: attributes[:email], agreement: true)
|
||||
|
||||
if Devise.check_at_sign && !resource[:email].index('@')
|
||||
resource[:email] = Rpam2.getenv(resource.find_pam_service, attributes[:email], attributes[:password], 'email', false)
|
||||
resource[:email] = "#{attributes[:email]}@#{resource.find_pam_suffix}" unless resource[:email]
|
||||
end
|
||||
end
|
||||
|
||||
resource
|
||||
end
|
||||
|
||||
def self.authenticate_with_pam(attributes = {})
|
||||
super if Devise.pam_authentication
|
||||
end
|
||||
end
|
||||
end
|
||||
11
app/models/concerns/redisable.rb
Normal file
11
app/models/concerns/redisable.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Redisable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def redis
|
||||
Redis.current
|
||||
end
|
||||
end
|
||||
16
app/models/concerns/relationship_cacheable.rb
Normal file
16
app/models/concerns/relationship_cacheable.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module RelationshipCacheable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :remove_relationship_cache
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_relationship_cache
|
||||
Rails.cache.delete("relationship:#{account_id}:#{target_account_id}")
|
||||
Rails.cache.delete("relationship:#{target_account_id}:#{account_id}")
|
||||
end
|
||||
end
|
||||
85
app/models/concerns/remotable.rb
Normal file
85
app/models/concerns/remotable.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Remotable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def remotable_attachment(attachment_name, limit)
|
||||
attribute_name = "#{attachment_name}_remote_url".to_sym
|
||||
method_name = "#{attribute_name}=".to_sym
|
||||
alt_method_name = "reset_#{attachment_name}!".to_sym
|
||||
|
||||
define_method method_name do |url|
|
||||
return if url.blank?
|
||||
|
||||
begin
|
||||
parsed_url = Addressable::URI.parse(url).normalize
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
return
|
||||
end
|
||||
|
||||
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? || self[attribute_name] == url
|
||||
|
||||
begin
|
||||
Request.new(:get, url).perform do |response|
|
||||
next if response.code != 200
|
||||
|
||||
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)
|
||||
|
||||
send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
|
||||
send("#{attachment_name}_file_name=", basename + extname)
|
||||
|
||||
self[attribute_name] = url if has_attribute?(attribute_name)
|
||||
end
|
||||
rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, GabSocial::HostValidationError, GabSocial::LengthValidationError => e
|
||||
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
||||
nil
|
||||
rescue Paperclip::Error, GabSocial::DimensionsValidationError => e
|
||||
Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
define_method alt_method_name do
|
||||
url = self[attribute_name]
|
||||
|
||||
return if url.blank?
|
||||
|
||||
self[attribute_name] = ''
|
||||
send(method_name, url)
|
||||
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
|
||||
136
app/models/concerns/status_threading_concern.rb
Normal file
136
app/models/concerns/status_threading_concern.rb
Normal file
@@ -0,0 +1,136 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module StatusThreadingConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def ancestors(limit, account = nil)
|
||||
find_statuses_from_tree_path(ancestor_ids(limit), account)
|
||||
end
|
||||
|
||||
def descendants(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil)
|
||||
find_statuses_from_tree_path(descendant_ids(limit, max_child_id, since_child_id, depth), account, promote: true)
|
||||
end
|
||||
|
||||
def self_replies(limit)
|
||||
account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted]).reorder(id: :asc).limit(limit)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ancestor_ids(limit)
|
||||
key = "ancestors:#{id}"
|
||||
ancestors = Rails.cache.fetch(key)
|
||||
|
||||
if ancestors.nil? || ancestors[:limit] < limit
|
||||
ids = ancestor_statuses(limit).pluck(:id).reverse!
|
||||
Rails.cache.write key, limit: limit, ids: ids
|
||||
ids
|
||||
else
|
||||
ancestors[:ids].last(limit)
|
||||
end
|
||||
end
|
||||
|
||||
def ancestor_statuses(limit)
|
||||
Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: limit])
|
||||
WITH RECURSIVE search_tree(id, in_reply_to_id, path)
|
||||
AS (
|
||||
SELECT id, in_reply_to_id, ARRAY[id]
|
||||
FROM statuses
|
||||
WHERE id = :id
|
||||
UNION ALL
|
||||
SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id
|
||||
FROM search_tree
|
||||
JOIN statuses ON statuses.id = search_tree.in_reply_to_id
|
||||
WHERE NOT statuses.id = ANY(path)
|
||||
)
|
||||
SELECT id
|
||||
FROM search_tree
|
||||
ORDER BY path
|
||||
LIMIT :limit
|
||||
SQL
|
||||
end
|
||||
|
||||
def descendant_ids(limit, max_child_id, since_child_id, depth)
|
||||
descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id)
|
||||
end
|
||||
|
||||
def descendant_statuses(limit, max_child_id, since_child_id, 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 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
|
||||
JOIN statuses ON statuses.in_reply_to_id = search_tree.id
|
||||
WHERE COALESCE(array_length(path, 1) < :depth, TRUE) AND NOT statuses.id = ANY(path)
|
||||
)
|
||||
SELECT id
|
||||
FROM search_tree
|
||||
ORDER BY path
|
||||
LIMIT :limit
|
||||
SQL
|
||||
|
||||
descendants_with_self - [self]
|
||||
end
|
||||
|
||||
def find_statuses_from_tree_path(ids, account, promote: false)
|
||||
statuses = statuses_with_accounts(ids).to_a
|
||||
account_ids = statuses.map(&:account_id).uniq
|
||||
domains = statuses.map(&:account_domain).compact.uniq
|
||||
relations = relations_map_for_account(account, account_ids, domains)
|
||||
|
||||
statuses.reject! { |status| filter_from_context?(status, account, relations) }
|
||||
|
||||
# Order ancestors/descendants by tree path
|
||||
statuses.sort_by! { |status| ids.index(status.id) }
|
||||
|
||||
# Bring self-replies to the top
|
||||
if promote
|
||||
promote_by!(statuses) { |status| status.in_reply_to_account_id == status.account_id }
|
||||
else
|
||||
statuses
|
||||
end
|
||||
end
|
||||
|
||||
def promote_by!(arr)
|
||||
insert_at = arr.find_index { |item| !yield(item) }
|
||||
|
||||
return arr if insert_at.nil?
|
||||
|
||||
arr.each_with_index do |item, index|
|
||||
next if index <= insert_at || !yield(item)
|
||||
|
||||
arr.insert(insert_at, arr.delete_at(index))
|
||||
insert_at += 1
|
||||
end
|
||||
|
||||
arr
|
||||
end
|
||||
|
||||
def relations_map_for_account(account, account_ids, domains)
|
||||
return {} if account.nil?
|
||||
|
||||
{
|
||||
blocking: Account.blocking_map(account_ids, account.id),
|
||||
blocked_by: Account.blocked_by_map(account_ids, account.id),
|
||||
muting: Account.muting_map(account_ids, account.id),
|
||||
following: Account.following_map(account_ids, account.id),
|
||||
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
|
||||
}
|
||||
end
|
||||
|
||||
def statuses_with_accounts(ids)
|
||||
Status.where(id: ids).includes(:account)
|
||||
end
|
||||
|
||||
def filter_from_context?(status, account, relations)
|
||||
StatusFilter.new(status, account, relations).filtered?
|
||||
end
|
||||
end
|
||||
43
app/models/concerns/streamable.rb
Normal file
43
app/models/concerns/streamable.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Streamable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :stream_entry, as: :activity
|
||||
|
||||
after_create do
|
||||
account.stream_entries.create!(activity: self, hidden: hidden?) if needs_stream_entry?
|
||||
end
|
||||
end
|
||||
|
||||
def title
|
||||
super
|
||||
end
|
||||
|
||||
def content
|
||||
title
|
||||
end
|
||||
|
||||
def target
|
||||
super
|
||||
end
|
||||
|
||||
def object_type
|
||||
:activity
|
||||
end
|
||||
|
||||
def thread
|
||||
super
|
||||
end
|
||||
|
||||
def hidden?
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def needs_stream_entry?
|
||||
account.local?
|
||||
end
|
||||
end
|
||||
54
app/models/concerns/user_roles.rb
Normal file
54
app/models/concerns/user_roles.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module UserRoles
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :admins, -> { where(admin: true) }
|
||||
scope :moderators, -> { where(moderator: true) }
|
||||
scope :staff, -> { admins.or(moderators) }
|
||||
end
|
||||
|
||||
def staff?
|
||||
admin? || moderator?
|
||||
end
|
||||
|
||||
def role
|
||||
if admin?
|
||||
'admin'
|
||||
elsif moderator?
|
||||
'moderator'
|
||||
else
|
||||
'user'
|
||||
end
|
||||
end
|
||||
|
||||
def role?(role)
|
||||
case role
|
||||
when 'user'
|
||||
true
|
||||
when 'moderator'
|
||||
staff?
|
||||
when 'admin'
|
||||
admin?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def promote!
|
||||
if moderator?
|
||||
update!(moderator: false, admin: true)
|
||||
elsif !admin?
|
||||
update!(moderator: true)
|
||||
end
|
||||
end
|
||||
|
||||
def demote!
|
||||
if admin?
|
||||
update!(admin: false, moderator: true)
|
||||
elsif moderator?
|
||||
update!(moderator: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user