Gab Social. All are welcome.
This commit is contained in:
538
app/models/account.rb
Normal file
538
app/models/account.rb
Normal file
@@ -0,0 +1,538 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: accounts
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# username :string default(""), not null
|
||||
# domain :string
|
||||
# secret :string default(""), not null
|
||||
# private_key :text
|
||||
# public_key :text default(""), not null
|
||||
# remote_url :string default(""), not null
|
||||
# salmon_url :string default(""), not null
|
||||
# hub_url :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# note :text default(""), not null
|
||||
# display_name :string default(""), not null
|
||||
# uri :string default(""), not null
|
||||
# url :string
|
||||
# avatar_file_name :string
|
||||
# avatar_content_type :string
|
||||
# avatar_file_size :integer
|
||||
# avatar_updated_at :datetime
|
||||
# header_file_name :string
|
||||
# header_content_type :string
|
||||
# header_file_size :integer
|
||||
# header_updated_at :datetime
|
||||
# avatar_remote_url :string
|
||||
# subscription_expires_at :datetime
|
||||
# locked :boolean default(FALSE), not null
|
||||
# header_remote_url :string default(""), not null
|
||||
# last_webfingered_at :datetime
|
||||
# inbox_url :string default(""), not null
|
||||
# outbox_url :string default(""), not null
|
||||
# shared_inbox_url :string default(""), not null
|
||||
# followers_url :string default(""), not null
|
||||
# protocol :integer default("ostatus"), not null
|
||||
# memorial :boolean default(FALSE), not null
|
||||
# moved_to_account_id :bigint(8)
|
||||
# featured_collection_url :string
|
||||
# fields :jsonb
|
||||
# actor_type :string
|
||||
# discoverable :boolean
|
||||
# also_known_as :string is an Array
|
||||
# silenced_at :datetime
|
||||
# suspended_at :datetime
|
||||
# is_pro :boolean default(FALSE), not null
|
||||
# pro_expires_at :datetime
|
||||
# is_verified :boolean default(FALSE), not null
|
||||
# is_donor :boolean default(FALSE), not null
|
||||
# is_investor :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class Account < ApplicationRecord
|
||||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
|
||||
MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
|
||||
MIN_FOLLOWERS_DISCOVERY = 10
|
||||
|
||||
include AccountAssociations
|
||||
include AccountAvatar
|
||||
include AccountFinderConcern
|
||||
include AccountHeader
|
||||
include AccountInteractions
|
||||
include Attachmentable
|
||||
include Paginable
|
||||
include AccountCounters
|
||||
include DomainNormalizable
|
||||
|
||||
enum protocol: [:ostatus, :activitypub]
|
||||
|
||||
validates :username, presence: true
|
||||
|
||||
# Remote user validations
|
||||
validates :username, uniqueness: { scope: :domain, case_sensitive: true }, if: -> { !local? && will_save_change_to_username? }
|
||||
validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? }
|
||||
|
||||
# Local user validations
|
||||
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
|
||||
validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
|
||||
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
|
||||
validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
|
||||
validates :note, note_length: { maximum: 500 }, if: -> { local? && will_save_change_to_note? }
|
||||
validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
|
||||
|
||||
scope :remote, -> { where.not(domain: nil) }
|
||||
scope :local, -> { where(domain: nil) }
|
||||
scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
|
||||
scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
|
||||
scope :silenced, -> { where.not(silenced_at: nil) }
|
||||
scope :suspended, -> { where.not(suspended_at: nil) }
|
||||
scope :without_suspended, -> { where(suspended_at: nil) }
|
||||
scope :without_silenced, -> { where(silenced_at: nil) }
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :bots, -> { where(actor_type: %w(Application Service)) }
|
||||
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
||||
scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
|
||||
scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
|
||||
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
|
||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) }
|
||||
scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
|
||||
scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) }
|
||||
scope :popular, -> { order('account_stats.followers_count desc') }
|
||||
|
||||
delegate :email,
|
||||
:unconfirmed_email,
|
||||
:current_sign_in_ip,
|
||||
:current_sign_in_at,
|
||||
:confirmed?,
|
||||
:approved?,
|
||||
:pending?,
|
||||
:admin?,
|
||||
:moderator?,
|
||||
:staff?,
|
||||
:locale,
|
||||
:hides_network?,
|
||||
:shows_application?,
|
||||
to: :user,
|
||||
prefix: true,
|
||||
allow_nil: true
|
||||
|
||||
delegate :chosen_languages, to: :user, prefix: false, allow_nil: true
|
||||
|
||||
def local?
|
||||
domain.nil?
|
||||
end
|
||||
|
||||
def moved?
|
||||
moved_to_account_id.present?
|
||||
end
|
||||
|
||||
def bot?
|
||||
%w(Application Service).include? actor_type
|
||||
end
|
||||
|
||||
alias bot bot?
|
||||
|
||||
def bot=(val)
|
||||
self.actor_type = ActiveModel::Type::Boolean.new.cast(val) ? 'Service' : 'Person'
|
||||
end
|
||||
|
||||
def acct
|
||||
local? ? username : "#{username}@#{domain}"
|
||||
end
|
||||
|
||||
def local_username_and_domain
|
||||
"#{username}@#{Rails.configuration.x.local_domain}"
|
||||
end
|
||||
|
||||
def local_followers_count
|
||||
Follow.where(target_account_id: id).count
|
||||
end
|
||||
|
||||
def to_webfinger_s
|
||||
"acct:#{local_username_and_domain}"
|
||||
end
|
||||
|
||||
def subscribed?
|
||||
subscription_expires_at.present?
|
||||
end
|
||||
|
||||
def possibly_stale?
|
||||
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
||||
end
|
||||
|
||||
def refresh!
|
||||
return if local?
|
||||
ResolveAccountService.new.call(acct)
|
||||
end
|
||||
|
||||
def silenced?
|
||||
silenced_at.present?
|
||||
end
|
||||
|
||||
def silence!(date = nil)
|
||||
date ||= Time.now.utc
|
||||
update!(silenced_at: date)
|
||||
end
|
||||
|
||||
def unsilence!
|
||||
update!(silenced_at: nil)
|
||||
end
|
||||
|
||||
def suspended?
|
||||
suspended_at.present?
|
||||
end
|
||||
|
||||
def suspend!(date = nil)
|
||||
date ||= Time.now.utc
|
||||
transaction do
|
||||
user&.disable! if local?
|
||||
update!(suspended_at: date)
|
||||
end
|
||||
end
|
||||
|
||||
def unsuspend!
|
||||
transaction do
|
||||
user&.enable! if local?
|
||||
update!(suspended_at: nil)
|
||||
end
|
||||
end
|
||||
|
||||
def memorialize!
|
||||
transaction do
|
||||
user&.disable! if local?
|
||||
update!(memorial: true)
|
||||
end
|
||||
end
|
||||
|
||||
def keypair
|
||||
@keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
|
||||
end
|
||||
|
||||
def tags_as_strings=(tag_names)
|
||||
tag_names.map! { |name| name.mb_chars.downcase.to_s }
|
||||
tag_names.uniq!
|
||||
|
||||
# Existing hashtags
|
||||
hashtags_map = Tag.where(name: tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag }
|
||||
|
||||
# Initialize not yet existing hashtags
|
||||
tag_names.each do |name|
|
||||
next if hashtags_map.key?(name)
|
||||
hashtags_map[name] = Tag.new(name: name)
|
||||
end
|
||||
|
||||
# Remove hashtags that are to be deleted
|
||||
tags.each do |tag|
|
||||
if hashtags_map.key?(tag.name)
|
||||
hashtags_map.delete(tag.name)
|
||||
else
|
||||
transaction do
|
||||
tags.delete(tag)
|
||||
tag.decrement_count!(:accounts_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add hashtags that were so far missing
|
||||
hashtags_map.each_value do |tag|
|
||||
transaction do
|
||||
tags << tag
|
||||
tag.increment_count!(:accounts_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def also_known_as
|
||||
self[:also_known_as] || []
|
||||
end
|
||||
|
||||
def fields
|
||||
(self[:fields] || []).map { |f| Field.new(self, f) }
|
||||
end
|
||||
|
||||
def fields_attributes=(attributes)
|
||||
fields = []
|
||||
old_fields = self[:fields] || []
|
||||
old_fields = [] if old_fields.is_a?(Hash)
|
||||
|
||||
if attributes.is_a?(Hash)
|
||||
attributes.each_value do |attr|
|
||||
next if attr[:name].blank?
|
||||
|
||||
previous = old_fields.find { |item| item['value'] == attr[:value] }
|
||||
|
||||
if previous && previous['verified_at'].present?
|
||||
attr[:verified_at] = previous['verified_at']
|
||||
end
|
||||
|
||||
fields << attr
|
||||
end
|
||||
end
|
||||
|
||||
self[:fields] = fields
|
||||
end
|
||||
|
||||
DEFAULT_FIELDS_SIZE = 4
|
||||
|
||||
def build_fields
|
||||
return if fields.size >= DEFAULT_FIELDS_SIZE
|
||||
|
||||
tmp = self[:fields] || []
|
||||
tmp = [] if tmp.is_a?(Hash)
|
||||
|
||||
(DEFAULT_FIELDS_SIZE - tmp.size).times do
|
||||
tmp << { name: '', value: '' }
|
||||
end
|
||||
|
||||
self.fields = tmp
|
||||
end
|
||||
|
||||
def magic_key
|
||||
modulus, exponent = [keypair.public_key.n, keypair.public_key.e].map do |component|
|
||||
result = []
|
||||
|
||||
until component.zero?
|
||||
result << [component % 256].pack('C')
|
||||
component >>= 8
|
||||
end
|
||||
|
||||
result.reverse.join
|
||||
end
|
||||
|
||||
(['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.')
|
||||
end
|
||||
|
||||
def subscription(webhook_url)
|
||||
@subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
|
||||
end
|
||||
|
||||
def save_with_optional_media!
|
||||
save!
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
self.avatar = nil
|
||||
self.header = nil
|
||||
self[:avatar_remote_url] = ''
|
||||
self[:header_remote_url] = ''
|
||||
save!
|
||||
end
|
||||
|
||||
def object_type
|
||||
:person
|
||||
end
|
||||
|
||||
def to_param
|
||||
username
|
||||
end
|
||||
|
||||
def excluded_from_timeline_account_ids
|
||||
Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
|
||||
end
|
||||
|
||||
def excluded_from_timeline_domains
|
||||
Rails.cache.fetch("exclude_domains_for:#{id}") { domain_blocks.pluck(:domain) }
|
||||
end
|
||||
|
||||
def preferred_inbox_url
|
||||
shared_inbox_url.presence || inbox_url
|
||||
end
|
||||
|
||||
class Field < ActiveModelSerializers::Model
|
||||
attributes :name, :value, :verified_at, :account, :errors
|
||||
|
||||
def initialize(account, attributes)
|
||||
@account = account
|
||||
@attributes = attributes
|
||||
@name = attributes['name'].strip[0, string_limit]
|
||||
@value = attributes['value'].strip[0, string_limit]
|
||||
@verified_at = attributes['verified_at']&.to_datetime
|
||||
@errors = {}
|
||||
end
|
||||
|
||||
def verified?
|
||||
verified_at.present?
|
||||
end
|
||||
|
||||
def value_for_verification
|
||||
@value_for_verification ||= begin
|
||||
if account.local?
|
||||
value
|
||||
else
|
||||
ActionController::Base.helpers.strip_tags(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def verifiable?
|
||||
value_for_verification.present? && value_for_verification.start_with?('http://', 'https://')
|
||||
end
|
||||
|
||||
def mark_verified!
|
||||
@verified_at = Time.now.utc
|
||||
@attributes['verified_at'] = @verified_at
|
||||
end
|
||||
|
||||
def to_h
|
||||
{ name: @name, value: @value, verified_at: @verified_at }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def string_limit
|
||||
if account.local?
|
||||
255
|
||||
else
|
||||
2047
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def readonly_attributes
|
||||
super - %w(statuses_count following_count followers_count)
|
||||
end
|
||||
|
||||
def domains
|
||||
reorder(nil).pluck(Arel.sql('distinct accounts.domain'))
|
||||
end
|
||||
|
||||
def inboxes
|
||||
urls = reorder(nil).where(protocol: :activitypub).pluck(Arel.sql("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)"))
|
||||
DeliveryFailureTracker.filter(urls)
|
||||
end
|
||||
|
||||
def search_for(terms, limit = 10, offset = 0)
|
||||
textsearch, query = generate_query_for_search(terms)
|
||||
|
||||
sql = <<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
|
||||
FROM accounts
|
||||
WHERE #{query} @@ #{textsearch}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, limit, offset])
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||
records
|
||||
end
|
||||
|
||||
def advanced_search_for(terms, account, limit = 10, following = false, offset = 0)
|
||||
textsearch, query = generate_query_for_search(terms)
|
||||
|
||||
if following
|
||||
sql = <<-SQL.squish
|
||||
WITH first_degree AS (
|
||||
SELECT target_account_id
|
||||
FROM follows
|
||||
WHERE account_id = ?
|
||||
)
|
||||
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 accounts.id IN (SELECT * FROM first_degree)
|
||||
AND #{query} @@ #{textsearch}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, account.id, account.id, account.id, limit, offset])
|
||||
else
|
||||
sql = <<-SQL.squish
|
||||
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}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, account.id, account.id, limit, offset])
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||
records
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_query_for_search(terms)
|
||||
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} || ' ''' || ':*')"
|
||||
|
||||
[textsearch, query]
|
||||
end
|
||||
end
|
||||
|
||||
def emojis
|
||||
@emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
|
||||
end
|
||||
|
||||
before_create :generate_keys
|
||||
before_validation :prepare_contents, if: :local?
|
||||
before_validation :prepare_username, on: :create
|
||||
before_destroy :clean_feed_manager
|
||||
|
||||
private
|
||||
|
||||
def prepare_contents
|
||||
display_name&.strip!
|
||||
note&.strip!
|
||||
end
|
||||
|
||||
def prepare_username
|
||||
username&.squish!
|
||||
end
|
||||
|
||||
def generate_keys
|
||||
return unless local? && !Rails.env.test?
|
||||
|
||||
keypair = OpenSSL::PKey::RSA.new(2048)
|
||||
self.private_key = keypair.to_pem
|
||||
self.public_key = keypair.public_key.to_pem
|
||||
end
|
||||
|
||||
def normalize_domain
|
||||
return if local?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def emojifiable_text
|
||||
[note, display_name, fields.map(&:value)].join(' ')
|
||||
end
|
||||
|
||||
def clean_feed_manager
|
||||
reblog_key = FeedManager.instance.key(:home, id, 'reblogs')
|
||||
reblogged_id_set = Redis.current.zrange(reblog_key, 0, -1)
|
||||
|
||||
Redis.current.pipelined do
|
||||
Redis.current.del(FeedManager.instance.key(:home, id))
|
||||
Redis.current.del(reblog_key)
|
||||
|
||||
reblogged_id_set.each do |reblogged_id|
|
||||
reblog_set_key = FeedManager.instance.key(:home, id, "reblogs:#{reblogged_id}")
|
||||
Redis.current.del(reblog_set_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
117
app/models/account_conversation.rb
Normal file
117
app/models/account_conversation.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_conversations
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# conversation_id :bigint(8)
|
||||
# participant_account_ids :bigint(8) default([]), not null, is an Array
|
||||
# status_ids :bigint(8) default([]), not null, is an Array
|
||||
# last_status_id :bigint(8)
|
||||
# lock_version :integer default(0), not null
|
||||
# unread :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class AccountConversation < ApplicationRecord
|
||||
after_commit :push_to_streaming_api
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :conversation
|
||||
belongs_to :last_status, class_name: 'Status'
|
||||
|
||||
before_validation :set_last_status
|
||||
|
||||
def participant_account_ids=(arr)
|
||||
self[:participant_account_ids] = arr.sort
|
||||
end
|
||||
|
||||
def participant_accounts
|
||||
if participant_account_ids.empty?
|
||||
[account]
|
||||
else
|
||||
participants = Account.where(id: participant_account_ids)
|
||||
participants.empty? ? [account] : participants
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def paginate_by_id(limit, options = {})
|
||||
if options[:min_id]
|
||||
paginate_by_min_id(limit, options[:min_id]).reverse
|
||||
else
|
||||
paginate_by_max_id(limit, options[:max_id], options[:since_id])
|
||||
end
|
||||
end
|
||||
|
||||
def paginate_by_min_id(limit, min_id = nil)
|
||||
query = order(arel_table[:last_status_id].asc).limit(limit)
|
||||
query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present?
|
||||
query
|
||||
end
|
||||
|
||||
def paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
||||
query = order(arel_table[:last_status_id].desc).limit(limit)
|
||||
query = query.where(arel_table[:last_status_id].lt(max_id)) if max_id.present?
|
||||
query = query.where(arel_table[:last_status_id].gt(since_id)) if since_id.present?
|
||||
query
|
||||
end
|
||||
|
||||
def add_status(recipient, status)
|
||||
conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
|
||||
|
||||
return conversation if conversation.status_ids.include?(status.id)
|
||||
|
||||
conversation.status_ids << status.id
|
||||
conversation.unread = status.account_id != recipient.id
|
||||
conversation.save
|
||||
conversation
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
retry
|
||||
end
|
||||
|
||||
def remove_status(recipient, status)
|
||||
conversation = find_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
|
||||
|
||||
return if conversation.nil?
|
||||
|
||||
conversation.status_ids.delete(status.id)
|
||||
|
||||
if conversation.status_ids.empty?
|
||||
conversation.destroy
|
||||
else
|
||||
conversation.save
|
||||
end
|
||||
|
||||
conversation
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
retry
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def participants_from_status(recipient, status)
|
||||
((status.active_mentions.pluck(:account_id) + [status.account_id]).uniq - [recipient.id]).sort
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_last_status
|
||||
self.status_ids = status_ids.sort
|
||||
self.last_status_id = status_ids.last
|
||||
end
|
||||
|
||||
def push_to_streaming_api
|
||||
return if destroyed? || !subscribed_to_timeline?
|
||||
PushConversationWorker.perform_async(id)
|
||||
end
|
||||
|
||||
def subscribed_to_timeline?
|
||||
Redis.current.exists("subscribed:#{streaming_channel}")
|
||||
end
|
||||
|
||||
def streaming_channel
|
||||
"timeline:direct:#{account_id}"
|
||||
end
|
||||
end
|
||||
32
app/models/account_domain_block.rb
Normal file
32
app/models/account_domain_block.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_domain_blocks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# domain :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8)
|
||||
#
|
||||
|
||||
class AccountDomainBlock < ApplicationRecord
|
||||
include Paginable
|
||||
include DomainNormalizable
|
||||
|
||||
belongs_to :account
|
||||
validates :domain, presence: true, uniqueness: { scope: :account_id }
|
||||
|
||||
after_commit :remove_blocking_cache
|
||||
after_commit :remove_relationship_cache
|
||||
|
||||
private
|
||||
|
||||
def remove_blocking_cache
|
||||
Rails.cache.delete("exclude_domains_for:#{account_id}")
|
||||
end
|
||||
|
||||
def remove_relationship_cache
|
||||
Rails.cache.delete_matched("relationship:#{account_id}:*")
|
||||
end
|
||||
end
|
||||
68
app/models/account_filter.rb
Normal file
68
app/models/account_filter.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountFilter
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
set_defaults!
|
||||
end
|
||||
|
||||
def results
|
||||
scope = Account.recent.includes(:user)
|
||||
|
||||
params.each do |key, value|
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_defaults!
|
||||
params['local'] = '1' if params['remote'].blank?
|
||||
params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? && params['pending'].blank?
|
||||
end
|
||||
|
||||
def scope_for(key, value)
|
||||
case key.to_s
|
||||
when 'local'
|
||||
Account.local
|
||||
when 'remote'
|
||||
Account.remote
|
||||
when 'by_domain'
|
||||
Account.where(domain: value)
|
||||
when 'active'
|
||||
Account.without_suspended
|
||||
when 'pending'
|
||||
accounts_with_users.merge User.pending
|
||||
when 'silenced'
|
||||
Account.silenced
|
||||
when 'suspended'
|
||||
Account.suspended
|
||||
when 'username'
|
||||
Account.matches_username(value)
|
||||
when 'display_name'
|
||||
Account.matches_display_name(value)
|
||||
when 'email'
|
||||
accounts_with_users.merge User.matches_email(value)
|
||||
when 'ip'
|
||||
valid_ip?(value) ? accounts_with_users.where('users.current_sign_in_ip <<= ?', value) : Account.none
|
||||
when 'staff'
|
||||
accounts_with_users.merge User.staff
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
def accounts_with_users
|
||||
Account.joins(:user)
|
||||
end
|
||||
|
||||
def valid_ip?(value)
|
||||
IPAddr.new(value) && true
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
end
|
||||
46
app/models/account_identity_proof.rb
Normal file
46
app/models/account_identity_proof.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_identity_proofs
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# provider :string default(""), not null
|
||||
# provider_username :string default(""), not null
|
||||
# token :text default(""), not null
|
||||
# verified :boolean default(FALSE), not null
|
||||
# live :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AccountIdentityProof < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validates :provider, inclusion: { in: ProofProvider::SUPPORTED_PROVIDERS }
|
||||
validates :provider_username, format: { with: /\A[a-z0-9_]+\z/i }, length: { minimum: 2, maximum: 30 }
|
||||
validates :provider_username, uniqueness: { scope: [:account_id, :provider] }
|
||||
validates :token, format: { with: /\A[a-f0-9]+\z/ }, length: { maximum: 66 }
|
||||
|
||||
validate :validate_with_provider, if: :token_changed?
|
||||
|
||||
scope :active, -> { where(verified: true, live: true) }
|
||||
|
||||
after_commit :queue_worker, if: :saved_change_to_token?
|
||||
|
||||
delegate :refresh!, :on_success_path, :badge, to: :provider_instance
|
||||
|
||||
def provider_instance
|
||||
@provider_instance ||= ProofProvider.find(provider, self)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def queue_worker
|
||||
provider_instance.worker_class.perform_async(id)
|
||||
end
|
||||
|
||||
def validate_with_provider
|
||||
provider_instance.validate!
|
||||
end
|
||||
end
|
||||
21
app/models/account_moderation_note.rb
Normal file
21
app/models/account_moderation_note.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_moderation_notes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# content :text not null
|
||||
# account_id :bigint(8) not null
|
||||
# target_account_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AccountModerationNote < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
scope :latest, -> { reorder('created_at DESC') }
|
||||
|
||||
validates :content, presence: true, length: { maximum: 500 }
|
||||
end
|
||||
27
app/models/account_pin.rb
Normal file
27
app/models/account_pin.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_pins
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# target_account_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AccountPin < ApplicationRecord
|
||||
include Paginable
|
||||
include RelationshipCacheable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
validate :validate_follow_relationship
|
||||
|
||||
private
|
||||
|
||||
def validate_follow_relationship
|
||||
errors.add(:base, I18n.t('accounts.pin_errors.following')) unless account.following?(target_account)
|
||||
end
|
||||
end
|
||||
34
app/models/account_stat.rb
Normal file
34
app/models/account_stat.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_stats
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# statuses_count :bigint(8) default(0), not null
|
||||
# following_count :bigint(8) default(0), not null
|
||||
# followers_count :bigint(8) default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# last_status_at :datetime
|
||||
#
|
||||
|
||||
class AccountStat < ApplicationRecord
|
||||
belongs_to :account, inverse_of: :account_stat
|
||||
|
||||
def increment_count!(key)
|
||||
update(attributes_for_increment(key))
|
||||
end
|
||||
|
||||
def decrement_count!(key)
|
||||
update(key => [public_send(key) - 1, 0].max)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attributes_for_increment(key)
|
||||
attrs = { key => public_send(key) + 1 }
|
||||
attrs[:last_status_at] = Time.now.utc if key == :statuses_count
|
||||
attrs
|
||||
end
|
||||
end
|
||||
24
app/models/account_tag_stat.rb
Normal file
24
app/models/account_tag_stat.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_tag_stats
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# tag_id :bigint(8) not null
|
||||
# accounts_count :bigint(8) default(0), not null
|
||||
# hidden :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AccountTagStat < ApplicationRecord
|
||||
belongs_to :tag, inverse_of: :account_tag_stat
|
||||
|
||||
def increment_count!(key)
|
||||
update(key => public_send(key) + 1)
|
||||
end
|
||||
|
||||
def decrement_count!(key)
|
||||
update(key => [public_send(key) - 1, 0].max)
|
||||
end
|
||||
end
|
||||
26
app/models/account_verification_request.rb
Normal file
26
app/models/account_verification_request.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_verification_requests
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# image_file_name :string
|
||||
# image_content_type :string
|
||||
# image_file_size :bigint(8)
|
||||
# image_updated_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AccountVerificationRequest < ApplicationRecord
|
||||
LIMIT = 4.megabytes
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
|
||||
belongs_to :account
|
||||
|
||||
has_attached_file :image
|
||||
validates_attachment :image, presence: true
|
||||
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :image, less_than: LIMIT
|
||||
remotable_attachment :image, LIMIT
|
||||
end
|
||||
23
app/models/account_warning.rb
Normal file
23
app/models/account_warning.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_warnings
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# target_account_id :bigint(8)
|
||||
# action :integer default("none"), not null
|
||||
# text :text default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AccountWarning < ApplicationRecord
|
||||
enum action: %i(none disable silence suspend), _suffix: :action
|
||||
|
||||
belongs_to :account, inverse_of: :account_warnings
|
||||
belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings
|
||||
|
||||
scope :latest, -> { order(created_at: :desc) }
|
||||
scope :custom, -> { where.not(text: '') }
|
||||
end
|
||||
15
app/models/account_warning_preset.rb
Normal file
15
app/models/account_warning_preset.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_warning_presets
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# text :text default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AccountWarningPreset < ApplicationRecord
|
||||
validates :text, presence: true
|
||||
end
|
||||
7
app/models/admin.rb
Normal file
7
app/models/admin.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
def self.table_name_prefix
|
||||
'admin_'
|
||||
end
|
||||
end
|
||||
134
app/models/admin/account_action.rb
Normal file
134
app/models/admin/account_action.rb
Normal file
@@ -0,0 +1,134 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::AccountAction
|
||||
include ActiveModel::Model
|
||||
include AccountableConcern
|
||||
include Authorization
|
||||
|
||||
TYPES = %w(
|
||||
none
|
||||
disable
|
||||
silence
|
||||
suspend
|
||||
).freeze
|
||||
|
||||
attr_accessor :target_account,
|
||||
:current_account,
|
||||
:type,
|
||||
:text,
|
||||
:report_id,
|
||||
:warning_preset_id,
|
||||
:send_email_notification
|
||||
|
||||
attr_reader :warning
|
||||
|
||||
def save!
|
||||
ApplicationRecord.transaction do
|
||||
process_action!
|
||||
process_warning!
|
||||
end
|
||||
|
||||
queue_email!
|
||||
process_reports!
|
||||
end
|
||||
|
||||
def report
|
||||
@report ||= Report.find(report_id) if report_id.present?
|
||||
end
|
||||
|
||||
def with_report?
|
||||
!report.nil?
|
||||
end
|
||||
|
||||
class << self
|
||||
def types_for_account(account)
|
||||
if account.local?
|
||||
TYPES
|
||||
else
|
||||
TYPES - %w(none disable)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_action!
|
||||
case type
|
||||
when 'disable'
|
||||
handle_disable!
|
||||
when 'silence'
|
||||
handle_silence!
|
||||
when 'suspend'
|
||||
handle_suspend!
|
||||
end
|
||||
end
|
||||
|
||||
def process_warning!
|
||||
return unless warnable?
|
||||
|
||||
authorize(target_account, :warn?)
|
||||
|
||||
@warning = AccountWarning.create!(target_account: target_account,
|
||||
account: current_account,
|
||||
action: type,
|
||||
text: text_for_warning)
|
||||
|
||||
# A log entry is only interesting if the warning contains
|
||||
# custom text from someone. Otherwise it's just noise.
|
||||
log_action(:create, warning) if warning.text.present?
|
||||
end
|
||||
|
||||
def process_reports!
|
||||
return if report_id.blank?
|
||||
|
||||
authorize(report, :update?)
|
||||
|
||||
if type == 'none'
|
||||
log_action(:resolve, report)
|
||||
report.resolve!(current_account)
|
||||
else
|
||||
Report.where(target_account: target_account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_disable!
|
||||
authorize(target_account.user, :disable?)
|
||||
log_action(:disable, target_account.user)
|
||||
target_account.user&.disable!
|
||||
end
|
||||
|
||||
def handle_silence!
|
||||
authorize(target_account, :silence?)
|
||||
log_action(:silence, target_account)
|
||||
target_account.silence!
|
||||
end
|
||||
|
||||
def handle_suspend!
|
||||
authorize(target_account, :suspend?)
|
||||
log_action(:suspend, target_account)
|
||||
target_account.suspend!
|
||||
queue_suspension_worker!
|
||||
end
|
||||
|
||||
def text_for_warning
|
||||
[warning_preset&.text, text].compact.join("\n\n")
|
||||
end
|
||||
|
||||
def queue_suspension_worker!
|
||||
Admin::SuspensionWorker.perform_async(target_account.id)
|
||||
end
|
||||
|
||||
def queue_email!
|
||||
return unless warnable?
|
||||
|
||||
UserMailer.warning(target_account.user, warning).deliver_later!
|
||||
end
|
||||
|
||||
def warnable?
|
||||
send_email_notification && target_account.local?
|
||||
end
|
||||
|
||||
def warning_preset
|
||||
@warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
|
||||
end
|
||||
end
|
||||
45
app/models/admin/action_log.rb
Normal file
45
app/models/admin/action_log.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: admin_action_logs
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# action :string default(""), not null
|
||||
# target_type :string
|
||||
# target_id :bigint(8)
|
||||
# recorded_changes :text default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Admin::ActionLog < ApplicationRecord
|
||||
serialize :recorded_changes
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target, polymorphic: true
|
||||
|
||||
default_scope -> { order('id desc') }
|
||||
|
||||
def action
|
||||
super.to_sym
|
||||
end
|
||||
|
||||
before_validation :set_changes
|
||||
|
||||
private
|
||||
|
||||
def set_changes
|
||||
case action
|
||||
when :destroy, :create
|
||||
self.recorded_changes = target.attributes
|
||||
when :update, :promote, :demote
|
||||
self.recorded_changes = target.previous_changes
|
||||
when :change_email
|
||||
self.recorded_changes = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
email: [target.email, nil],
|
||||
unconfirmed_email: [nil, target.unconfirmed_email]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
6
app/models/application_record.rb
Normal file
6
app/models/application_record.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
include Remotable
|
||||
end
|
||||
22
app/models/backup.rb
Normal file
22
app/models/backup.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: backups
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# user_id :bigint(8)
|
||||
# dump_file_name :string
|
||||
# dump_content_type :string
|
||||
# dump_file_size :integer
|
||||
# dump_updated_at :datetime
|
||||
# processed :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Backup < ApplicationRecord
|
||||
belongs_to :user, inverse_of: :backups
|
||||
|
||||
has_attached_file :dump
|
||||
do_not_validate_attachment_file_type :dump
|
||||
end
|
||||
40
app/models/block.rb
Normal file
40
app/models/block.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: blocks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# target_account_id :bigint(8) not null
|
||||
# uri :string
|
||||
#
|
||||
|
||||
class Block < ApplicationRecord
|
||||
include Paginable
|
||||
include RelationshipCacheable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
|
||||
def local?
|
||||
false # Force uri_for to use uri attribute
|
||||
end
|
||||
|
||||
after_commit :remove_blocking_cache
|
||||
before_validation :set_uri, only: :create
|
||||
|
||||
private
|
||||
|
||||
def remove_blocking_cache
|
||||
Rails.cache.delete("exclude_account_ids_for:#{account_id}")
|
||||
Rails.cache.delete("exclude_account_ids_for:#{target_account_id}")
|
||||
end
|
||||
|
||||
def set_uri
|
||||
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
|
||||
end
|
||||
end
|
||||
16
app/models/btc_payment.rb
Normal file
16
app/models/btc_payment.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: btc_payments
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# btcpay_invoice_id :string not null
|
||||
# plan :string not null
|
||||
# success :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class BtcPayment < ApplicationRecord
|
||||
belongs_to :account
|
||||
end
|
||||
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
|
||||
5
app/models/context.rb
Normal file
5
app/models/context.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Context < ActiveModelSerializers::Model
|
||||
attributes :ancestors, :descendants
|
||||
end
|
||||
20
app/models/conversation.rb
Normal file
20
app/models/conversation.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: conversations
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# uri :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Conversation < ApplicationRecord
|
||||
validates :uri, uniqueness: true, if: :uri?
|
||||
|
||||
has_many :statuses
|
||||
|
||||
def local?
|
||||
uri.nil?
|
||||
end
|
||||
end
|
||||
14
app/models/conversation_mute.rb
Normal file
14
app/models/conversation_mute.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: conversation_mutes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# conversation_id :bigint(8) not null
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class ConversationMute < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :conversation
|
||||
end
|
||||
82
app/models/custom_emoji.rb
Normal file
82
app/models/custom_emoji.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_emojis
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# shortcode :string default(""), not null
|
||||
# domain :string
|
||||
# image_file_name :string
|
||||
# image_content_type :string
|
||||
# image_file_size :integer
|
||||
# image_updated_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# disabled :boolean default(FALSE), not null
|
||||
# uri :string
|
||||
# image_remote_url :string
|
||||
# visible_in_picker :boolean default(TRUE), not null
|
||||
#
|
||||
|
||||
class CustomEmoji < ApplicationRecord
|
||||
LIMIT = 50.kilobytes
|
||||
|
||||
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
|
||||
|
||||
SCAN_RE = /(?<=[^[:alnum:]:]|\n|^)
|
||||
:(#{SHORTCODE_RE_FRAGMENT}):
|
||||
(?=[^[:alnum:]:]|$)/x
|
||||
|
||||
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
|
||||
|
||||
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
|
||||
|
||||
before_validation :downcase_domain
|
||||
|
||||
validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { less_than: LIMIT }
|
||||
validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
|
||||
|
||||
scope :local, -> { where(domain: nil) }
|
||||
scope :remote, -> { where.not(domain: nil) }
|
||||
scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) }
|
||||
|
||||
remotable_attachment :image, LIMIT
|
||||
|
||||
include Attachmentable
|
||||
|
||||
after_commit :remove_entity_cache
|
||||
|
||||
def local?
|
||||
domain.nil?
|
||||
end
|
||||
|
||||
def object_type
|
||||
:emoji
|
||||
end
|
||||
|
||||
class << self
|
||||
def from_text(text, domain)
|
||||
return [] if text.blank?
|
||||
|
||||
shortcodes = text.scan(SCAN_RE).map(&:first).uniq
|
||||
|
||||
return [] if shortcodes.empty?
|
||||
|
||||
EntityCache.instance.emoji(shortcodes, domain)
|
||||
end
|
||||
|
||||
def search(shortcode)
|
||||
where('"custom_emojis"."shortcode" ILIKE ?', "%#{shortcode}%")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_entity_cache
|
||||
Rails.cache.delete(EntityCache.instance.to_key(:emoji, shortcode, domain))
|
||||
end
|
||||
|
||||
def downcase_domain
|
||||
self.domain = domain.downcase unless domain.nil?
|
||||
end
|
||||
end
|
||||
36
app/models/custom_emoji_filter.rb
Normal file
36
app/models/custom_emoji_filter.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CustomEmojiFilter
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def results
|
||||
scope = CustomEmoji.alphabetic
|
||||
|
||||
params.each do |key, value|
|
||||
scope.merge!(scope_for(key, value)) if value.present?
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope_for(key, value)
|
||||
case key.to_s
|
||||
when 'local'
|
||||
CustomEmoji.local
|
||||
when 'remote'
|
||||
CustomEmoji.remote
|
||||
when 'by_domain'
|
||||
CustomEmoji.where(domain: value.downcase)
|
||||
when 'shortcode'
|
||||
CustomEmoji.search(value)
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
end
|
||||
56
app/models/custom_filter.rb
Normal file
56
app/models/custom_filter.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_filters
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# expires_at :datetime
|
||||
# phrase :text default(""), not null
|
||||
# context :string default([]), not null, is an Array
|
||||
# whole_word :boolean default(TRUE), not null
|
||||
# irreversible :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class CustomFilter < ApplicationRecord
|
||||
VALID_CONTEXTS = %w(
|
||||
home
|
||||
notifications
|
||||
public
|
||||
thread
|
||||
).freeze
|
||||
|
||||
include Expireable
|
||||
|
||||
belongs_to :account
|
||||
|
||||
validates :phrase, :context, presence: true
|
||||
validate :context_must_be_valid
|
||||
validate :irreversible_must_be_within_context
|
||||
|
||||
scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) }
|
||||
|
||||
before_validation :clean_up_contexts
|
||||
after_commit :remove_cache
|
||||
|
||||
private
|
||||
|
||||
def clean_up_contexts
|
||||
self.context = Array(context).map(&:strip).map(&:presence).compact
|
||||
end
|
||||
|
||||
def remove_cache
|
||||
Rails.cache.delete("filters:#{account_id}")
|
||||
Redis.current.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
|
||||
end
|
||||
|
||||
def context_must_be_valid
|
||||
errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) }
|
||||
end
|
||||
|
||||
def irreversible_must_be_within_context
|
||||
errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications')
|
||||
end
|
||||
end
|
||||
42
app/models/domain_block.rb
Normal file
42
app/models/domain_block.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: domain_blocks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# domain :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# severity :integer default("silence")
|
||||
# reject_media :boolean default(FALSE), not null
|
||||
# reject_reports :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class DomainBlock < ApplicationRecord
|
||||
include DomainNormalizable
|
||||
|
||||
enum severity: [:silence, :suspend, :noop]
|
||||
|
||||
validates :domain, presence: true, uniqueness: true
|
||||
|
||||
has_many :accounts, foreign_key: :domain, primary_key: :domain
|
||||
delegate :count, to: :accounts, prefix: true
|
||||
|
||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
|
||||
def self.blocked?(domain)
|
||||
where(domain: domain, severity: :suspend).exists?
|
||||
end
|
||||
|
||||
def stricter_than?(other_block)
|
||||
return true if suspend?
|
||||
return false if other_block.suspend? && (silence? || noop?)
|
||||
return false if other_block.silence? && noop?
|
||||
(reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports)
|
||||
end
|
||||
|
||||
def affected_accounts_count
|
||||
scope = suspend? ? accounts.where(suspended_at: created_at) : accounts.where(silenced_at: created_at)
|
||||
scope.count
|
||||
end
|
||||
end
|
||||
30
app/models/email_domain_block.rb
Normal file
30
app/models/email_domain_block.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: email_domain_blocks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# domain :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class EmailDomainBlock < ApplicationRecord
|
||||
include DomainNormalizable
|
||||
|
||||
validates :domain, presence: true, uniqueness: true
|
||||
|
||||
def self.block?(email)
|
||||
_, domain = email.split('@', 2)
|
||||
|
||||
return true if domain.nil?
|
||||
|
||||
begin
|
||||
domain = TagManager.instance.normalize_domain(domain)
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
return true
|
||||
end
|
||||
|
||||
where(domain: domain).exists?
|
||||
end
|
||||
end
|
||||
95
app/models/export.rb
Normal file
95
app/models/export.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
class Export
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def to_blocked_accounts_csv
|
||||
to_csv account.blocking.select(:username, :domain)
|
||||
end
|
||||
|
||||
def to_muted_accounts_csv
|
||||
CSV.generate(headers: ['Account address', 'Hide notifications'], write_headers: true) do |csv|
|
||||
account.mute_relationships.includes(:target_account).reorder(id: :desc).each do |mute|
|
||||
csv << [acct(mute.target_account), mute.hide_notifications]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_following_accounts_csv
|
||||
CSV.generate(headers: ['Account address', 'Show reposts'], write_headers: true) do |csv|
|
||||
account.active_relationships.includes(:target_account).reorder(id: :desc).each do |follow|
|
||||
csv << [acct(follow.target_account), follow.show_reblogs]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_lists_csv
|
||||
CSV.generate do |csv|
|
||||
account.owned_lists.select(:title, :id).each do |list|
|
||||
list.accounts.select(:username, :domain).each do |account|
|
||||
csv << [list.title, acct(account)]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_blocked_domains_csv
|
||||
CSV.generate do |csv|
|
||||
account.domain_blocks.pluck(:domain).each do |domain|
|
||||
csv << [domain]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def total_storage
|
||||
account.media_attachments.sum(:file_file_size)
|
||||
end
|
||||
|
||||
def total_statuses
|
||||
account.statuses_count
|
||||
end
|
||||
|
||||
def total_follows
|
||||
account.following_count
|
||||
end
|
||||
|
||||
def total_lists
|
||||
account.owned_lists.count
|
||||
end
|
||||
|
||||
def total_followers
|
||||
account.followers_count
|
||||
end
|
||||
|
||||
def total_blocks
|
||||
account.blocking.count
|
||||
end
|
||||
|
||||
def total_mutes
|
||||
account.muting.count
|
||||
end
|
||||
|
||||
def total_domain_blocks
|
||||
account.domain_blocks.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def to_csv(accounts)
|
||||
CSV.generate do |csv|
|
||||
accounts.each do |account|
|
||||
csv << [acct(account)]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def acct(account)
|
||||
account.local? ? account.local_username_and_domain : account.acct
|
||||
end
|
||||
end
|
||||
42
app/models/favourite.rb
Normal file
42
app/models/favourite.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: favorites
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# status_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class Favourite < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
update_index('statuses#status', :status) if Chewy.enabled?
|
||||
|
||||
belongs_to :account, inverse_of: :favourites
|
||||
belongs_to :status, inverse_of: :favourites
|
||||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :status_id, uniqueness: { scope: :account_id }
|
||||
|
||||
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
|
||||
status&.increment_count!(:favourites_count)
|
||||
end
|
||||
|
||||
def decrement_cache_counters
|
||||
return if association(:status).loaded? && (status.marked_for_destruction? || status.marked_for_mass_destruction?)
|
||||
status&.decrement_count!(:favourites_count)
|
||||
end
|
||||
end
|
||||
47
app/models/featured_tag.rb
Normal file
47
app/models/featured_tag.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: featured_tags
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# tag_id :bigint(8)
|
||||
# statuses_count :bigint(8) default(0), not null
|
||||
# last_status_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class FeaturedTag < ApplicationRecord
|
||||
belongs_to :account, inverse_of: :featured_tags, required: true
|
||||
belongs_to :tag, inverse_of: :featured_tags, required: true
|
||||
|
||||
delegate :name, to: :tag, allow_nil: true
|
||||
|
||||
validates_associated :tag, on: :create
|
||||
validates :name, presence: true, on: :create
|
||||
validate :validate_featured_tags_limit, on: :create
|
||||
|
||||
def name=(str)
|
||||
self.tag = Tag.find_or_initialize_by(name: str.strip.delete('#').mb_chars.downcase.to_s)
|
||||
end
|
||||
|
||||
def increment(timestamp)
|
||||
update(statuses_count: statuses_count + 1, last_status_at: timestamp)
|
||||
end
|
||||
|
||||
def decrement(deleted_status_id)
|
||||
update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at)
|
||||
end
|
||||
|
||||
def reset_data
|
||||
self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count
|
||||
self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_featured_tags_limit
|
||||
errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= 10
|
||||
end
|
||||
end
|
||||
32
app/models/feed.rb
Normal file
32
app/models/feed.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Feed
|
||||
include Redisable
|
||||
|
||||
def initialize(type, id)
|
||||
@type = type
|
||||
@id = id
|
||||
end
|
||||
|
||||
def get(limit, max_id = nil, since_id = nil, min_id = nil)
|
||||
from_redis(limit, max_id, since_id, min_id)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def from_redis(limit, max_id, since_id, min_id)
|
||||
if min_id.blank?
|
||||
max_id = '+inf' if max_id.blank?
|
||||
since_id = '-inf' if since_id.blank?
|
||||
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
|
||||
else
|
||||
unhydrated = redis.zrangebyscore(key, "(#{min_id}", '+inf', limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
|
||||
end
|
||||
|
||||
Status.where(id: unhydrated).cache_ids
|
||||
end
|
||||
|
||||
def key
|
||||
FeedManager.instance.key(@type, @id)
|
||||
end
|
||||
end
|
||||
62
app/models/follow.rb
Normal file
62
app/models/follow.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: follows
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# target_account_id :bigint(8) not null
|
||||
# show_reblogs :boolean default(TRUE), not null
|
||||
# uri :string
|
||||
#
|
||||
|
||||
class Follow < ApplicationRecord
|
||||
include Paginable
|
||||
include RelationshipCacheable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
validates_with FollowLimitValidator, on: :create
|
||||
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
|
||||
def local?
|
||||
false # Force uri_for to use uri attribute
|
||||
end
|
||||
|
||||
def revoke_request!
|
||||
FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, uri: uri)
|
||||
destroy!
|
||||
end
|
||||
|
||||
before_validation :set_uri, only: :create
|
||||
after_create :increment_cache_counters
|
||||
after_destroy :remove_endorsements
|
||||
after_destroy :decrement_cache_counters
|
||||
|
||||
private
|
||||
|
||||
def set_uri
|
||||
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
|
||||
end
|
||||
|
||||
def remove_endorsements
|
||||
AccountPin.where(target_account_id: target_account_id, account_id: account_id).delete_all
|
||||
end
|
||||
|
||||
def increment_cache_counters
|
||||
account&.increment_count!(:following_count)
|
||||
target_account&.increment_count!(:followers_count)
|
||||
end
|
||||
|
||||
def decrement_cache_counters
|
||||
account&.decrement_count!(:following_count)
|
||||
target_account&.decrement_count!(:followers_count)
|
||||
end
|
||||
end
|
||||
46
app/models/follow_request.rb
Normal file
46
app/models/follow_request.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: follow_requests
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# target_account_id :bigint(8) not null
|
||||
# show_reblogs :boolean default(TRUE), not null
|
||||
# uri :string
|
||||
#
|
||||
|
||||
class FollowRequest < ApplicationRecord
|
||||
include Paginable
|
||||
include RelationshipCacheable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
validates_with FollowLimitValidator, on: :create
|
||||
|
||||
def authorize!
|
||||
account.follow!(target_account, reblogs: show_reblogs, uri: uri)
|
||||
MergeWorker.perform_async(target_account.id, account.id) if account.local?
|
||||
destroy!
|
||||
end
|
||||
|
||||
alias reject! destroy!
|
||||
|
||||
def local?
|
||||
false # Force uri_for to use uri attribute
|
||||
end
|
||||
|
||||
before_validation :set_uri, only: :create
|
||||
|
||||
private
|
||||
|
||||
def set_uri
|
||||
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
|
||||
end
|
||||
end
|
||||
79
app/models/form/account_batch.rb
Normal file
79
app/models/form/account_batch.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Form::AccountBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
|
||||
attr_accessor :account_ids, :action, :current_account
|
||||
|
||||
def save
|
||||
case action
|
||||
when 'unfollow'
|
||||
unfollow!
|
||||
when 'remove_from_followers'
|
||||
remove_from_followers!
|
||||
when 'block_domains'
|
||||
block_domains!
|
||||
when 'approve'
|
||||
approve!
|
||||
when 'reject'
|
||||
reject!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unfollow!
|
||||
accounts.find_each do |target_account|
|
||||
UnfollowService.new.call(current_account, target_account)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_followers!
|
||||
current_account.passive_relationships.where(account_id: account_ids).find_each do |follow|
|
||||
reject_follow!(follow)
|
||||
end
|
||||
end
|
||||
|
||||
def block_domains!
|
||||
AfterAccountDomainBlockWorker.push_bulk(account_domains) do |domain|
|
||||
[current_account.id, domain]
|
||||
end
|
||||
end
|
||||
|
||||
def account_domains
|
||||
accounts.pluck(Arel.sql('distinct domain')).compact
|
||||
end
|
||||
|
||||
def accounts
|
||||
Account.where(id: account_ids)
|
||||
end
|
||||
|
||||
def reject_follow!(follow)
|
||||
follow.destroy
|
||||
|
||||
return unless follow.account.activitypub?
|
||||
|
||||
json = ActiveModelSerializers::SerializableResource.new(
|
||||
follow,
|
||||
serializer: ActivityPub::RejectFollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
|
||||
ActivityPub::DeliveryWorker.perform_async(json, current_account.id, follow.account.inbox_url)
|
||||
end
|
||||
|
||||
def approve!
|
||||
users = accounts.includes(:user).map(&:user)
|
||||
|
||||
users.each { |user| authorize(user, :approve?) }
|
||||
.each(&:approve!)
|
||||
end
|
||||
|
||||
def reject!
|
||||
records = accounts.includes(:user)
|
||||
|
||||
records.each { |account| authorize(account.user, :reject?) }
|
||||
.each { |account| SuspendAccountService.new.call(account, including_user: true, destroy: true, skip_distribution: true) }
|
||||
end
|
||||
end
|
||||
96
app/models/form/admin_settings.rb
Normal file
96
app/models/form/admin_settings.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Form::AdminSettings
|
||||
include ActiveModel::Model
|
||||
|
||||
KEYS = %i(
|
||||
site_contact_username
|
||||
site_contact_email
|
||||
site_title
|
||||
site_short_description
|
||||
site_description
|
||||
site_extended_description
|
||||
site_terms
|
||||
registrations_mode
|
||||
closed_registrations_message
|
||||
open_deletion
|
||||
timeline_preview
|
||||
show_staff_badge
|
||||
bootstrap_timeline_accounts
|
||||
theme
|
||||
min_invite_role
|
||||
activity_api_enabled
|
||||
peers_api_enabled
|
||||
show_known_fediverse_at_about_page
|
||||
preview_sensitive_media
|
||||
custom_css
|
||||
profile_directory
|
||||
thumbnail
|
||||
hero
|
||||
mascot
|
||||
).freeze
|
||||
|
||||
BOOLEAN_KEYS = %i(
|
||||
open_deletion
|
||||
timeline_preview
|
||||
show_staff_badge
|
||||
activity_api_enabled
|
||||
peers_api_enabled
|
||||
show_known_fediverse_at_about_page
|
||||
preview_sensitive_media
|
||||
profile_directory
|
||||
).freeze
|
||||
|
||||
UPLOAD_KEYS = %i(
|
||||
thumbnail
|
||||
hero
|
||||
mascot
|
||||
).freeze
|
||||
|
||||
attr_accessor(*KEYS)
|
||||
|
||||
validates :site_short_description, :site_description, html: { wrap_with: :p }
|
||||
validates :site_extended_description, :site_terms, :closed_registrations_message, html: true
|
||||
validates :registrations_mode, inclusion: { in: %w(open approved none) }
|
||||
validates :min_invite_role, inclusion: { in: %w(disabled user moderator admin) }
|
||||
validates :site_contact_email, :site_contact_username, presence: true
|
||||
validates :site_contact_username, existing_username: true
|
||||
validates :bootstrap_timeline_accounts, existing_username: { multiple: true }
|
||||
|
||||
def initialize(_attributes = {})
|
||||
super
|
||||
initialize_attributes
|
||||
end
|
||||
|
||||
def save
|
||||
return false unless valid?
|
||||
|
||||
KEYS.each do |key|
|
||||
value = instance_variable_get("@#{key}")
|
||||
|
||||
if UPLOAD_KEYS.include?(key) && !value.nil?
|
||||
upload = SiteUpload.where(var: key).first_or_initialize(var: key)
|
||||
upload.update(file: value)
|
||||
else
|
||||
setting = Setting.where(var: key).first_or_initialize(var: key)
|
||||
setting.update(value: typecast_value(key, value))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_attributes
|
||||
KEYS.each do |key|
|
||||
instance_variable_set("@#{key}", Setting.public_send(key)) if instance_variable_get("@#{key}").nil?
|
||||
end
|
||||
end
|
||||
|
||||
def typecast_value(key, value)
|
||||
if BOOLEAN_KEYS.include?(key)
|
||||
value == '1'
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
7
app/models/form/delete_confirmation.rb
Normal file
7
app/models/form/delete_confirmation.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Form::DeleteConfirmation
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :password
|
||||
end
|
||||
25
app/models/form/migration.rb
Normal file
25
app/models/form/migration.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Form::Migration
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_accessor :acct, :account
|
||||
|
||||
def initialize(attrs = {})
|
||||
@account = attrs[:account]
|
||||
@acct = attrs[:account].acct unless @account.nil?
|
||||
@acct = attrs[:acct].gsub(/\A@/, '').strip unless attrs[:acct].nil?
|
||||
end
|
||||
|
||||
def valid?
|
||||
return false unless super
|
||||
set_account
|
||||
errors.empty?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
self.account = (ResolveAccountService.new.call(acct) if account.nil? && acct.present?)
|
||||
end
|
||||
end
|
||||
44
app/models/form/status_batch.rb
Normal file
44
app/models/form/status_batch.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Form::StatusBatch
|
||||
include ActiveModel::Model
|
||||
include AccountableConcern
|
||||
|
||||
attr_accessor :status_ids, :action, :current_account
|
||||
|
||||
def save
|
||||
case action
|
||||
when 'nsfw_on', 'nsfw_off'
|
||||
change_sensitive(action == 'nsfw_on')
|
||||
when 'delete'
|
||||
delete_statuses
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def change_sensitive(sensitive)
|
||||
media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
Status.where(id: media_attached_status_ids).reorder(nil).find_each do |status|
|
||||
status.update!(sensitive: sensitive)
|
||||
log_action :update, status
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
false
|
||||
end
|
||||
|
||||
def delete_statuses
|
||||
Status.where(id: status_ids).reorder(nil).find_each do |status|
|
||||
RemovalWorker.perform_async(status.id)
|
||||
Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
|
||||
log_action :destroy, status
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
7
app/models/form/two_factor_confirmation.rb
Normal file
7
app/models/form/two_factor_confirmation.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Form::TwoFactorConfirmation
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :code
|
||||
end
|
||||
69
app/models/group.rb
Normal file
69
app/models/group.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: groups
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# title :string not null
|
||||
# description :string not null
|
||||
# cover_image_file_name :string
|
||||
# cover_image_content_type :string
|
||||
# cover_image_file_size :integer
|
||||
# cover_image_updated_at :datetime
|
||||
# is_nsfw :boolean default(FALSE), not null
|
||||
# is_featured :boolean default(FALSE), not null
|
||||
# is_archived :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Group < ApplicationRecord
|
||||
include Paginable
|
||||
include GroupInteractions
|
||||
|
||||
PER_ACCOUNT_LIMIT = 50
|
||||
|
||||
belongs_to :account, optional: true
|
||||
|
||||
has_many :group_accounts, inverse_of: :group, dependent: :destroy
|
||||
has_many :accounts, through: :group_accounts
|
||||
|
||||
validates :title, presence: true
|
||||
validates :description, presence: true
|
||||
|
||||
LIMIT = 4.megabytes
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
|
||||
has_attached_file :cover_image
|
||||
validates_attachment_content_type :cover_image, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :cover_image, less_than: LIMIT
|
||||
remotable_attachment :cover_image, LIMIT
|
||||
|
||||
validates_each :account_id, on: :create do |record, _attr, value|
|
||||
record.errors.add(:base, I18n.t('groups.errors.limit')) if Group.where(account_id: value).count >= PER_ACCOUNT_LIMIT
|
||||
end
|
||||
|
||||
before_destroy :clean_feed_manager
|
||||
after_create :add_owner_to_accounts
|
||||
|
||||
private
|
||||
|
||||
def add_owner_to_accounts
|
||||
group_accounts << GroupAccount.new(account: account, role: :admin, write_permissions: true)
|
||||
end
|
||||
|
||||
def clean_feed_manager
|
||||
reblog_key = FeedManager.instance.key(:group, id, 'reblogs')
|
||||
reblogged_id_set = Redis.current.zrange(reblog_key, 0, -1)
|
||||
|
||||
Redis.current.pipelined do
|
||||
Redis.current.del(FeedManager.instance.key(:group, id))
|
||||
Redis.current.del(reblog_key)
|
||||
|
||||
reblogged_id_set.each do |reblogged_id|
|
||||
reblog_set_key = FeedManager.instance.key(:group, id, "reblogs:#{reblogged_id}")
|
||||
Redis.current.del(reblog_set_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
21
app/models/group_account.rb
Normal file
21
app/models/group_account.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: group_accounts
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# group_id :bigint(8) not null
|
||||
# account_id :bigint(8) not null
|
||||
# write_permissions :boolean default(FALSE), not null
|
||||
# role :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class GroupAccount < ApplicationRecord
|
||||
enum role: { admin: "admin" }
|
||||
|
||||
belongs_to :group
|
||||
belongs_to :account
|
||||
|
||||
validates :account_id, uniqueness: { scope: :group_id }
|
||||
end
|
||||
25
app/models/home_feed.rb
Normal file
25
app/models/home_feed.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class HomeFeed < Feed
|
||||
def initialize(account)
|
||||
@type = :home
|
||||
@id = account.id
|
||||
@account = account
|
||||
end
|
||||
|
||||
def get(limit, max_id = nil, since_id = nil, min_id = nil)
|
||||
if redis.exists("account:#{@account.id}:regeneration")
|
||||
from_database(limit, max_id, since_id, min_id)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def from_database(limit, max_id, since_id, min_id)
|
||||
Status.as_home_timeline(@account)
|
||||
.paginate_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
|
||||
.reject { |status| FeedManager.instance.filter?(:home, status, @account.id) }
|
||||
end
|
||||
end
|
||||
22
app/models/identity.rb
Normal file
22
app/models/identity.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: identities
|
||||
#
|
||||
# provider :string default(""), not null
|
||||
# uid :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint(8) not null, primary key
|
||||
# user_id :bigint(8)
|
||||
#
|
||||
|
||||
class Identity < ApplicationRecord
|
||||
belongs_to :user, dependent: :destroy
|
||||
validates :uid, presence: true, uniqueness: { scope: :provider }
|
||||
validates :provider, presence: true
|
||||
|
||||
def self.find_for_oauth(auth)
|
||||
find_or_create_by(uid: auth.uid, provider: auth.provider)
|
||||
end
|
||||
end
|
||||
42
app/models/import.rb
Normal file
42
app/models/import.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: imports
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# type :integer not null
|
||||
# approved :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# data_file_name :string
|
||||
# data_content_type :string
|
||||
# data_file_size :integer
|
||||
# data_updated_at :datetime
|
||||
# account_id :bigint(8) not null
|
||||
# overwrite :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class Import < ApplicationRecord
|
||||
FILE_TYPES = %w(text/plain text/csv).freeze
|
||||
MODES = %i(merge overwrite).freeze
|
||||
|
||||
self.inheritance_column = false
|
||||
|
||||
belongs_to :account
|
||||
|
||||
enum type: [:following, :blocking, :muting, :domain_blocking]
|
||||
|
||||
validates :type, presence: true
|
||||
|
||||
has_attached_file :data
|
||||
validates_attachment_content_type :data, content_type: FILE_TYPES
|
||||
validates_attachment_presence :data
|
||||
|
||||
def mode
|
||||
overwrite? ? :overwrite : :merge
|
||||
end
|
||||
|
||||
def mode=(str)
|
||||
self.overwrite = str.to_sym == :overwrite
|
||||
end
|
||||
end
|
||||
29
app/models/instance.rb
Normal file
29
app/models/instance.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Instance
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :domain, :accounts_count, :domain_block
|
||||
|
||||
def initialize(resource)
|
||||
@domain = resource.domain
|
||||
@accounts_count = resource.is_a?(DomainBlock) ? nil : resource.accounts_count
|
||||
@domain_block = resource.is_a?(DomainBlock) ? resource : DomainBlock.find_by(domain: domain)
|
||||
end
|
||||
|
||||
def cached_sample_accounts
|
||||
Rails.cache.fetch("#{cache_key}/sample_accounts", expires_in: 12.hours) { Account.where(domain: domain).searchable.joins(:account_stat).popular.limit(3) }
|
||||
end
|
||||
|
||||
def cached_accounts_count
|
||||
@accounts_count || Rails.cache.fetch("#{cache_key}/count", expires_in: 12.hours) { Account.where(domain: domain).count }
|
||||
end
|
||||
|
||||
def to_param
|
||||
domain
|
||||
end
|
||||
|
||||
def cache_key
|
||||
domain
|
||||
end
|
||||
end
|
||||
21
app/models/instance_filter.rb
Normal file
21
app/models/instance_filter.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InstanceFilter
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def results
|
||||
if params[:limited].present?
|
||||
scope = DomainBlock
|
||||
scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
|
||||
scope.order(id: :desc)
|
||||
else
|
||||
scope = Account.remote
|
||||
scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
|
||||
scope.by_domain_accounts
|
||||
end
|
||||
end
|
||||
end
|
||||
39
app/models/invite.rb
Normal file
39
app/models/invite.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: invites
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# user_id :bigint(8) not null
|
||||
# code :string default(""), not null
|
||||
# expires_at :datetime
|
||||
# max_uses :integer
|
||||
# uses :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# autofollow :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class Invite < ApplicationRecord
|
||||
include Expireable
|
||||
|
||||
belongs_to :user
|
||||
has_many :users, inverse_of: :invite
|
||||
|
||||
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
|
||||
|
||||
before_validation :set_code
|
||||
|
||||
def valid_for_use?
|
||||
(max_uses.nil? || uses < max_uses) && !expired?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_code
|
||||
loop do
|
||||
self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join
|
||||
break if Invite.find_by(code: code).nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
32
app/models/invite_filter.rb
Normal file
32
app/models/invite_filter.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InviteFilter
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def results
|
||||
scope = Invite.order(created_at: :desc)
|
||||
|
||||
params.each do |key, value|
|
||||
scope.merge!(scope_for(key, value)) if value.present?
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope_for(key, _value)
|
||||
case key.to_s
|
||||
when 'available'
|
||||
Invite.available
|
||||
when 'expired'
|
||||
Invite.expired
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
end
|
||||
47
app/models/list.rb
Normal file
47
app/models/list.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: lists
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# title :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class List < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
PER_ACCOUNT_LIMIT = 50
|
||||
|
||||
belongs_to :account, optional: true
|
||||
|
||||
has_many :list_accounts, inverse_of: :list, dependent: :destroy
|
||||
has_many :accounts, through: :list_accounts
|
||||
|
||||
validates :title, presence: true
|
||||
|
||||
validates_each :account_id, on: :create do |record, _attr, value|
|
||||
record.errors.add(:base, I18n.t('lists.errors.limit')) if List.where(account_id: value).count >= PER_ACCOUNT_LIMIT
|
||||
end
|
||||
|
||||
before_destroy :clean_feed_manager
|
||||
|
||||
private
|
||||
|
||||
def clean_feed_manager
|
||||
reblog_key = FeedManager.instance.key(:list, id, 'reblogs')
|
||||
reblogged_id_set = Redis.current.zrange(reblog_key, 0, -1)
|
||||
|
||||
Redis.current.pipelined do
|
||||
Redis.current.del(FeedManager.instance.key(:list, id))
|
||||
Redis.current.del(reblog_key)
|
||||
|
||||
reblogged_id_set.each do |reblogged_id|
|
||||
reblog_set_key = FeedManager.instance.key(:list, id, "reblogs:#{reblogged_id}")
|
||||
Redis.current.del(reblog_set_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/models/list_account.rb
Normal file
26
app/models/list_account.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: list_accounts
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# list_id :bigint(8) not null
|
||||
# account_id :bigint(8) not null
|
||||
# follow_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class ListAccount < ApplicationRecord
|
||||
belongs_to :list
|
||||
belongs_to :account
|
||||
belongs_to :follow
|
||||
|
||||
validates :account_id, uniqueness: { scope: :list_id }
|
||||
|
||||
before_validation :set_follow
|
||||
|
||||
private
|
||||
|
||||
def set_follow
|
||||
self.follow = Follow.find_by(account_id: list.account_id, target_account_id: account.id)
|
||||
end
|
||||
end
|
||||
8
app/models/list_feed.rb
Normal file
8
app/models/list_feed.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ListFeed < Feed
|
||||
def initialize(list)
|
||||
@type = :list
|
||||
@id = list.id
|
||||
end
|
||||
end
|
||||
255
app/models/media_attachment.rb
Normal file
255
app/models/media_attachment.rb
Normal file
@@ -0,0 +1,255 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: media_attachments
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_id :bigint(8)
|
||||
# file_file_name :string
|
||||
# file_content_type :string
|
||||
# file_file_size :integer
|
||||
# file_updated_at :datetime
|
||||
# remote_url :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# shortcode :string
|
||||
# type :integer default("image"), not null
|
||||
# file_meta :json
|
||||
# account_id :bigint(8)
|
||||
# description :text
|
||||
# scheduled_status_id :bigint(8)
|
||||
# blurhash :string
|
||||
#
|
||||
|
||||
class MediaAttachment < ApplicationRecord
|
||||
self.inheritance_column = nil
|
||||
|
||||
enum type: [:image, :gifv, :video, :unknown]
|
||||
|
||||
IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].freeze
|
||||
VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v', '.mov'].freeze
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
|
||||
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
|
||||
|
||||
BLURHASH_OPTIONS = {
|
||||
x_comp: 4,
|
||||
y_comp: 4,
|
||||
}.freeze
|
||||
|
||||
IMAGE_STYLES = {
|
||||
original: {
|
||||
pixels: 1_638_400, # 1280x1280px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
},
|
||||
|
||||
small: {
|
||||
pixels: 160_000, # 400x400px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze
|
||||
|
||||
VIDEO_STYLES = {
|
||||
small: {
|
||||
convert_options: {
|
||||
output: {
|
||||
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
|
||||
},
|
||||
},
|
||||
format: 'png',
|
||||
time: 0,
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze
|
||||
|
||||
VIDEO_FORMAT = {
|
||||
format: 'mp4',
|
||||
convert_options: {
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
'movflags' => 'faststart',
|
||||
'pix_fmt' => 'yuv420p',
|
||||
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
|
||||
'vsync' => 'cfr',
|
||||
'c:v' => 'h264',
|
||||
'b:v' => '500K',
|
||||
'maxrate' => '1300K',
|
||||
'bufsize' => '1300K',
|
||||
'crf' => 18,
|
||||
},
|
||||
},
|
||||
}.freeze
|
||||
|
||||
IMAGE_LIMIT = 8.megabytes
|
||||
VIDEO_LIMIT = 40.megabytes
|
||||
|
||||
belongs_to :account, inverse_of: :media_attachments, optional: true
|
||||
belongs_to :status, inverse_of: :media_attachments, optional: true
|
||||
belongs_to :scheduled_status, inverse_of: :media_attachments, optional: true
|
||||
|
||||
has_attached_file :file,
|
||||
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: IMAGE_LIMIT, unless: :video_or_gifv?
|
||||
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :video_or_gifv?
|
||||
remotable_attachment :file, VIDEO_LIMIT
|
||||
|
||||
include Attachmentable
|
||||
|
||||
validates :account, presence: true
|
||||
validates :description, length: { maximum: 420 }, if: :local?
|
||||
|
||||
scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
|
||||
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
|
||||
scope :local, -> { where(remote_url: '') }
|
||||
scope :remote, -> { where.not(remote_url: '') }
|
||||
|
||||
default_scope { order(id: :asc) }
|
||||
|
||||
def local?
|
||||
remote_url.blank?
|
||||
end
|
||||
|
||||
def needs_redownload?
|
||||
file.blank? && remote_url.present?
|
||||
end
|
||||
|
||||
def video_or_gifv?
|
||||
video? || gifv?
|
||||
end
|
||||
|
||||
def to_param
|
||||
shortcode
|
||||
end
|
||||
|
||||
def focus=(point)
|
||||
return if point.blank?
|
||||
|
||||
x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
|
||||
|
||||
meta = file.instance_read(:meta) || {}
|
||||
meta['focus'] = { 'x' => x, 'y' => y }
|
||||
|
||||
file.instance_write(:meta, meta)
|
||||
end
|
||||
|
||||
def focus
|
||||
x = file.meta['focus']['x']
|
||||
y = file.meta['focus']['y']
|
||||
|
||||
"#{x},#{y}"
|
||||
end
|
||||
|
||||
after_commit :reset_parent_cache, on: :update
|
||||
before_create :prepare_description, unless: :local?
|
||||
before_create :set_shortcode
|
||||
before_post_process :set_type_and_extension
|
||||
before_save :set_meta
|
||||
|
||||
class << self
|
||||
private
|
||||
|
||||
def file_styles(f)
|
||||
if f.instance.file_content_type == 'image/gif'
|
||||
{
|
||||
small: IMAGE_STYLES[:small],
|
||||
original: VIDEO_FORMAT,
|
||||
}
|
||||
elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
|
||||
IMAGE_STYLES
|
||||
elsif VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type)
|
||||
{
|
||||
small: VIDEO_STYLES[:small],
|
||||
original: VIDEO_FORMAT,
|
||||
}
|
||||
else
|
||||
VIDEO_STYLES
|
||||
end
|
||||
end
|
||||
|
||||
def file_processors(f)
|
||||
if f.file_content_type == 'image/gif'
|
||||
[:gif_transcoder, :blurhash_transcoder]
|
||||
elsif VIDEO_MIME_TYPES.include? f.file_content_type
|
||||
[:video_transcoder, :blurhash_transcoder]
|
||||
else
|
||||
[:lazy_thumbnail, :blurhash_transcoder]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_shortcode
|
||||
self.type = :unknown if file.blank? && !type_changed?
|
||||
|
||||
return unless local?
|
||||
|
||||
loop do
|
||||
self.shortcode = SecureRandom.urlsafe_base64(14)
|
||||
break if MediaAttachment.find_by(shortcode: shortcode).nil?
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_description
|
||||
self.description = description.strip[0...420] unless description.nil?
|
||||
end
|
||||
|
||||
def set_type_and_extension
|
||||
self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
|
||||
end
|
||||
|
||||
def set_meta
|
||||
meta = populate_meta
|
||||
return if meta == {}
|
||||
file.instance_write :meta, meta
|
||||
end
|
||||
|
||||
def populate_meta
|
||||
meta = file.instance_read(:meta) || {}
|
||||
|
||||
file.queued_for_write.each do |style, file|
|
||||
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
|
||||
end
|
||||
|
||||
meta
|
||||
end
|
||||
|
||||
def image_geometry(file)
|
||||
width, height = FastImage.size(file.path)
|
||||
|
||||
return {} if width.nil?
|
||||
|
||||
{
|
||||
width: width,
|
||||
height: height,
|
||||
size: "#{width}x#{height}",
|
||||
aspect: width.to_f / height.to_f,
|
||||
}
|
||||
end
|
||||
|
||||
def video_metadata(file)
|
||||
movie = FFMPEG::Movie.new(file.path)
|
||||
|
||||
return {} unless movie.valid?
|
||||
|
||||
{
|
||||
width: movie.width,
|
||||
height: movie.height,
|
||||
frame_rate: movie.frame_rate,
|
||||
duration: movie.duration,
|
||||
bitrate: movie.bitrate,
|
||||
}
|
||||
end
|
||||
|
||||
def reset_parent_cache
|
||||
return if status_id.nil?
|
||||
Rails.cache.delete("statuses/#{status_id}")
|
||||
end
|
||||
end
|
||||
35
app/models/mention.rb
Normal file
35
app/models/mention.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: mentions
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8)
|
||||
# silent :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class Mention < ApplicationRecord
|
||||
belongs_to :account, inverse_of: :mentions
|
||||
belongs_to :status
|
||||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :account, uniqueness: { scope: :status }
|
||||
|
||||
scope :active, -> { where(silent: false) }
|
||||
scope :silent, -> { where(silent: true) }
|
||||
|
||||
delegate(
|
||||
:username,
|
||||
:acct,
|
||||
to: :account,
|
||||
prefix: true
|
||||
)
|
||||
|
||||
def active?
|
||||
!silent?
|
||||
end
|
||||
end
|
||||
30
app/models/mute.rb
Normal file
30
app/models/mute.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: mutes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# target_account_id :bigint(8) not null
|
||||
# hide_notifications :boolean default(TRUE), not null
|
||||
#
|
||||
|
||||
class Mute < ApplicationRecord
|
||||
include Paginable
|
||||
include RelationshipCacheable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
|
||||
after_commit :remove_blocking_cache
|
||||
|
||||
private
|
||||
|
||||
def remove_blocking_cache
|
||||
Rails.cache.delete("exclude_account_ids_for:#{account_id}")
|
||||
end
|
||||
end
|
||||
114
app/models/notification.rb
Normal file
114
app/models/notification.rb
Normal file
@@ -0,0 +1,114 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: notifications
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# activity_id :bigint(8) not null
|
||||
# activity_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# from_account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class Notification < ApplicationRecord
|
||||
include Paginable
|
||||
include Cacheable
|
||||
|
||||
TYPE_CLASS_MAP = {
|
||||
mention: 'Mention',
|
||||
reblog: 'Status',
|
||||
follow: 'Follow',
|
||||
follow_request: 'FollowRequest',
|
||||
favourite: 'Favourite',
|
||||
poll: 'Poll',
|
||||
}.freeze
|
||||
|
||||
STATUS_INCLUDES = [:account, :application, :preloadable_poll, :media_attachments, :tags, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, :tags, active_mentions: :account]].freeze
|
||||
|
||||
belongs_to :account, optional: true
|
||||
belongs_to :from_account, class_name: 'Account', optional: true
|
||||
belongs_to :activity, polymorphic: true, optional: true
|
||||
|
||||
belongs_to :mention, foreign_type: 'Mention', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow, foreign_type: 'Follow', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow_request, foreign_type: 'FollowRequest', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_type: 'Favourite', foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_type: 'Poll', foreign_key: 'activity_id', optional: true
|
||||
|
||||
validates :account_id, uniqueness: { scope: [:activity_type, :activity_id] }
|
||||
validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values }
|
||||
|
||||
scope :browserable, ->(exclude_types = [], account_id = nil) {
|
||||
types = TYPE_CLASS_MAP.values - activity_types_from_types(exclude_types + [:follow_request])
|
||||
if account_id.nil?
|
||||
where(activity_type: types)
|
||||
else
|
||||
where(activity_type: types, from_account_id: account_id)
|
||||
end
|
||||
}
|
||||
|
||||
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, poll: [status: STATUS_INCLUDES]
|
||||
|
||||
def type
|
||||
@type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
|
||||
end
|
||||
|
||||
def target_status
|
||||
case type
|
||||
when :reblog
|
||||
status&.reblog
|
||||
when :favourite
|
||||
favourite&.status
|
||||
when :mention
|
||||
mention&.status
|
||||
when :poll
|
||||
poll&.status
|
||||
end
|
||||
end
|
||||
|
||||
def browserable?
|
||||
type != :follow_request
|
||||
end
|
||||
|
||||
class << self
|
||||
def cache_ids
|
||||
select(:id, :updated_at, :activity_type, :activity_id)
|
||||
end
|
||||
|
||||
def reload_stale_associations!(cached_items)
|
||||
account_ids = (cached_items.map(&:from_account_id) + cached_items.map { |item| item.target_status&.account_id }.compact).uniq
|
||||
|
||||
return if account_ids.empty?
|
||||
|
||||
accounts = Account.where(id: account_ids).includes(:account_stat).each_with_object({}) { |a, h| h[a.id] = a }
|
||||
|
||||
cached_items.each do |item|
|
||||
item.from_account = accounts[item.from_account_id]
|
||||
item.target_status.account = accounts[item.target_status.account_id] if item.target_status
|
||||
end
|
||||
end
|
||||
|
||||
def activity_types_from_types(types)
|
||||
types.map { |type| TYPE_CLASS_MAP[type.to_sym] }.compact
|
||||
end
|
||||
end
|
||||
|
||||
after_initialize :set_from_account
|
||||
before_validation :set_from_account
|
||||
|
||||
private
|
||||
|
||||
def set_from_account
|
||||
return unless new_record?
|
||||
|
||||
case activity_type
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
|
||||
self.from_account_id = activity&.account_id
|
||||
when 'Mention'
|
||||
self.from_account_id = activity&.status&.account_id
|
||||
end
|
||||
end
|
||||
end
|
||||
108
app/models/poll.rb
Normal file
108
app/models/poll.rb
Normal file
@@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: polls
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# status_id :bigint(8)
|
||||
# expires_at :datetime
|
||||
# options :string default([]), not null, is an Array
|
||||
# cached_tallies :bigint(8) default([]), not null, is an Array
|
||||
# multiple :boolean default(FALSE), not null
|
||||
# hide_totals :boolean default(FALSE), not null
|
||||
# votes_count :bigint(8) default(0), not null
|
||||
# last_fetched_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# lock_version :integer default(0), not null
|
||||
#
|
||||
|
||||
class Poll < ApplicationRecord
|
||||
include Expireable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :status
|
||||
|
||||
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :destroy
|
||||
|
||||
has_many :notifications, as: :activity, dependent: :destroy
|
||||
|
||||
validates :options, presence: true
|
||||
validates :expires_at, presence: true, if: :local?
|
||||
validates_with PollValidator, on: :create, if: :local?
|
||||
|
||||
scope :attached, -> { where.not(status_id: nil) }
|
||||
scope :unattached, -> { where(status_id: nil) }
|
||||
|
||||
before_validation :prepare_options
|
||||
before_validation :prepare_votes_count
|
||||
|
||||
after_initialize :prepare_cached_tallies
|
||||
|
||||
after_commit :reset_parent_cache, on: :update
|
||||
|
||||
def loaded_options
|
||||
options.map.with_index { |title, key| Option.new(self, key.to_s, title, show_totals_now? ? cached_tallies[key] : nil) }
|
||||
end
|
||||
|
||||
def possibly_stale?
|
||||
remote? && last_fetched_before_expiration? && time_passed_since_last_fetch?
|
||||
end
|
||||
|
||||
def voted?(account)
|
||||
account.id == account_id || votes.where(account: account).exists?
|
||||
end
|
||||
|
||||
delegate :local?, to: :account
|
||||
|
||||
def remote?
|
||||
!local?
|
||||
end
|
||||
|
||||
def emojis
|
||||
@emojis ||= CustomEmoji.from_text(options.join(' '), account.domain)
|
||||
end
|
||||
|
||||
class Option < ActiveModelSerializers::Model
|
||||
attributes :id, :title, :votes_count, :poll
|
||||
|
||||
def initialize(poll, id, title, votes_count)
|
||||
@poll = poll
|
||||
@id = id
|
||||
@title = title
|
||||
@votes_count = votes_count
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_cached_tallies
|
||||
self.cached_tallies = options.map { 0 } if cached_tallies.empty?
|
||||
end
|
||||
|
||||
def prepare_votes_count
|
||||
self.votes_count = cached_tallies.sum unless cached_tallies.empty?
|
||||
end
|
||||
|
||||
def prepare_options
|
||||
self.options = options.map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
def reset_parent_cache
|
||||
return if status_id.nil?
|
||||
Rails.cache.delete("statuses/#{status_id}")
|
||||
end
|
||||
|
||||
def last_fetched_before_expiration?
|
||||
last_fetched_at.nil? || expires_at.nil? || last_fetched_at < expires_at
|
||||
end
|
||||
|
||||
def time_passed_since_last_fetch?
|
||||
last_fetched_at.nil? || last_fetched_at < 1.minute.ago
|
||||
end
|
||||
|
||||
def show_totals_now?
|
||||
expired? || !hide_totals?
|
||||
end
|
||||
end
|
||||
39
app/models/poll_vote.rb
Normal file
39
app/models/poll_vote.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: poll_votes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# poll_id :bigint(8)
|
||||
# choice :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# uri :string
|
||||
#
|
||||
|
||||
class PollVote < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :poll, inverse_of: :votes
|
||||
|
||||
validates :choice, presence: true
|
||||
validates_with VoteValidator
|
||||
|
||||
after_create_commit :increment_counter_cache
|
||||
|
||||
delegate :local?, to: :account
|
||||
|
||||
def object_type
|
||||
:vote
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def increment_counter_cache
|
||||
poll.cached_tallies[choice] = (poll.cached_tallies[choice] || 0) + 1
|
||||
poll.save
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
poll.reload
|
||||
retry
|
||||
end
|
||||
end
|
||||
86
app/models/preview_card.rb
Normal file
86
app/models/preview_card.rb
Normal file
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: preview_cards
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# url :string default(""), not null
|
||||
# title :string default(""), not null
|
||||
# description :string default(""), not null
|
||||
# image_file_name :string
|
||||
# image_content_type :string
|
||||
# image_file_size :integer
|
||||
# image_updated_at :datetime
|
||||
# type :integer default("link"), not null
|
||||
# html :text default(""), not null
|
||||
# author_name :string default(""), not null
|
||||
# author_url :string default(""), not null
|
||||
# provider_name :string default(""), not null
|
||||
# provider_url :string default(""), not null
|
||||
# width :integer default(0), not null
|
||||
# height :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# embed_url :string default(""), not null
|
||||
#
|
||||
|
||||
class PreviewCard < ApplicationRecord
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 1.megabytes
|
||||
|
||||
self.inheritance_column = false
|
||||
|
||||
enum type: [:link, :photo, :video, :rich]
|
||||
|
||||
has_and_belongs_to_many :statuses
|
||||
|
||||
has_attached_file :image, styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
|
||||
|
||||
include Attachmentable
|
||||
|
||||
validates :url, presence: true, uniqueness: true
|
||||
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :image, less_than: LIMIT
|
||||
remotable_attachment :image, LIMIT
|
||||
|
||||
before_save :extract_dimensions, if: :link?
|
||||
|
||||
def save_with_optional_image!
|
||||
save!
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
self.image = nil
|
||||
save!
|
||||
end
|
||||
|
||||
class << self
|
||||
private
|
||||
|
||||
def image_styles(f)
|
||||
styles = {
|
||||
original: {
|
||||
geometry: '400x400>',
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
convert_options: '-coalesce -strip',
|
||||
},
|
||||
}
|
||||
|
||||
styles[:original][:format] = 'jpg' if f.instance.image_content_type == 'image/gif'
|
||||
styles
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_dimensions
|
||||
file = image.queued_for_write[:original]
|
||||
|
||||
return if file.nil?
|
||||
|
||||
width, height = FastImage.size(file.path)
|
||||
|
||||
return nil if width.nil?
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
end
|
||||
end
|
||||
80
app/models/relay.rb
Normal file
80
app/models/relay.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: relays
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# inbox_url :string default(""), not null
|
||||
# follow_activity_id :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# state :integer default("idle"), not null
|
||||
#
|
||||
|
||||
class Relay < ApplicationRecord
|
||||
PRESET_RELAY = 'https://relay.gab.com/inbox'
|
||||
|
||||
validates :inbox_url, presence: true, uniqueness: true, url: true, if: :will_save_change_to_inbox_url?
|
||||
|
||||
enum state: [:idle, :pending, :accepted, :rejected]
|
||||
|
||||
scope :enabled, -> { accepted }
|
||||
|
||||
before_destroy :ensure_disabled
|
||||
|
||||
alias enabled? accepted?
|
||||
|
||||
def enable!
|
||||
activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil)
|
||||
payload = Oj.dump(follow_activity(activity_id))
|
||||
|
||||
update!(state: :pending, follow_activity_id: activity_id)
|
||||
DeliveryFailureTracker.new(inbox_url).track_success!
|
||||
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
|
||||
end
|
||||
|
||||
def disable!
|
||||
activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil)
|
||||
payload = Oj.dump(unfollow_activity(activity_id))
|
||||
|
||||
update!(state: :idle, follow_activity_id: nil)
|
||||
DeliveryFailureTracker.new(inbox_url).track_success!
|
||||
ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def follow_activity(activity_id)
|
||||
{
|
||||
'@context': ActivityPub::TagManager::CONTEXT,
|
||||
id: activity_id,
|
||||
type: 'Follow',
|
||||
actor: ActivityPub::TagManager.instance.uri_for(some_local_account),
|
||||
object: ActivityPub::TagManager::COLLECTIONS[:public],
|
||||
}
|
||||
end
|
||||
|
||||
def unfollow_activity(activity_id)
|
||||
{
|
||||
'@context': ActivityPub::TagManager::CONTEXT,
|
||||
id: activity_id,
|
||||
type: 'Undo',
|
||||
actor: ActivityPub::TagManager.instance.uri_for(some_local_account),
|
||||
object: {
|
||||
id: follow_activity_id,
|
||||
type: 'Follow',
|
||||
actor: ActivityPub::TagManager.instance.uri_for(some_local_account),
|
||||
object: ActivityPub::TagManager::COLLECTIONS[:public],
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def some_local_account
|
||||
@some_local_account ||= Account.representative
|
||||
end
|
||||
|
||||
def ensure_disabled
|
||||
return unless enabled?
|
||||
disable!
|
||||
end
|
||||
end
|
||||
56
app/models/remote_follow.rb
Normal file
56
app/models/remote_follow.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RemoteFollow
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_accessor :acct, :addressable_template
|
||||
|
||||
validates :acct, presence: true
|
||||
|
||||
def initialize(attrs = nil)
|
||||
@acct = attrs[:acct].gsub(/\A@/, '').strip if !attrs.nil? && !attrs[:acct].nil?
|
||||
end
|
||||
|
||||
def valid?
|
||||
return false unless super
|
||||
|
||||
populate_template
|
||||
errors.empty?
|
||||
end
|
||||
|
||||
def subscribe_address_for(account)
|
||||
addressable_template.expand(uri: account.local_username_and_domain).to_s
|
||||
end
|
||||
|
||||
def interact_address_for(status)
|
||||
addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(status)).to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def populate_template
|
||||
if acct.blank? || redirect_url_link.nil? || redirect_url_link.template.nil?
|
||||
missing_resource_error
|
||||
else
|
||||
@addressable_template = Addressable::Template.new(redirect_uri_template)
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_uri_template
|
||||
redirect_url_link.template
|
||||
end
|
||||
|
||||
def redirect_url_link
|
||||
acct_resource&.link('http://ostatus.org/schema/1.0/subscribe')
|
||||
end
|
||||
|
||||
def acct_resource
|
||||
@_acct_resource ||= Goldfinger.finger("acct:#{acct}")
|
||||
rescue Goldfinger::Error, HTTP::ConnectionError
|
||||
nil
|
||||
end
|
||||
|
||||
def missing_resource_error
|
||||
errors.add(:acct, I18n.t('remote_follow.missing_resource'))
|
||||
end
|
||||
end
|
||||
57
app/models/remote_profile.rb
Normal file
57
app/models/remote_profile.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RemoteProfile
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_reader :document
|
||||
|
||||
def initialize(body)
|
||||
@document = Nokogiri::XML.parse(body, nil, 'utf-8')
|
||||
end
|
||||
|
||||
def root
|
||||
@root ||= document.at_xpath('/atom:feed|/atom:entry', atom: OStatus::TagManager::XMLNS)
|
||||
end
|
||||
|
||||
def author
|
||||
@author ||= root.at_xpath('./atom:author|./dfrn:owner', atom: OStatus::TagManager::XMLNS, dfrn: OStatus::TagManager::DFRN_XMLNS)
|
||||
end
|
||||
|
||||
def hub_link
|
||||
@hub_link ||= link_href_from_xml(root, 'hub')
|
||||
end
|
||||
|
||||
def display_name
|
||||
@display_name ||= author.at_xpath('./poco:displayName', poco: OStatus::TagManager::POCO_XMLNS)&.content
|
||||
end
|
||||
|
||||
def note
|
||||
@note ||= author.at_xpath('./atom:summary|./poco:note', atom: OStatus::TagManager::XMLNS, poco: OStatus::TagManager::POCO_XMLNS)&.content
|
||||
end
|
||||
|
||||
def scope
|
||||
@scope ||= author.at_xpath('./gabsocial:scope', gabsocial: OStatus::TagManager::GABSCL_XMLNS)&.content
|
||||
end
|
||||
|
||||
def avatar
|
||||
@avatar ||= link_href_from_xml(author, 'avatar')
|
||||
end
|
||||
|
||||
def header
|
||||
@header ||= link_href_from_xml(author, 'header')
|
||||
end
|
||||
|
||||
def emojis
|
||||
@emojis ||= author.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS)
|
||||
end
|
||||
|
||||
def locked?
|
||||
scope == 'private'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def link_href_from_xml(xml, type)
|
||||
xml.at_xpath(%(./atom:link[@rel="#{type}"]/@href), atom: OStatus::TagManager::XMLNS)&.content
|
||||
end
|
||||
end
|
||||
103
app/models/report.rb
Normal file
103
app/models/report.rb
Normal file
@@ -0,0 +1,103 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: reports
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_ids :bigint(8) default([]), not null, is an Array
|
||||
# comment :text default(""), not null
|
||||
# action_taken :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# action_taken_by_account_id :bigint(8)
|
||||
# target_account_id :bigint(8) not null
|
||||
# assigned_account_id :bigint(8)
|
||||
# uri :string
|
||||
#
|
||||
|
||||
class Report < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
belongs_to :action_taken_by_account, class_name: 'Account', optional: true
|
||||
belongs_to :assigned_account, class_name: 'Account', optional: true
|
||||
|
||||
has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy
|
||||
|
||||
scope :unresolved, -> { where(action_taken: false) }
|
||||
scope :resolved, -> { where(action_taken: true) }
|
||||
|
||||
validates :comment, length: { maximum: 1000 }
|
||||
|
||||
def local?
|
||||
false # Force uri_for to use uri attribute
|
||||
end
|
||||
|
||||
before_validation :set_uri, only: :create
|
||||
|
||||
def object_type
|
||||
:flag
|
||||
end
|
||||
|
||||
def statuses
|
||||
Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
|
||||
end
|
||||
|
||||
def media_attachments
|
||||
MediaAttachment.where(status_id: status_ids)
|
||||
end
|
||||
|
||||
def assign_to_self!(current_account)
|
||||
update!(assigned_account_id: current_account.id)
|
||||
end
|
||||
|
||||
def unassign!
|
||||
update!(assigned_account_id: nil)
|
||||
end
|
||||
|
||||
def resolve!(acting_account)
|
||||
update!(action_taken: true, action_taken_by_account_id: acting_account.id)
|
||||
end
|
||||
|
||||
def unresolve!
|
||||
update!(action_taken: false, action_taken_by_account_id: nil)
|
||||
end
|
||||
|
||||
def unresolved?
|
||||
!action_taken?
|
||||
end
|
||||
|
||||
def unresolved_siblings?
|
||||
Report.where.not(id: id).where(target_account_id: target_account_id).unresolved.exists?
|
||||
end
|
||||
|
||||
def history
|
||||
time_range = created_at..updated_at
|
||||
|
||||
sql = [
|
||||
Admin::ActionLog.where(
|
||||
target_type: 'Report',
|
||||
target_id: id,
|
||||
created_at: time_range
|
||||
).unscope(:order),
|
||||
|
||||
Admin::ActionLog.where(
|
||||
target_type: 'Account',
|
||||
target_id: target_account_id,
|
||||
created_at: time_range
|
||||
).unscope(:order),
|
||||
|
||||
Admin::ActionLog.where(
|
||||
target_type: 'Status',
|
||||
target_id: status_ids,
|
||||
created_at: time_range
|
||||
).unscope(:order),
|
||||
].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ')
|
||||
|
||||
Admin::ActionLog.from("(#{sql}) AS admin_action_logs")
|
||||
end
|
||||
|
||||
def set_uri
|
||||
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? && account.local?
|
||||
end
|
||||
end
|
||||
30
app/models/report_filter.rb
Normal file
30
app/models/report_filter.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReportFilter
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def results
|
||||
scope = Report.unresolved
|
||||
params.each do |key, value|
|
||||
scope = scope.merge scope_for(key, value)
|
||||
end
|
||||
scope
|
||||
end
|
||||
|
||||
def scope_for(key, value)
|
||||
case key.to_sym
|
||||
when :resolved
|
||||
Report.resolved
|
||||
when :account_id
|
||||
Report.where(account_id: value)
|
||||
when :target_account_id
|
||||
Report.where(target_account_id: value)
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
end
|
||||
21
app/models/report_note.rb
Normal file
21
app/models/report_note.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: report_notes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# content :text not null
|
||||
# report_id :bigint(8) not null
|
||||
# account_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class ReportNote < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :report, inverse_of: :notes, touch: true
|
||||
|
||||
scope :latest, -> { reorder(created_at: :desc) }
|
||||
|
||||
validates :content, presence: true, length: { maximum: 500 }
|
||||
end
|
||||
39
app/models/scheduled_status.rb
Normal file
39
app/models/scheduled_status.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: scheduled_statuses
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# scheduled_at :datetime
|
||||
# params :jsonb
|
||||
#
|
||||
|
||||
class ScheduledStatus < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
TOTAL_LIMIT = 300
|
||||
DAILY_LIMIT = 25
|
||||
|
||||
belongs_to :account, inverse_of: :scheduled_statuses
|
||||
has_many :media_attachments, inverse_of: :scheduled_status, dependent: :nullify
|
||||
|
||||
validate :validate_future_date
|
||||
validate :validate_total_limit
|
||||
validate :validate_daily_limit
|
||||
|
||||
private
|
||||
|
||||
def validate_future_date
|
||||
errors.add(:scheduled_at, I18n.t('scheduled_statuses.too_soon')) if scheduled_at.present? && scheduled_at <= Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET
|
||||
end
|
||||
|
||||
def validate_total_limit
|
||||
errors.add(:base, I18n.t('scheduled_statuses.over_total_limit', limit: TOTAL_LIMIT)) if account.scheduled_statuses.count >= TOTAL_LIMIT
|
||||
end
|
||||
|
||||
def validate_daily_limit
|
||||
errors.add(:base, I18n.t('scheduled_statuses.over_daily_limit', limit: DAILY_LIMIT)) if account.scheduled_statuses.where('scheduled_at::date = ?::date', scheduled_at).count >= DAILY_LIMIT
|
||||
end
|
||||
end
|
||||
5
app/models/search.rb
Normal file
5
app/models/search.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Search < ActiveModelSerializers::Model
|
||||
attributes :accounts, :statuses, :hashtags
|
||||
end
|
||||
81
app/models/session_activation.rb
Normal file
81
app/models/session_activation.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: session_activations
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# session_id :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_agent :string default(""), not null
|
||||
# ip :inet
|
||||
# access_token_id :bigint(8)
|
||||
# user_id :bigint(8) not null
|
||||
# web_push_subscription_id :bigint(8)
|
||||
#
|
||||
|
||||
class SessionActivation < ApplicationRecord
|
||||
belongs_to :user, inverse_of: :session_activations
|
||||
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy, optional: true
|
||||
belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy, optional: true
|
||||
|
||||
delegate :token,
|
||||
to: :access_token,
|
||||
allow_nil: true
|
||||
|
||||
def detection
|
||||
@detection ||= Browser.new(user_agent)
|
||||
end
|
||||
|
||||
def browser
|
||||
detection.id
|
||||
end
|
||||
|
||||
def platform
|
||||
detection.platform.id
|
||||
end
|
||||
|
||||
before_create :assign_access_token
|
||||
before_save :assign_user_agent
|
||||
|
||||
class << self
|
||||
def active?(id)
|
||||
id && where(session_id: id).exists?
|
||||
end
|
||||
|
||||
def activate(**options)
|
||||
activation = create!(options)
|
||||
purge_old
|
||||
activation
|
||||
end
|
||||
|
||||
def deactivate(id)
|
||||
return unless id
|
||||
where(session_id: id).destroy_all
|
||||
end
|
||||
|
||||
def purge_old
|
||||
order('created_at desc').offset(Rails.configuration.x.max_session_activations).destroy_all
|
||||
end
|
||||
|
||||
def exclusive(id)
|
||||
where('session_id != ?', id).destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_user_agent
|
||||
self.user_agent = '' if user_agent.nil?
|
||||
end
|
||||
|
||||
def assign_access_token
|
||||
superapp = Doorkeeper::Application.find_by(superapp: true)
|
||||
|
||||
self.access_token = Doorkeeper::AccessToken.create!(application_id: superapp&.id,
|
||||
resource_owner_id: user_id,
|
||||
scopes: 'read write follow',
|
||||
expires_in: Doorkeeper.configuration.access_token_expires_in,
|
||||
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?)
|
||||
end
|
||||
end
|
||||
58
app/models/setting.rb
Normal file
58
app/models/setting.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: settings
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# var :string not null
|
||||
# value :text
|
||||
# thing_type :string
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# thing_id :bigint(8)
|
||||
#
|
||||
|
||||
class Setting < RailsSettings::Base
|
||||
source Rails.root.join('config', 'settings.yml')
|
||||
|
||||
def to_param
|
||||
var
|
||||
end
|
||||
|
||||
class << self
|
||||
def [](key)
|
||||
return super(key) unless rails_initialized?
|
||||
|
||||
val = Rails.cache.fetch(cache_key(key, nil)) do
|
||||
db_val = object(key)
|
||||
|
||||
if db_val
|
||||
default_value = default_settings[key]
|
||||
|
||||
return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash)
|
||||
db_val.value
|
||||
else
|
||||
default_settings[key]
|
||||
end
|
||||
end
|
||||
val
|
||||
end
|
||||
|
||||
def all_as_records
|
||||
vars = thing_scoped
|
||||
records = vars.each_with_object({}) { |r, h| h[r.var] = r }
|
||||
|
||||
default_settings.each do |key, default_value|
|
||||
next if records.key?(key) || default_value.is_a?(Hash)
|
||||
records[key] = Setting.new(var: key, value: default_value)
|
||||
end
|
||||
|
||||
records
|
||||
end
|
||||
|
||||
def default_settings
|
||||
return {} unless RailsSettings::Default.enabled?
|
||||
RailsSettings::Default.instance
|
||||
end
|
||||
end
|
||||
end
|
||||
45
app/models/site_upload.rb
Normal file
45
app/models/site_upload.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: site_uploads
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# var :string default(""), not null
|
||||
# file_file_name :string
|
||||
# file_content_type :string
|
||||
# file_file_size :integer
|
||||
# file_updated_at :datetime
|
||||
# meta :json
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class SiteUpload < ApplicationRecord
|
||||
has_attached_file :file
|
||||
|
||||
validates_attachment_content_type :file, content_type: /\Aimage\/.*\z/
|
||||
validates :file, presence: true
|
||||
validates :var, presence: true, uniqueness: true
|
||||
|
||||
before_save :set_meta
|
||||
after_commit :clear_cache
|
||||
|
||||
def cache_key
|
||||
"site_uploads/#{var}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_meta
|
||||
tempfile = file.queued_for_write[:original]
|
||||
|
||||
return if tempfile.nil?
|
||||
|
||||
width, height = FastImage.size(tempfile.path)
|
||||
self.meta = { width: width, height: height }
|
||||
end
|
||||
|
||||
def clear_cache
|
||||
Rails.cache.delete(cache_key)
|
||||
end
|
||||
end
|
||||
538
app/models/status.rb
Normal file
538
app/models/status.rb
Normal file
@@ -0,0 +1,538 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: statuses
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# uri :string
|
||||
# text :text default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# in_reply_to_id :bigint(8)
|
||||
# reblog_of_id :bigint(8)
|
||||
# url :string
|
||||
# sensitive :boolean default(FALSE), not null
|
||||
# visibility :integer default("public"), not null
|
||||
# spoiler_text :text default(""), not null
|
||||
# reply :boolean default(FALSE), not null
|
||||
# language :string
|
||||
# conversation_id :bigint(8)
|
||||
# local :boolean
|
||||
# account_id :bigint(8) not null
|
||||
# application_id :bigint(8)
|
||||
# in_reply_to_account_id :bigint(8)
|
||||
# poll_id :bigint(8)
|
||||
# group_id :integer
|
||||
#
|
||||
|
||||
class Status < ApplicationRecord
|
||||
before_destroy :unlink_from_conversations
|
||||
|
||||
include Paginable
|
||||
include Streamable
|
||||
include Cacheable
|
||||
include StatusThreadingConcern
|
||||
|
||||
# If `override_timestamps` is set at creation time, Snowflake ID creation
|
||||
# will be based on current time instead of `created_at`
|
||||
attr_accessor :override_timestamps
|
||||
|
||||
update_index('statuses#status', :proper) if Chewy.enabled?
|
||||
|
||||
enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
|
||||
|
||||
belongs_to :application, class_name: 'Doorkeeper::Application', optional: 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 :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true
|
||||
belongs_to :group, 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, optional: true
|
||||
|
||||
has_many :favourites, 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, inverse_of: :status
|
||||
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
||||
has_many :media_attachments, dependent: :nullify
|
||||
|
||||
has_and_belongs_to_many :tags
|
||||
has_and_belongs_to_many :preview_cards
|
||||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
has_one :stream_entry, as: :activity, inverse_of: :status
|
||||
has_one :status_stat, inverse_of: :status
|
||||
has_one :poll, inverse_of: :status, dependent: :destroy
|
||||
|
||||
validates :uri, uniqueness: true, presence: true, unless: :local?
|
||||
validates :text, presence: true, unless: -> { with_media? || reblog? }
|
||||
validates_with StatusLengthValidator
|
||||
validates_with DisallowedHashtagsValidator
|
||||
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
|
||||
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
|
||||
|
||||
accepts_nested_attributes_for :poll
|
||||
|
||||
default_scope { recent }
|
||||
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :remote, -> { where(local: false).or(where.not(uri: nil)) }
|
||||
scope :local, -> { where(local: true).or(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') }
|
||||
scope :with_public_visibility, -> { where(visibility: :public) }
|
||||
scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
|
||||
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
|
||||
scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
|
||||
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
|
||||
scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
|
||||
scope :tagged_with_all, ->(tags) {
|
||||
Array(tags).map(&:id).map(&:to_i).reduce(self) do |result, id|
|
||||
result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
|
||||
end
|
||||
}
|
||||
scope :tagged_with_none, ->(tags) {
|
||||
Array(tags).map(&:id).map(&:to_i).reduce(self) do |result, id|
|
||||
result.joins("LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
|
||||
.where("t#{id}.tag_id IS NULL")
|
||||
end
|
||||
}
|
||||
|
||||
cache_associated :application,
|
||||
:media_attachments,
|
||||
:conversation,
|
||||
:status_stat,
|
||||
:tags,
|
||||
:preview_cards,
|
||||
:stream_entry,
|
||||
:preloadable_poll,
|
||||
account: :account_stat,
|
||||
active_mentions: { account: :account_stat },
|
||||
reblog: [
|
||||
:application,
|
||||
:stream_entry,
|
||||
:tags,
|
||||
:preview_cards,
|
||||
:media_attachments,
|
||||
:conversation,
|
||||
:status_stat,
|
||||
:preloadable_poll,
|
||||
account: :account_stat,
|
||||
active_mentions: { account: :account_stat },
|
||||
],
|
||||
thread: { account: :account_stat }
|
||||
|
||||
delegate :domain, to: :account, prefix: true
|
||||
|
||||
REAL_TIME_WINDOW = 6.hours
|
||||
|
||||
def searchable_by(preloaded = nil)
|
||||
ids = [account_id]
|
||||
|
||||
if preloaded.nil?
|
||||
ids += mentions.pluck(:account_id)
|
||||
ids += favourites.pluck(:account_id)
|
||||
ids += reblogs.pluck(:account_id)
|
||||
else
|
||||
ids += preloaded.mentions[id] || []
|
||||
ids += preloaded.favourites[id] || []
|
||||
ids += preloaded.reblogs[id] || []
|
||||
end
|
||||
|
||||
ids.uniq
|
||||
end
|
||||
|
||||
def reply?
|
||||
!in_reply_to_id.nil? || attributes['reply']
|
||||
end
|
||||
|
||||
def local?
|
||||
attributes['local'] || uri.nil?
|
||||
end
|
||||
|
||||
def reblog?
|
||||
!reblog_of_id.nil?
|
||||
end
|
||||
|
||||
def within_realtime_window?
|
||||
created_at >= REAL_TIME_WINDOW.ago
|
||||
end
|
||||
|
||||
def verb
|
||||
if destroyed?
|
||||
:delete
|
||||
else
|
||||
reblog? ? :share : :post
|
||||
end
|
||||
end
|
||||
|
||||
def object_type
|
||||
reply? ? :comment : :note
|
||||
end
|
||||
|
||||
def proper
|
||||
reblog? ? reblog : self
|
||||
end
|
||||
|
||||
def content
|
||||
proper.text
|
||||
end
|
||||
|
||||
def target
|
||||
reblog
|
||||
end
|
||||
|
||||
def preview_card
|
||||
preview_cards.first
|
||||
end
|
||||
|
||||
def title
|
||||
if destroyed?
|
||||
"#{account.acct} deleted status"
|
||||
else
|
||||
reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
|
||||
end
|
||||
end
|
||||
|
||||
def hidden?
|
||||
private_visibility? || direct_visibility? || limited_visibility?
|
||||
end
|
||||
|
||||
def distributable?
|
||||
public_visibility? || unlisted_visibility?
|
||||
end
|
||||
|
||||
def with_media?
|
||||
media_attachments.any?
|
||||
end
|
||||
|
||||
def non_sensitive_with_media?
|
||||
!sensitive? && with_media?
|
||||
end
|
||||
|
||||
def emojis
|
||||
return @emojis if defined?(@emojis)
|
||||
|
||||
fields = [spoiler_text, text]
|
||||
fields += preloadable_poll.options unless preloadable_poll.nil?
|
||||
|
||||
@emojis = CustomEmoji.from_text(fields.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
|
||||
|
||||
def replies_count
|
||||
status_stat&.replies_count || 0
|
||||
end
|
||||
|
||||
def reblogs_count
|
||||
status_stat&.reblogs_count || 0
|
||||
end
|
||||
|
||||
def favourites_count
|
||||
status_stat&.favourites_count || 0
|
||||
end
|
||||
|
||||
def increment_count!(key)
|
||||
update_status_stat!(key => public_send(key) + 1)
|
||||
end
|
||||
|
||||
def decrement_count!(key)
|
||||
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
||||
end
|
||||
|
||||
after_create_commit :increment_counter_caches
|
||||
after_destroy_commit :decrement_counter_caches
|
||||
|
||||
after_create_commit :store_uri, if: :local?
|
||||
after_create_commit :update_statistics, if: :local?
|
||||
|
||||
around_create GabSocial::Snowflake::Callbacks
|
||||
|
||||
before_validation :prepare_contents, if: :local?
|
||||
before_validation :set_reblog
|
||||
before_validation :set_visibility
|
||||
before_validation :set_conversation
|
||||
before_validation :set_group_id
|
||||
before_validation :set_local
|
||||
|
||||
after_create :set_poll_id
|
||||
|
||||
class << self
|
||||
def selectable_visibilities
|
||||
visibilities.keys - %w(direct limited)
|
||||
end
|
||||
|
||||
def in_chosen_languages(account)
|
||||
where(language: nil).or where(language: account.chosen_languages)
|
||||
end
|
||||
|
||||
def as_home_timeline(account)
|
||||
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
|
||||
end
|
||||
|
||||
def as_group_timeline(group)
|
||||
where(group: group)
|
||||
end
|
||||
|
||||
def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
|
||||
# direct timeline is mix of direct message from_me and to_me.
|
||||
# 2 queries are executed with pagination.
|
||||
# constant expression using arel_table is required for partial index
|
||||
|
||||
# _from_me part does not require any timeline filters
|
||||
query_from_me = where(account_id: account.id)
|
||||
.where(Status.arel_table[:visibility].eq(3))
|
||||
.limit(limit)
|
||||
.order('statuses.id DESC')
|
||||
|
||||
# _to_me part requires mute and block filter.
|
||||
# FIXME: may we check mutes.hide_notifications?
|
||||
query_to_me = Status
|
||||
.joins(:mentions)
|
||||
.merge(Mention.where(account_id: account.id))
|
||||
.where(Status.arel_table[:visibility].eq(3))
|
||||
.limit(limit)
|
||||
.order('mentions.status_id DESC')
|
||||
.not_excluded_by_account(account)
|
||||
|
||||
if max_id.present?
|
||||
query_from_me = query_from_me.where('statuses.id < ?', max_id)
|
||||
query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
|
||||
end
|
||||
|
||||
if since_id.present?
|
||||
query_from_me = query_from_me.where('statuses.id > ?', since_id)
|
||||
query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
|
||||
end
|
||||
|
||||
if cache_ids
|
||||
# returns array of cache_ids object that have id and updated_at
|
||||
(query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
|
||||
else
|
||||
# returns ActiveRecord.Relation
|
||||
items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
|
||||
Status.where(id: items.map(&:id))
|
||||
end
|
||||
end
|
||||
|
||||
def as_public_timeline(account = nil, local_only = false)
|
||||
query = timeline_scope(local_only).without_replies
|
||||
|
||||
apply_timeline_filters(query, account, local_only)
|
||||
end
|
||||
|
||||
def as_tag_timeline(tag, account = nil, local_only = false)
|
||||
query = timeline_scope(local_only).tagged_with(tag)
|
||||
|
||||
apply_timeline_filters(query, account, local_only)
|
||||
end
|
||||
|
||||
def as_outbox_timeline(account)
|
||||
where(account: account, visibility: :public)
|
||||
end
|
||||
|
||||
def favourites_map(status_ids, account_id)
|
||||
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
|
||||
end
|
||||
|
||||
def reblogs_map(status_ids, account_id)
|
||||
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).each_with_object({}) { |s, h| h[s.reblog_of_id] = true }
|
||||
end
|
||||
|
||||
def mutes_map(conversation_ids, account_id)
|
||||
ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
|
||||
end
|
||||
|
||||
def pins_map(status_ids, account_id)
|
||||
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||
end
|
||||
|
||||
def reload_stale_associations!(cached_items)
|
||||
account_ids = []
|
||||
|
||||
cached_items.each do |item|
|
||||
account_ids << item.account_id
|
||||
account_ids << item.reblog.account_id if item.reblog?
|
||||
end
|
||||
|
||||
account_ids.uniq!
|
||||
|
||||
return if account_ids.empty?
|
||||
|
||||
accounts = Account.where(id: account_ids).includes(:account_stat).each_with_object({}) { |a, h| h[a.id] = a }
|
||||
|
||||
cached_items.each do |item|
|
||||
item.account = accounts[item.account_id]
|
||||
item.reblog.account = accounts[item.reblog.account_id] if item.reblog?
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_for(target_account, account)
|
||||
visibility = [:public, :unlisted]
|
||||
|
||||
if account.nil?
|
||||
where(visibility: visibility)
|
||||
elsif target_account.blocking?(account) # get rid of blocked peeps
|
||||
none
|
||||
elsif account.id == target_account.id # author can see own stuff
|
||||
all
|
||||
else
|
||||
# followers can see followers-only stuff, but also things they are mentioned in.
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def timeline_scope(local_only = false)
|
||||
starting_scope = local_only ? Status.local : Status
|
||||
starting_scope
|
||||
.with_public_visibility
|
||||
.without_reblogs
|
||||
end
|
||||
|
||||
def apply_timeline_filters(query, account, local_only)
|
||||
if account.nil?
|
||||
filter_timeline_default(query)
|
||||
else
|
||||
filter_timeline_for_account(query, account, local_only)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_timeline_for_account(query, account, local_only)
|
||||
query = query.not_excluded_by_account(account)
|
||||
query = query.not_domain_blocked_by_account(account) unless local_only
|
||||
query = query.in_chosen_languages(account) if account.chosen_languages.present?
|
||||
query.merge(account_silencing_filter(account))
|
||||
end
|
||||
|
||||
def filter_timeline_default(query)
|
||||
query.excluding_silenced_accounts
|
||||
end
|
||||
|
||||
def account_silencing_filter(account)
|
||||
if account.silenced?
|
||||
including_myself = left_outer_joins(:account).where(account_id: account.id).references(:accounts)
|
||||
excluding_silenced_accounts.or(including_myself)
|
||||
else
|
||||
excluding_silenced_accounts
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_status_stat!(attrs)
|
||||
return if marked_for_destruction? || destroyed?
|
||||
|
||||
record = status_stat || build_status_stat
|
||||
record.update(attrs)
|
||||
end
|
||||
|
||||
def store_uri
|
||||
update_column(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil?
|
||||
end
|
||||
|
||||
def prepare_contents
|
||||
text&.strip!
|
||||
spoiler_text&.strip!
|
||||
end
|
||||
|
||||
def set_reblog
|
||||
self.reblog = reblog.reblog if reblog? && reblog.reblog?
|
||||
end
|
||||
|
||||
def set_poll_id
|
||||
update_column(:poll_id, poll.id) unless poll.nil?
|
||||
end
|
||||
|
||||
def set_visibility
|
||||
self.visibility = reblog.visibility if reblog? && visibility.nil?
|
||||
self.visibility = (account.locked? ? :private : :public) if visibility.nil?
|
||||
self.sensitive = false if sensitive.nil?
|
||||
end
|
||||
|
||||
def set_group_id
|
||||
self.group_id = thread.group_id if thread&.group_id?
|
||||
|
||||
if reply? && !thread.nil?
|
||||
replied_status = Status.find(in_reply_to_id)
|
||||
self.group_id = replied_status.group_id
|
||||
end
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
self.thread = thread.reblog if thread&.reblog?
|
||||
|
||||
self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
|
||||
|
||||
if reply? && !thread.nil?
|
||||
self.in_reply_to_account_id = carried_over_reply_to_account_id
|
||||
self.conversation_id = thread.conversation_id if conversation_id.nil?
|
||||
elsif conversation_id.nil?
|
||||
self.conversation = Conversation.new
|
||||
end
|
||||
end
|
||||
|
||||
def carried_over_reply_to_account_id
|
||||
if thread.account_id == account_id && thread.reply?
|
||||
thread.in_reply_to_account_id
|
||||
else
|
||||
thread.account_id
|
||||
end
|
||||
end
|
||||
|
||||
def set_local
|
||||
self.local = account.local?
|
||||
end
|
||||
|
||||
def update_statistics
|
||||
return unless public_visibility? || unlisted_visibility?
|
||||
ActivityTracker.increment('activity:statuses:local')
|
||||
end
|
||||
|
||||
def increment_counter_caches
|
||||
return if direct_visibility?
|
||||
|
||||
account&.increment_count!(:statuses_count)
|
||||
reblog&.increment_count!(:reblogs_count) if reblog? && (public_visibility? || unlisted_visibility?)
|
||||
thread&.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
|
||||
end
|
||||
|
||||
def decrement_counter_caches
|
||||
return if direct_visibility? || marked_for_mass_destruction?
|
||||
|
||||
account&.decrement_count!(:statuses_count)
|
||||
reblog&.decrement_count!(:reblogs_count) if reblog? && (public_visibility? || unlisted_visibility?)
|
||||
thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
|
||||
end
|
||||
|
||||
def unlink_from_conversations
|
||||
return unless direct_visibility?
|
||||
|
||||
mentioned_accounts = mentions.includes(:account).map(&:account)
|
||||
inbox_owners = mentioned_accounts.select(&:local?) + (account.local? ? [account] : [])
|
||||
|
||||
inbox_owners.each do |inbox_owner|
|
||||
AccountConversation.remove_status(inbox_owner, self)
|
||||
end
|
||||
end
|
||||
end
|
||||
18
app/models/status_pin.rb
Normal file
18
app/models/status_pin.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: status_pins
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# status_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class StatusPin < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :status
|
||||
|
||||
validates_with StatusPinValidator
|
||||
end
|
||||
25
app/models/status_stat.rb
Normal file
25
app/models/status_stat.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: status_stats
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_id :bigint(8) not null
|
||||
# replies_count :bigint(8) default(0), not null
|
||||
# reblogs_count :bigint(8) default(0), not null
|
||||
# favourites_count :bigint(8) default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class StatusStat < ApplicationRecord
|
||||
belongs_to :status, inverse_of: :status_stat
|
||||
|
||||
after_commit :reset_parent_cache
|
||||
|
||||
private
|
||||
|
||||
def reset_parent_cache
|
||||
Rails.cache.delete("statuses/#{status_id}")
|
||||
end
|
||||
end
|
||||
59
app/models/stream_entry.rb
Normal file
59
app/models/stream_entry.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: stream_entries
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# activity_id :bigint(8)
|
||||
# activity_type :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# hidden :boolean default(FALSE), not null
|
||||
# account_id :bigint(8)
|
||||
#
|
||||
|
||||
class StreamEntry < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
belongs_to :account, inverse_of: :stream_entries
|
||||
belongs_to :activity, polymorphic: true
|
||||
belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', inverse_of: :stream_entry
|
||||
|
||||
validates :account, :activity, presence: true
|
||||
|
||||
STATUS_INCLUDES = [:account, :stream_entry, :conversation, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :conversation, :media_attachments, :tags, mentions: :account], thread: [:stream_entry, :account]].freeze
|
||||
|
||||
default_scope { where(activity_type: 'Status') }
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
|
||||
|
||||
delegate :target, :title, :content, :thread,
|
||||
to: :status,
|
||||
allow_nil: true
|
||||
|
||||
def object_type
|
||||
orphaned? || targeted? ? :activity : status.object_type
|
||||
end
|
||||
|
||||
def verb
|
||||
orphaned? ? :delete : status.verb
|
||||
end
|
||||
|
||||
def targeted?
|
||||
[:follow, :request_friend, :authorize, :reject, :unfollow, :block, :unblock, :share, :favorite].include? verb
|
||||
end
|
||||
|
||||
def threaded?
|
||||
(verb == :favorite || object_type == :comment) && !thread.nil?
|
||||
end
|
||||
|
||||
def mentions
|
||||
orphaned? ? [] : status.active_mentions.map(&:account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def orphaned?
|
||||
status.nil?
|
||||
end
|
||||
end
|
||||
62
app/models/subscription.rb
Normal file
62
app/models/subscription.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: subscriptions
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# callback_url :string default(""), not null
|
||||
# secret :string
|
||||
# expires_at :datetime
|
||||
# confirmed :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# last_successful_delivery_at :datetime
|
||||
# domain :string
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class Subscription < ApplicationRecord
|
||||
MIN_EXPIRATION = 1.day.to_i
|
||||
MAX_EXPIRATION = 30.days.to_i
|
||||
|
||||
belongs_to :account
|
||||
|
||||
validates :callback_url, presence: true
|
||||
validates :callback_url, uniqueness: { scope: :account_id }
|
||||
|
||||
scope :confirmed, -> { where(confirmed: true) }
|
||||
scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) }
|
||||
scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) }
|
||||
scope :active, -> { confirmed.future_expiration }
|
||||
|
||||
def lease_seconds=(value)
|
||||
self.expires_at = future_expiration(value)
|
||||
end
|
||||
|
||||
def lease_seconds
|
||||
(expires_at - Time.now.utc).to_i
|
||||
end
|
||||
|
||||
def expired?
|
||||
Time.now.utc > expires_at
|
||||
end
|
||||
|
||||
before_validation :set_min_expiration
|
||||
|
||||
private
|
||||
|
||||
def future_expiration(value)
|
||||
Time.now.utc + future_offset(value).seconds
|
||||
end
|
||||
|
||||
def future_offset(seconds)
|
||||
[
|
||||
[MIN_EXPIRATION, seconds.to_i].max,
|
||||
MAX_EXPIRATION,
|
||||
].min
|
||||
end
|
||||
|
||||
def set_min_expiration
|
||||
self.lease_seconds = 0 unless expires_at
|
||||
end
|
||||
end
|
||||
91
app/models/tag.rb
Normal file
91
app/models/tag.rb
Normal file
@@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tags
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# name :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
has_and_belongs_to_many :statuses
|
||||
has_and_belongs_to_many :accounts
|
||||
has_and_belongs_to_many :sample_accounts, -> { searchable.discoverable.popular.limit(3) }, class_name: 'Account'
|
||||
|
||||
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
|
||||
has_one :account_tag_stat, dependent: :destroy
|
||||
|
||||
HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*'
|
||||
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
|
||||
|
||||
validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i }
|
||||
|
||||
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
|
||||
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
|
||||
scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
|
||||
|
||||
delegate :accounts_count,
|
||||
:accounts_count=,
|
||||
:increment_count!,
|
||||
:decrement_count!,
|
||||
:hidden?,
|
||||
to: :account_tag_stat
|
||||
|
||||
after_save :save_account_tag_stat
|
||||
|
||||
def account_tag_stat
|
||||
super || build_account_tag_stat
|
||||
end
|
||||
|
||||
def cached_sample_accounts
|
||||
Rails.cache.fetch("#{cache_key}/sample_accounts", expires_in: 12.hours) { sample_accounts }
|
||||
end
|
||||
|
||||
def to_param
|
||||
name
|
||||
end
|
||||
|
||||
def history
|
||||
days = []
|
||||
|
||||
7.times do |i|
|
||||
day = i.days.ago.beginning_of_day.to_i
|
||||
|
||||
days << {
|
||||
day: day.to_s,
|
||||
uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0',
|
||||
accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s,
|
||||
}
|
||||
end
|
||||
|
||||
days
|
||||
end
|
||||
|
||||
class << self
|
||||
def search_for(term, limit = 5, offset = 0)
|
||||
pattern = sanitize_sql_like(term.strip) + '%'
|
||||
|
||||
Tag.where('lower(name) like lower(?)', pattern)
|
||||
.order(:name)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
end
|
||||
|
||||
def find_normalized(name)
|
||||
find_by(name: name.mb_chars.downcase.to_s)
|
||||
end
|
||||
|
||||
def find_normalized!(name)
|
||||
find_normalized(name) || raise(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def save_account_tag_stat
|
||||
return unless account_tag_stat&.changed?
|
||||
account_tag_stat.save
|
||||
end
|
||||
end
|
||||
17
app/models/tombstone.rb
Normal file
17
app/models/tombstone.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tombstones
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# uri :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# by_moderator :boolean
|
||||
#
|
||||
|
||||
class Tombstone < ApplicationRecord
|
||||
belongs_to :account
|
||||
end
|
||||
18
app/models/transaction.rb
Normal file
18
app/models/transaction.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: transactions
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# payment_type :string
|
||||
# provider_type :string
|
||||
# provider_response :text
|
||||
# amount :integer not null
|
||||
# success :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class Transaction < ApplicationRecord
|
||||
belongs_to :account, inverse_of: :transactions
|
||||
end
|
||||
65
app/models/trending_tags.rb
Normal file
65
app/models/trending_tags.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TrendingTags
|
||||
KEY = 'trending_tags'
|
||||
EXPIRE_HISTORY_AFTER = 7.days.seconds
|
||||
EXPIRE_TRENDS_AFTER = 1.day.seconds
|
||||
THRESHOLD = 5
|
||||
|
||||
class << self
|
||||
include Redisable
|
||||
|
||||
def record_use!(tag, account, at_time = Time.now.utc)
|
||||
return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
|
||||
|
||||
increment_historical_use!(tag.id, at_time)
|
||||
increment_unique_use!(tag.id, account.id, at_time)
|
||||
increment_vote!(tag.id, at_time)
|
||||
end
|
||||
|
||||
def get(limit)
|
||||
key = "#{KEY}:#{Time.now.utc.beginning_of_day.to_i}"
|
||||
tag_ids = redis.zrevrange(key, 0, limit - 1).map(&:to_i)
|
||||
tags = Tag.where(id: tag_ids).to_a.each_with_object({}) { |tag, h| h[tag.id] = tag }
|
||||
tag_ids.map { |tag_id| tags[tag_id] }.compact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def increment_historical_use!(tag_id, at_time)
|
||||
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
|
||||
redis.incrby(key, 1)
|
||||
redis.expire(key, EXPIRE_HISTORY_AFTER)
|
||||
end
|
||||
|
||||
def increment_unique_use!(tag_id, account_id, at_time)
|
||||
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts"
|
||||
redis.pfadd(key, account_id)
|
||||
redis.expire(key, EXPIRE_HISTORY_AFTER)
|
||||
end
|
||||
|
||||
def increment_vote!(tag_id, at_time)
|
||||
key = "#{KEY}:#{at_time.beginning_of_day.to_i}"
|
||||
expected = redis.pfcount("activity:tags:#{tag_id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
|
||||
expected = 1.0 if expected.zero?
|
||||
observed = redis.pfcount("activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
|
||||
|
||||
if expected > observed || observed < THRESHOLD
|
||||
redis.zrem(key, tag_id.to_s)
|
||||
else
|
||||
score = ((observed - expected)**2) / expected
|
||||
redis.zadd(key, score, tag_id.to_s)
|
||||
end
|
||||
|
||||
redis.expire(key, EXPIRE_TRENDS_AFTER)
|
||||
end
|
||||
|
||||
def disallowed_hashtags
|
||||
return @disallowed_hashtags if defined?(@disallowed_hashtags)
|
||||
|
||||
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
|
||||
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
|
||||
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
|
||||
end
|
||||
end
|
||||
end
|
||||
330
app/models/user.rb
Normal file
330
app/models/user.rb
Normal file
@@ -0,0 +1,330 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: users
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# email :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# encrypted_password :string default(""), not null
|
||||
# reset_password_token :string
|
||||
# reset_password_sent_at :datetime
|
||||
# remember_created_at :datetime
|
||||
# sign_in_count :integer default(0), not null
|
||||
# current_sign_in_at :datetime
|
||||
# last_sign_in_at :datetime
|
||||
# current_sign_in_ip :inet
|
||||
# last_sign_in_ip :inet
|
||||
# admin :boolean default(FALSE), not null
|
||||
# confirmation_token :string
|
||||
# confirmed_at :datetime
|
||||
# confirmation_sent_at :datetime
|
||||
# unconfirmed_email :string
|
||||
# locale :string
|
||||
# encrypted_otp_secret :string
|
||||
# encrypted_otp_secret_iv :string
|
||||
# encrypted_otp_secret_salt :string
|
||||
# consumed_timestep :integer
|
||||
# otp_required_for_login :boolean default(FALSE), not null
|
||||
# last_emailed_at :datetime
|
||||
# otp_backup_codes :string is an Array
|
||||
# filtered_languages :string default([]), not null, is an Array
|
||||
# account_id :bigint(8) not null
|
||||
# disabled :boolean default(FALSE), not null
|
||||
# moderator :boolean default(FALSE), not null
|
||||
# invite_id :bigint(8)
|
||||
# remember_token :string
|
||||
# chosen_languages :string is an Array
|
||||
# created_by_application_id :bigint(8)
|
||||
# approved :boolean default(TRUE), not null
|
||||
#
|
||||
|
||||
class User < ApplicationRecord
|
||||
include Settings::Extend
|
||||
include UserRoles
|
||||
|
||||
# The home and list feeds will be stored in Redis for this amount
|
||||
# of time, and status fan-out to followers will include only people
|
||||
# within this time frame. Lowering the duration may improve performance
|
||||
# if lots of people sign up, but not a lot of them check their feed
|
||||
# every day. Raising the duration reduces the amount of expensive
|
||||
# RegenerationWorker jobs that need to be run when those people come
|
||||
# to check their feed
|
||||
ACTIVE_DURATION = ENV.fetch('USER_ACTIVE_DAYS', 7).to_i.days.freeze
|
||||
|
||||
devise :two_factor_authenticatable,
|
||||
otp_secret_encryption_key: Rails.configuration.x.otp_secret
|
||||
|
||||
devise :two_factor_backupable,
|
||||
otp_number_of_backup_codes: 10
|
||||
|
||||
devise :registerable, :recoverable, :rememberable, :trackable, :validatable,
|
||||
:confirmable
|
||||
|
||||
include Omniauthable
|
||||
include PamAuthenticable
|
||||
include LdapAuthenticable
|
||||
|
||||
belongs_to :account, inverse_of: :user
|
||||
belongs_to :invite, counter_cache: :uses, optional: true
|
||||
belongs_to :created_by_application, class_name: 'Doorkeeper::Application', optional: true
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
|
||||
has_many :backups, inverse_of: :user
|
||||
|
||||
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
|
||||
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
||||
|
||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
||||
validates_with BlacklistedEmailValidator, on: :create
|
||||
validates_with EmailMxValidator, if: :validate_email_dns?
|
||||
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
||||
|
||||
scope :recent, -> { order(id: :desc) }
|
||||
scope :pending, -> { where(approved: false) }
|
||||
scope :approved, -> { where(approved: true) }
|
||||
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
||||
scope :enabled, -> { where(disabled: false) }
|
||||
scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
|
||||
scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where.not(accounts: { suspended_at: nil }) }
|
||||
scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
|
||||
scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
|
||||
|
||||
before_validation :sanitize_languages
|
||||
before_create :set_approved
|
||||
|
||||
# This avoids a deprecation warning from Rails 5.1
|
||||
# It seems possible that a future release of devise-two-factor will
|
||||
# handle this itself, and this can be removed from our User class.
|
||||
attribute :otp_secret
|
||||
|
||||
has_many :session_activations, dependent: :destroy
|
||||
|
||||
delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
|
||||
:reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network,
|
||||
:expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
|
||||
:advanced_layout, to: :settings, prefix: :setting, allow_nil: false
|
||||
|
||||
attr_reader :invite_code
|
||||
attr_writer :external
|
||||
|
||||
def confirmed?
|
||||
confirmed_at.present?
|
||||
end
|
||||
|
||||
def invited?
|
||||
invite_id.present?
|
||||
end
|
||||
|
||||
def valid_invitation?
|
||||
invite_id.present? && invite.valid_for_use?
|
||||
end
|
||||
|
||||
def disable!
|
||||
update!(disabled: true,
|
||||
last_sign_in_at: current_sign_in_at,
|
||||
current_sign_in_at: nil)
|
||||
end
|
||||
|
||||
def enable!
|
||||
update!(disabled: false)
|
||||
end
|
||||
|
||||
def confirm
|
||||
new_user = !confirmed?
|
||||
self.approved = true if open_registrations?
|
||||
|
||||
super
|
||||
|
||||
if new_user && approved?
|
||||
prepare_new_user!
|
||||
elsif new_user
|
||||
notify_staff_about_pending_account!
|
||||
end
|
||||
end
|
||||
|
||||
def confirm!
|
||||
new_user = !confirmed?
|
||||
self.approved = true if open_registrations?
|
||||
|
||||
skip_confirmation!
|
||||
save!
|
||||
|
||||
prepare_new_user! if new_user && approved?
|
||||
end
|
||||
|
||||
def pending?
|
||||
!approved?
|
||||
end
|
||||
|
||||
def active_for_authentication?
|
||||
super && approved?
|
||||
end
|
||||
|
||||
def inactive_message
|
||||
!approved? ? :pending : super
|
||||
end
|
||||
|
||||
def approve!
|
||||
return if approved?
|
||||
|
||||
update!(approved: true)
|
||||
prepare_new_user!
|
||||
end
|
||||
|
||||
def update_tracked_fields!(request)
|
||||
super
|
||||
prepare_returning_user!
|
||||
end
|
||||
|
||||
def disable_two_factor!
|
||||
self.otp_required_for_login = false
|
||||
otp_backup_codes&.clear
|
||||
save!
|
||||
end
|
||||
|
||||
def setting_default_privacy
|
||||
settings.default_privacy || (account.locked? ? 'private' : 'public')
|
||||
end
|
||||
|
||||
def allows_digest_emails?
|
||||
settings.notification_emails['digest']
|
||||
end
|
||||
|
||||
def allows_report_emails?
|
||||
settings.notification_emails['report']
|
||||
end
|
||||
|
||||
def allows_pending_account_emails?
|
||||
settings.notification_emails['pending_account']
|
||||
end
|
||||
|
||||
def hides_network?
|
||||
@hides_network ||= settings.hide_network
|
||||
end
|
||||
|
||||
def aggregates_reblogs?
|
||||
@aggregates_reblogs ||= settings.aggregate_reblogs
|
||||
end
|
||||
|
||||
def shows_application?
|
||||
@shows_application ||= settings.show_application
|
||||
end
|
||||
|
||||
def token_for_app(a)
|
||||
return nil if a.nil? || a.owner != self
|
||||
Doorkeeper::AccessToken
|
||||
.find_or_create_by(application_id: a.id, resource_owner_id: id) do |t|
|
||||
|
||||
t.scopes = a.scopes
|
||||
t.expires_in = Doorkeeper.configuration.access_token_expires_in
|
||||
t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
def activate_session(request)
|
||||
session_activations.activate(session_id: SecureRandom.hex,
|
||||
user_agent: request.user_agent,
|
||||
ip: request.remote_ip).session_id
|
||||
end
|
||||
|
||||
def exclusive_session(id)
|
||||
session_activations.exclusive(id)
|
||||
end
|
||||
|
||||
def session_active?(id)
|
||||
session_activations.active? id
|
||||
end
|
||||
|
||||
def web_push_subscription(session)
|
||||
session.web_push_subscription.nil? ? nil : session.web_push_subscription
|
||||
end
|
||||
|
||||
def invite_code=(code)
|
||||
self.invite = Invite.find_by(code: code) if code.present?
|
||||
@invite_code = code
|
||||
end
|
||||
|
||||
def password_required?
|
||||
return false if Devise.pam_authentication || Devise.ldap_authentication
|
||||
super
|
||||
end
|
||||
|
||||
def send_reset_password_instructions
|
||||
return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication)
|
||||
super
|
||||
end
|
||||
|
||||
def reset_password!(new_password, new_password_confirmation)
|
||||
return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication)
|
||||
super
|
||||
end
|
||||
|
||||
def show_all_media?
|
||||
setting_display_media == 'show_all'
|
||||
end
|
||||
|
||||
def hide_all_media?
|
||||
setting_display_media == 'hide_all'
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def send_devise_notification(notification, *args)
|
||||
devise_mailer.send(notification, self, *args).deliver_later
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_approved
|
||||
self.approved = open_registrations? || valid_invitation? || external?
|
||||
end
|
||||
|
||||
def open_registrations?
|
||||
Setting.registrations_mode == 'open'
|
||||
end
|
||||
|
||||
def external?
|
||||
!!@external
|
||||
end
|
||||
|
||||
def sanitize_languages
|
||||
return if chosen_languages.nil?
|
||||
chosen_languages.reject!(&:blank?)
|
||||
self.chosen_languages = nil if chosen_languages.empty?
|
||||
end
|
||||
|
||||
def prepare_new_user!
|
||||
BootstrapTimelineWorker.perform_async(account_id)
|
||||
ActivityTracker.increment('activity:accounts:local')
|
||||
UserMailer.welcome(self).deliver_later
|
||||
end
|
||||
|
||||
def prepare_returning_user!
|
||||
ActivityTracker.record('activity:logins', id)
|
||||
regenerate_feed! if needs_feed_update?
|
||||
end
|
||||
|
||||
def notify_staff_about_pending_account!
|
||||
User.staff.includes(:account).each do |u|
|
||||
next unless u.allows_pending_account_emails?
|
||||
AdminMailer.new_pending_account(u.account, self).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
def regenerate_feed!
|
||||
return unless Redis.current.setnx("account:#{account_id}:regeneration", true)
|
||||
Redis.current.expire("account:#{account_id}:regeneration", 1.day.seconds)
|
||||
RegenerationWorker.perform_async(account_id)
|
||||
end
|
||||
|
||||
def needs_feed_update?
|
||||
last_sign_in_at < ACTIVE_DURATION.ago
|
||||
end
|
||||
|
||||
def validate_email_dns?
|
||||
email_changed? && !(Rails.env.test? || Rails.env.development?)
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user