mirror of
https://github.com/yingziwu/mastodon.git
synced 2026-02-27 04:32:42 +00:00
Merge tag 'v4.2.0-beta2'
This commit is contained in:
commit
9e38d55101
3010 changed files with 81215 additions and 55173 deletions
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: accounts
|
||||
|
|
@ -49,10 +50,11 @@
|
|||
# trendable :boolean
|
||||
# reviewed_at :datetime
|
||||
# requested_review_at :datetime
|
||||
# indexable :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class Account < ApplicationRecord
|
||||
self.ignored_columns = %w(
|
||||
self.ignored_columns += %w(
|
||||
subscription_expires_at
|
||||
secret
|
||||
remote_url
|
||||
|
|
@ -61,9 +63,11 @@ class Account < ApplicationRecord
|
|||
trust_level
|
||||
)
|
||||
|
||||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
|
||||
MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]\.\-]+[[:word:]]+)?)/i
|
||||
URL_PREFIX_RE = /\Ahttp(s?):\/\/[^\/]+/
|
||||
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
|
||||
|
||||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i
|
||||
MENTION_RE = %r{(?<=^|[^/[:word:]])@((#{USERNAME_RE})(?:@[[:word:].-]+[[:word:]]+)?)}i
|
||||
URL_PREFIX_RE = %r{\Ahttp(s?)://[^/]+}
|
||||
USERNAME_ONLY_RE = /\A#{USERNAME_RE}\z/i
|
||||
|
||||
include Attachmentable
|
||||
|
|
@ -77,9 +81,10 @@ class Account < ApplicationRecord
|
|||
include DomainNormalizable
|
||||
include DomainMaterializable
|
||||
include AccountMerging
|
||||
include AccountSearch
|
||||
|
||||
enum protocol: [:ostatus, :activitypub]
|
||||
enum suspension_origin: [:local, :remote], _prefix: true
|
||||
enum protocol: { ostatus: 0, activitypub: 1 }
|
||||
enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
|
||||
|
||||
validates :username, presence: true
|
||||
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
||||
|
|
@ -87,12 +92,19 @@ class Account < ApplicationRecord
|
|||
# Remote user validations, also applies to internal actors
|
||||
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
|
||||
|
||||
# Remote user validations
|
||||
validates :uri, presence: true, unless: :local?, on: :create
|
||||
|
||||
# Local user validations
|
||||
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
|
||||
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
|
||||
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? }
|
||||
validates :uri, absence: true, if: :local?, on: :create
|
||||
validates :inbox_url, absence: true, if: :local?, on: :create
|
||||
validates :shared_inbox_url, absence: true, if: :local?, on: :create
|
||||
validates :followers_url, absence: true, if: :local?, on: :create
|
||||
|
||||
scope :remote, -> { where.not(domain: nil) }
|
||||
scope :local, -> { where(domain: nil) }
|
||||
|
|
@ -110,17 +122,19 @@ class Account < ApplicationRecord
|
|||
scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
|
||||
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }
|
||||
scope :without_unapproved, -> { left_outer_joins(:user).merge(User.approved.confirmed).or(remote) }
|
||||
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
|
||||
scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
|
||||
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, accounts.id desc')) }
|
||||
scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
|
||||
scope :popular, -> { order('account_stats.followers_count desc') }
|
||||
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches("%.#{domain}"))) }
|
||||
scope :by_domain_and_subdomains, ->(domain) { where(domain: Instance.by_domain_and_subdomains(domain).select(:domain)) }
|
||||
scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
|
||||
scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
|
||||
|
||||
after_update_commit :trigger_update_webhooks
|
||||
|
||||
delegate :email,
|
||||
:unconfirmed_email,
|
||||
:current_sign_in_at,
|
||||
|
|
@ -136,6 +150,7 @@ class Account < ApplicationRecord
|
|||
:locale,
|
||||
:shows_application?,
|
||||
:prefers_noindex?,
|
||||
:time_zone,
|
||||
to: :user,
|
||||
prefix: true,
|
||||
allow_nil: true
|
||||
|
|
@ -196,6 +211,12 @@ class Account < ApplicationRecord
|
|||
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
||||
end
|
||||
|
||||
def schedule_refresh_if_stale!
|
||||
return unless last_webfingered_at.present? && last_webfingered_at <= BACKGROUND_REFRESH_INTERVAL.ago
|
||||
|
||||
AccountRefreshWorker.perform_in(rand(6.hours.to_i), id)
|
||||
end
|
||||
|
||||
def refresh!
|
||||
ResolveAccountService.new.call(acct) unless local?
|
||||
end
|
||||
|
|
@ -295,11 +316,11 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
def fields
|
||||
(self[:fields] || []).map do |f|
|
||||
(self[:fields] || []).filter_map do |f|
|
||||
Account::Field.new(self, f)
|
||||
rescue
|
||||
nil
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
||||
def fields_attributes=(attributes)
|
||||
|
|
@ -313,9 +334,7 @@ class Account < ApplicationRecord
|
|||
|
||||
previous = old_fields.find { |item| item['value'] == attr[:value] }
|
||||
|
||||
if previous && previous['verified_at'].present?
|
||||
attr[:verified_at] = previous['verified_at']
|
||||
end
|
||||
attr[:verified_at] = previous['verified_at'] if previous && previous['verified_at'].present?
|
||||
|
||||
fields << attr
|
||||
end
|
||||
|
|
@ -409,14 +428,6 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
class << self
|
||||
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/.freeze
|
||||
TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
|
||||
|
||||
REPUTATION_SCORE_FUNCTION = '(greatest(0, coalesce(s.followers_count, 0)) / (greatest(0, coalesce(s.following_count, 0)) + 1.0))'
|
||||
FOLLOWERS_SCORE_FUNCTION = 'log(greatest(0, coalesce(s.followers_count, 0)) + 2)'
|
||||
TIME_DISTANCE_FUNCTION = '(case when s.last_status_at is null then 0 else exp(-1.0 * ((greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) / (2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))))) end)'
|
||||
BOOST = "((#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0)"
|
||||
|
||||
def readonly_attributes
|
||||
super - %w(statuses_count following_count followers_count)
|
||||
end
|
||||
|
|
@ -426,122 +437,46 @@ class Account < ApplicationRecord
|
|||
DeliveryFailureTracker.without_unavailable(urls)
|
||||
end
|
||||
|
||||
def search_for(terms, limit: 10, offset: 0)
|
||||
tsquery = generate_query_for_search(terms)
|
||||
|
||||
sql = <<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
#{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT JOIN users ON accounts.id = users.account_id
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, limit: limit, offset: offset, tsquery: tsquery])
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||
records
|
||||
end
|
||||
|
||||
def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
|
||||
tsquery = generate_query_for_search(terms)
|
||||
sql = advanced_search_for_sql_template(following)
|
||||
records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||
records
|
||||
end
|
||||
|
||||
def from_text(text)
|
||||
return [] if text.blank?
|
||||
|
||||
text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.filter_map do |(username, domain)|
|
||||
domain = begin
|
||||
if TagManager.instance.local_domain?(domain)
|
||||
nil
|
||||
else
|
||||
TagManager.instance.normalize_domain(domain)
|
||||
end
|
||||
end
|
||||
domain = if TagManager.instance.local_domain?(domain)
|
||||
nil
|
||||
else
|
||||
TagManager.instance.normalize_domain(domain)
|
||||
end
|
||||
|
||||
EntityCache.instance.mention(username, domain)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def inverse_alias(key, original_key)
|
||||
define_method("#{key}=") do |value|
|
||||
public_send("#{original_key}=", !ActiveModel::Type::Boolean.new.cast(value))
|
||||
end
|
||||
|
||||
def generate_query_for_search(unsanitized_terms)
|
||||
terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
|
||||
|
||||
# The final ":*" is for prefix search.
|
||||
# The trailing space does not seem to fit any purpose, but `to_tsquery`
|
||||
# behaves differently with and without a leading space if the terms start
|
||||
# with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
|
||||
# the same query.
|
||||
"' #{terms} ':*"
|
||||
end
|
||||
|
||||
def advanced_search_for_sql_template(following)
|
||||
if following
|
||||
<<-SQL.squish
|
||||
WITH first_degree AS (
|
||||
SELECT target_account_id
|
||||
FROM follows
|
||||
WHERE account_id = :id
|
||||
UNION ALL
|
||||
SELECT :id
|
||||
)
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE accounts.id IN (SELECT * FROM first_degree)
|
||||
AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id, s.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
else
|
||||
<<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
#{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank,
|
||||
count(f.id) AS followed
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
|
||||
LEFT JOIN users ON accounts.id = users.account_id
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
||||
GROUP BY accounts.id, s.id
|
||||
ORDER BY followed DESC, rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
define_method(key) do
|
||||
!public_send(original_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
inverse_alias :show_collections, :hide_collections
|
||||
inverse_alias :unlocked, :locked
|
||||
|
||||
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_create :generate_keys
|
||||
before_destroy :clean_feed_manager
|
||||
|
||||
def ensure_keys!
|
||||
return unless local? && private_key.blank? && public_key.blank?
|
||||
|
||||
generate_keys
|
||||
save!
|
||||
end
|
||||
|
|
@ -594,4 +529,9 @@ class Account < ApplicationRecord
|
|||
|
||||
CanonicalEmailBlock.where(reference_account: self).delete_all
|
||||
end
|
||||
|
||||
# NOTE: the `account.created` webhook is triggered by the `User` model, not `Account`.
|
||||
def trigger_update_webhooks
|
||||
TriggerWebhookWorker.perform_async('account.updated', 'Account', id) if local?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ class Account::Field < ActiveModelSerializers::Model
|
|||
@account = account
|
||||
|
||||
super(
|
||||
name: sanitize(attributes['name']),
|
||||
value: sanitize(attributes['value']),
|
||||
name: sanitize(attributes['name']),
|
||||
value: sanitize(attributes['value']),
|
||||
verified_at: attributes['verified_at']&.to_datetime,
|
||||
)
|
||||
end
|
||||
|
|
@ -25,13 +25,11 @@ class Account::Field < ActiveModelSerializers::Model
|
|||
end
|
||||
|
||||
def value_for_verification
|
||||
@value_for_verification ||= begin
|
||||
if account.local?
|
||||
value
|
||||
else
|
||||
extract_url_from_html
|
||||
end
|
||||
end
|
||||
@value_for_verification ||= if account.local?
|
||||
value
|
||||
else
|
||||
extract_url_from_html
|
||||
end
|
||||
end
|
||||
|
||||
def verifiable?
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class AccountAlias < ApplicationRecord
|
|||
|
||||
def acct=(val)
|
||||
val = val.to_s.strip
|
||||
super(val.start_with?('@') ? val[1..-1] : val)
|
||||
super(val.start_with?('@') ? val[1..] : val)
|
||||
end
|
||||
|
||||
def pretty_acct
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_conversations
|
||||
|
|
@ -117,6 +118,7 @@ class AccountConversation < ApplicationRecord
|
|||
|
||||
def push_to_streaming_api
|
||||
return if destroyed? || !subscribed_to_timeline?
|
||||
|
||||
PushConversationWorker.perform_async(id)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_domain_blocks
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ class AccountFilter
|
|||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
@params = params.to_h.symbolize_keys
|
||||
end
|
||||
|
||||
def results
|
||||
scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil)
|
||||
|
||||
params.each do |key, value|
|
||||
relevant_params.each do |key, value|
|
||||
next if key.to_s == 'page'
|
||||
|
||||
scope.merge!(scope_for(key, value)) if value.present?
|
||||
|
|
@ -34,6 +34,16 @@ class AccountFilter
|
|||
|
||||
private
|
||||
|
||||
def relevant_params
|
||||
params.tap do |args|
|
||||
args.delete(:origin) if origin_is_remote_and_domain_present?
|
||||
end
|
||||
end
|
||||
|
||||
def origin_is_remote_and_domain_present?
|
||||
params[:origin] == 'remote' && params[:by_domain].present?
|
||||
end
|
||||
|
||||
def scope_for(key, value)
|
||||
case key.to_s
|
||||
when 'origin'
|
||||
|
|
@ -45,7 +55,7 @@ class AccountFilter
|
|||
when 'by_domain'
|
||||
Account.where(domain: value.to_s.strip)
|
||||
when 'username'
|
||||
Account.matches_username(value.to_s.strip)
|
||||
Account.matches_username(value.to_s.strip.delete_prefix('@'))
|
||||
when 'display_name'
|
||||
Account.matches_display_name(value.to_s.strip)
|
||||
when 'email'
|
||||
|
|
@ -94,7 +104,15 @@ class AccountFilter
|
|||
def order_scope(value)
|
||||
case value.to_s
|
||||
when 'active'
|
||||
accounts_with_users.left_joins(:account_stat).order(Arel.sql('coalesce(users.current_sign_in_at, account_stats.last_status_at, to_timestamp(0)) desc, accounts.id desc'))
|
||||
accounts_with_users
|
||||
.left_joins(:account_stat)
|
||||
.order(
|
||||
Arel.sql(
|
||||
<<~SQL.squish
|
||||
COALESCE(users.current_sign_in_at, account_stats.last_status_at, to_timestamp(0)) DESC, accounts.id DESC
|
||||
SQL
|
||||
)
|
||||
)
|
||||
when 'recent'
|
||||
Account.recent
|
||||
else
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class AccountMigration < ApplicationRecord
|
|||
|
||||
return false unless errors.empty?
|
||||
|
||||
with_lock("account_migration:#{account.id}") do
|
||||
with_redis_lock("account_migration:#{account.id}") do
|
||||
save
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_moderation_notes
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_notes
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_pins
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_stats
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
|
||||
class AccountStat < ApplicationRecord
|
||||
self.locking_column = nil
|
||||
self.ignored_columns = %w(lock_version)
|
||||
self.ignored_columns += %w(lock_version)
|
||||
|
||||
belongs_to :account, inverse_of: :account_stat
|
||||
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
before_save :update_last_inspected
|
||||
|
||||
def statuses_to_delete(limit = 50, max_id = nil, min_id = nil)
|
||||
scope = account.statuses
|
||||
scope = account_statuses
|
||||
scope.merge!(old_enough_scope(max_id))
|
||||
scope = scope.where(Status.arel_table[:id].gteq(min_id)) if min_id.present?
|
||||
scope = scope.where(id: min_id..) if min_id.present?
|
||||
scope.merge!(without_popular_scope) unless min_favs.nil? && min_reblogs.nil?
|
||||
scope.merge!(without_direct_scope) if keep_direct?
|
||||
scope.merge!(without_pinned_scope) if keep_pinned?
|
||||
|
|
@ -80,7 +80,7 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
def compute_cutoff_id
|
||||
min_id = last_inspected || 0
|
||||
max_id = Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)
|
||||
subquery = account.statuses.where(Status.arel_table[:id].gteq(min_id)).where(Status.arel_table[:id].lteq(max_id))
|
||||
subquery = account_statuses.where(id: min_id..max_id)
|
||||
subquery = subquery.select(:id).reorder(id: :asc).limit(EARLY_SEARCH_CUTOFF)
|
||||
|
||||
# We're textually interpolating a subquery here as ActiveRecord seem to not provide
|
||||
|
|
@ -91,11 +91,11 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
# The most important thing about `last_inspected` is that any toot older than it is guaranteed
|
||||
# not to be kept by the policy regardless of its age.
|
||||
def record_last_inspected(last_id)
|
||||
redis.set("account_cleanup:#{account.id}", last_id, ex: 1.week.seconds)
|
||||
redis.set("account_cleanup:#{account_id}", last_id, ex: 2.weeks.seconds)
|
||||
end
|
||||
|
||||
def last_inspected
|
||||
redis.get("account_cleanup:#{account.id}")&.to_i
|
||||
redis.get("account_cleanup:#{account_id}")&.to_i
|
||||
end
|
||||
|
||||
def invalidate_last_inspected(status, action)
|
||||
|
|
@ -117,14 +117,12 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
private
|
||||
|
||||
def update_last_inspected
|
||||
if EXCEPTION_BOOLS.map { |name| attribute_change_to_be_saved(name) }.compact.include?([true, false])
|
||||
if EXCEPTION_BOOLS.filter_map { |name| attribute_change_to_be_saved(name) }.include?([true, false])
|
||||
# Policy has been widened in such a way that any previously-inspected status
|
||||
# may need to be deleted, so we'll have to start again.
|
||||
redis.del("account_cleanup:#{account.id}")
|
||||
end
|
||||
if EXCEPTION_THRESHOLDS.map { |name| attribute_change_to_be_saved(name) }.compact.any? { |old, new| old.present? && (new.nil? || new > old) }
|
||||
redis.del("account_cleanup:#{account.id}")
|
||||
redis.del("account_cleanup:#{account_id}")
|
||||
end
|
||||
redis.del("account_cleanup:#{account_id}") if EXCEPTION_THRESHOLDS.filter_map { |name| attribute_change_to_be_saved(name) }.any? { |old, new| old.present? && (new.nil? || new > old) }
|
||||
end
|
||||
|
||||
def validate_local_account
|
||||
|
|
@ -141,27 +139,25 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
# has switched to snowflake IDs significantly over 2 years ago anyway.
|
||||
snowflake_id = Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)
|
||||
|
||||
if max_id.nil? || snowflake_id < max_id
|
||||
max_id = snowflake_id
|
||||
end
|
||||
max_id = snowflake_id if max_id.nil? || snowflake_id < max_id
|
||||
|
||||
Status.where(Status.arel_table[:id].lteq(max_id))
|
||||
Status.where(id: ..max_id)
|
||||
end
|
||||
|
||||
def without_self_fav_scope
|
||||
Status.where('NOT EXISTS (SELECT * FROM favourites fav WHERE fav.account_id = statuses.account_id AND fav.status_id = statuses.id)')
|
||||
Status.where('NOT EXISTS (SELECT 1 FROM favourites fav WHERE fav.account_id = statuses.account_id AND fav.status_id = statuses.id)')
|
||||
end
|
||||
|
||||
def without_self_bookmark_scope
|
||||
Status.where('NOT EXISTS (SELECT * FROM bookmarks bookmark WHERE bookmark.account_id = statuses.account_id AND bookmark.status_id = statuses.id)')
|
||||
Status.where('NOT EXISTS (SELECT 1 FROM bookmarks bookmark WHERE bookmark.account_id = statuses.account_id AND bookmark.status_id = statuses.id)')
|
||||
end
|
||||
|
||||
def without_pinned_scope
|
||||
Status.where('NOT EXISTS (SELECT * FROM status_pins pin WHERE pin.account_id = statuses.account_id AND pin.status_id = statuses.id)')
|
||||
Status.where('NOT EXISTS (SELECT 1 FROM status_pins pin WHERE pin.account_id = statuses.account_id AND pin.status_id = statuses.id)')
|
||||
end
|
||||
|
||||
def without_media_scope
|
||||
Status.where('NOT EXISTS (SELECT * FROM media_attachments media WHERE media.status_id = statuses.id)')
|
||||
Status.where('NOT EXISTS (SELECT 1 FROM media_attachments media WHERE media.status_id = statuses.id)')
|
||||
end
|
||||
|
||||
def without_poll_scope
|
||||
|
|
@ -174,4 +170,8 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
scope = scope.where('COALESCE(status_stats.favourites_count, 0) < ?', min_favs) unless min_favs.nil?
|
||||
scope
|
||||
end
|
||||
|
||||
def account_statuses
|
||||
Status.where(account_id: account_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ class AccountStatusesFilter
|
|||
private
|
||||
|
||||
def initial_scope
|
||||
if suspended?
|
||||
Status.none
|
||||
elsif anonymous?
|
||||
return Status.none if suspended?
|
||||
|
||||
if anonymous?
|
||||
account.statuses.where(visibility: %i(public unlisted))
|
||||
elsif author?
|
||||
account.statuses.all # NOTE: #merge! does not work without the #all
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
|
|||
end
|
||||
|
||||
def setting_to_usernames_and_domains
|
||||
setting.split(',').map do |str|
|
||||
setting.split(',').filter_map do |str|
|
||||
username, domain = str.strip.gsub(/\A@/, '').split('@', 2)
|
||||
domain = nil if TagManager.instance.local_domain?(domain)
|
||||
|
||||
next if username.blank?
|
||||
|
||||
[username.downcase, domain&.downcase]
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
||||
def setting
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ class AccountSuggestions::Source
|
|||
def as_ordered_suggestions(scope, ordered_list)
|
||||
return [] if ordered_list.empty?
|
||||
|
||||
map = scope.index_by(&method(:to_ordered_list_key))
|
||||
map = scope.index_by { |account| to_ordered_list_key(account) }
|
||||
|
||||
ordered_list.map { |ordered_list_key| map[ordered_list_key] }.compact.map do |account|
|
||||
ordered_list.filter_map { |ordered_list_key| map[ordered_list_key] }.map do |account|
|
||||
AccountSuggestions::Suggestion.new(
|
||||
account: account,
|
||||
source: key
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_summaries
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_warnings
|
||||
|
|
@ -17,13 +18,13 @@
|
|||
|
||||
class AccountWarning < ApplicationRecord
|
||||
enum action: {
|
||||
none: 0,
|
||||
disable: 1_000,
|
||||
none: 0,
|
||||
disable: 1_000,
|
||||
mark_statuses_as_sensitive: 1_250,
|
||||
delete_statuses: 1_500,
|
||||
sensitive: 2_000,
|
||||
silence: 3_000,
|
||||
suspend: 4_000,
|
||||
delete_statuses: 1_500,
|
||||
sensitive: 2_000,
|
||||
silence: 3_000,
|
||||
suspend: 4_000,
|
||||
}, _suffix: :action
|
||||
|
||||
before_validation :before_validate
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class Admin::AccountAction
|
|||
alias include_statuses? include_statuses
|
||||
|
||||
validates :type, :target_account, :current_account, presence: true
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def initialize(attributes = {})
|
||||
@send_email_notification = true
|
||||
|
|
@ -71,6 +72,10 @@ class Admin::AccountAction
|
|||
TYPES - %w(none disable)
|
||||
end
|
||||
end
|
||||
|
||||
def i18n_scope
|
||||
:activerecord
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -166,13 +171,11 @@ class Admin::AccountAction
|
|||
end
|
||||
|
||||
def reports
|
||||
@reports ||= begin
|
||||
if type == 'none'
|
||||
with_report? ? [report] : []
|
||||
else
|
||||
Report.where(target_account: target_account).unresolved
|
||||
end
|
||||
end
|
||||
@reports ||= if type == 'none'
|
||||
with_report? ? [report] : []
|
||||
else
|
||||
Report.where(target_account: target_account).unresolved
|
||||
end
|
||||
end
|
||||
|
||||
def warning_preset
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#
|
||||
|
||||
class Admin::ActionLog < ApplicationRecord
|
||||
self.ignored_columns = %w(
|
||||
self.ignored_columns += %w(
|
||||
recorded_changes
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ class Admin::AppealFilter
|
|||
status
|
||||
).freeze
|
||||
|
||||
IGNORED_PARAMS = %w(page).freeze
|
||||
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
|
|
@ -15,7 +17,7 @@ class Admin::AppealFilter
|
|||
scope = Appeal.order(id: :desc)
|
||||
|
||||
params.each do |key, value|
|
||||
next if %w(page).include?(key.to_s)
|
||||
next if IGNORED_PARAMS.include?(key.to_s)
|
||||
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class Admin::Import
|
|||
|
||||
def validate_data
|
||||
return if data.nil?
|
||||
|
||||
errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
|
||||
rescue CSV::MalformedCSVError => e
|
||||
errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ class Admin::StatusFilter
|
|||
report_id
|
||||
).freeze
|
||||
|
||||
IGNORED_PARAMS = %w(page report_id).freeze
|
||||
|
||||
attr_reader :params
|
||||
|
||||
def initialize(account, params)
|
||||
|
|
@ -17,7 +19,7 @@ class Admin::StatusFilter
|
|||
scope = @account.statuses.where(visibility: [:public, :unlisted])
|
||||
|
||||
params.each do |key, value|
|
||||
next if %w(page report_id).include?(key.to_s)
|
||||
next if IGNORED_PARAMS.include?(key.to_s)
|
||||
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
class Announcement < ApplicationRecord
|
||||
scope :unpublished, -> { where(published: false) }
|
||||
scope :published, -> { where(published: true) }
|
||||
scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where('announcement_mutes.id IS NULL') }
|
||||
scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where(announcement_mutes: { id: nil }) }
|
||||
scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) ASC')) }
|
||||
scope :reverse_chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) DESC')) }
|
||||
|
||||
|
|
@ -54,13 +54,11 @@ class Announcement < ApplicationRecord
|
|||
end
|
||||
|
||||
def statuses
|
||||
@statuses ||= begin
|
||||
if status_ids.nil?
|
||||
[]
|
||||
else
|
||||
Status.where(id: status_ids, visibility: [:public, :unlisted])
|
||||
end
|
||||
end
|
||||
@statuses ||= if status_ids.nil?
|
||||
[]
|
||||
else
|
||||
Status.where(id: status_ids, visibility: [:public, :unlisted])
|
||||
end
|
||||
end
|
||||
|
||||
def tags
|
||||
|
|
@ -82,7 +80,7 @@ class Announcement < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
|
||||
ActiveRecord::Associations::Preloader.new(records: records, associations: :custom_emoji)
|
||||
records
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
#
|
||||
|
||||
class AnnouncementReaction < ApplicationRecord
|
||||
before_validation :set_custom_emoji
|
||||
after_commit :queue_publish
|
||||
|
||||
belongs_to :account
|
||||
|
|
@ -23,8 +24,6 @@ class AnnouncementReaction < ApplicationRecord
|
|||
validates :name, presence: true
|
||||
validates_with ReactionValidator
|
||||
|
||||
before_validation :set_custom_emoji
|
||||
|
||||
private
|
||||
|
||||
def set_custom_emoji
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class Appeal < ApplicationRecord
|
|||
MAX_STRIKE_AGE = 20.days
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id'
|
||||
belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id', inverse_of: :appeal
|
||||
belongs_to :approved_by_account, class_name: 'Account', optional: true
|
||||
belongs_to :rejected_by_account, class_name: 'Account', optional: true
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ class ApplicationRecord < ActiveRecord::Base
|
|||
|
||||
include Remotable
|
||||
|
||||
connects_to database: { writing: :primary, reading: ENV['REPLICA_DB_NAME'] || ENV['REPLICA_DATABASE_URL'] ? :replica : :primary }
|
||||
|
||||
class << self
|
||||
def update_index(_type_name, *_args, &_block)
|
||||
super if Chewy.enabled?
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: backups
|
||||
|
|
@ -18,5 +19,5 @@ class Backup < ApplicationRecord
|
|||
belongs_to :user, inverse_of: :backups
|
||||
|
||||
has_attached_file :dump, s3_permissions: ->(*) { ENV['S3_PERMISSION'] == '' ? nil : 'private' }
|
||||
do_not_validate_attachment_file_type :dump
|
||||
validates_attachment_content_type :dump, content_type: /\Aapplication/
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: blocks
|
||||
|
|
@ -24,8 +25,8 @@ class Block < ApplicationRecord
|
|||
false # Force uri_for to use uri attribute
|
||||
end
|
||||
|
||||
after_commit :remove_blocking_cache
|
||||
before_validation :set_uri, only: :create
|
||||
after_commit :remove_blocking_cache
|
||||
|
||||
private
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: bookmarks
|
||||
|
|
|
|||
54
app/models/bulk_import.rb
Normal file
54
app/models/bulk_import.rb
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: bulk_imports
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# type :integer not null
|
||||
# state :integer not null
|
||||
# total_items :integer default(0), not null
|
||||
# imported_items :integer default(0), not null
|
||||
# processed_items :integer default(0), not null
|
||||
# finished_at :datetime
|
||||
# overwrite :boolean default(FALSE), not null
|
||||
# likely_mismatched :boolean default(FALSE), not null
|
||||
# original_filename :string default(""), not null
|
||||
# account_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class BulkImport < ApplicationRecord
|
||||
self.inheritance_column = false
|
||||
|
||||
belongs_to :account
|
||||
has_many :rows, class_name: 'BulkImportRow', inverse_of: :bulk_import, dependent: :delete_all
|
||||
|
||||
enum type: {
|
||||
following: 0,
|
||||
blocking: 1,
|
||||
muting: 2,
|
||||
domain_blocking: 3,
|
||||
bookmarks: 4,
|
||||
lists: 5,
|
||||
}
|
||||
|
||||
enum state: {
|
||||
unconfirmed: 0,
|
||||
scheduled: 1,
|
||||
in_progress: 2,
|
||||
finished: 3,
|
||||
}
|
||||
|
||||
validates :type, presence: true
|
||||
|
||||
def self.progress!(bulk_import_id, imported: false)
|
||||
# Use `increment_counter` so that the incrementation is done atomically in the database
|
||||
BulkImport.increment_counter(:processed_items, bulk_import_id) # rubocop:disable Rails/SkipsModelValidations
|
||||
BulkImport.increment_counter(:imported_items, bulk_import_id) if imported # rubocop:disable Rails/SkipsModelValidations
|
||||
|
||||
# Since the incrementation has been done atomically, concurrent access to `bulk_import` is now bening
|
||||
bulk_import = BulkImport.find(bulk_import_id)
|
||||
bulk_import.update!(state: :finished, finished_at: Time.now.utc) if bulk_import.processed_items == bulk_import.total_items
|
||||
end
|
||||
end
|
||||
15
app/models/bulk_import_row.rb
Normal file
15
app/models/bulk_import_row.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: bulk_import_rows
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# bulk_import_id :bigint(8) not null
|
||||
# data :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class BulkImportRow < ApplicationRecord
|
||||
belongs_to :bulk_import
|
||||
end
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: canonical_email_blocks
|
||||
|
|
|
|||
|
|
@ -68,5 +68,8 @@ module AccountAssociations
|
|||
|
||||
# Account statuses cleanup policy
|
||||
has_one :statuses_cleanup_policy, class_name: 'AccountStatusesCleanupPolicy', inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Imports
|
||||
has_many :bulk_imports, inverse_of: :account, dependent: :delete_all
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -81,8 +81,10 @@ module AccountInteractions
|
|||
# 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
|
||||
with_options class_name: 'Follow', dependent: :destroy do
|
||||
has_many :active_relationships, foreign_key: 'account_id', inverse_of: :account
|
||||
has_many :passive_relationships, foreign_key: 'target_account_id', inverse_of: :target_account
|
||||
end
|
||||
|
||||
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
|
||||
|
|
@ -91,15 +93,19 @@ module AccountInteractions
|
|||
has_many :account_notes, dependent: :destroy
|
||||
|
||||
# Block relationships
|
||||
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
|
||||
with_options class_name: 'Block', dependent: :destroy do
|
||||
has_many :block_relationships, foreign_key: 'account_id', inverse_of: :account
|
||||
has_many :blocked_by_relationships, foreign_key: :target_account_id, inverse_of: :target_account
|
||||
end
|
||||
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
|
||||
with_options class_name: 'Mute', dependent: :destroy do
|
||||
has_many :mute_relationships, foreign_key: 'account_id', inverse_of: :account
|
||||
has_many :muted_by_relationships, foreign_key: :target_account_id, inverse_of: :target_account
|
||||
end
|
||||
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
|
||||
|
|
@ -151,9 +157,7 @@ module AccountInteractions
|
|||
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.update!(hide_notifications: notifications) if mute.hide_notifications? != notifications
|
||||
|
||||
mute
|
||||
end
|
||||
|
|
@ -267,7 +271,8 @@ module AccountInteractions
|
|||
end
|
||||
|
||||
def lists_for_local_distribution
|
||||
lists.joins(account: :user)
|
||||
scope = lists.joins(account: :user)
|
||||
scope.where.not(list_accounts: { follow_id: nil }).or(scope.where(account_id: id))
|
||||
.where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
|
||||
end
|
||||
|
||||
|
|
@ -280,7 +285,7 @@ module AccountInteractions
|
|||
followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(url_prefix)}/%", false, true)).or(followers.where(uri: url_prefix)).pluck_each(:uri) do |uri|
|
||||
Xorcist.xor!(digest, Digest::SHA256.digest(uri))
|
||||
end
|
||||
digest.unpack('H*')[0]
|
||||
digest.unpack1('H*')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -290,10 +295,25 @@ module AccountInteractions
|
|||
followers.where(domain: nil).pluck_each(:username) do |username|
|
||||
Xorcist.xor!(digest, Digest::SHA256.digest(ActivityPub::TagManager.instance.uri_for_username(username)))
|
||||
end
|
||||
digest.unpack('H*')[0]
|
||||
digest.unpack1('H*')
|
||||
end
|
||||
end
|
||||
|
||||
def relations_map(account_ids, domains = nil, **options)
|
||||
relations = {
|
||||
blocked_by: Account.blocked_by_map(account_ids, id),
|
||||
following: Account.following_map(account_ids, id),
|
||||
}
|
||||
|
||||
return relations if options[:skip_blocking_and_muting]
|
||||
|
||||
relations.merge!({
|
||||
blocking: Account.blocking_map(account_ids, id),
|
||||
muting: Account.muting_map(account_ids, id),
|
||||
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, id),
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_potential_friendship(other_account)
|
||||
|
|
|
|||
|
|
@ -21,11 +21,9 @@ module AccountMerging
|
|||
|
||||
owned_classes.each do |klass|
|
||||
klass.where(account_id: other_account.id).find_each do |record|
|
||||
begin
|
||||
record.update_attribute(:account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
record.update_attribute(:account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -36,11 +34,9 @@ module AccountMerging
|
|||
|
||||
target_classes.each do |klass|
|
||||
klass.where(target_account_id: other_account.id).find_each do |record|
|
||||
begin
|
||||
record.update_attribute(:target_account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
record.update_attribute(:target_account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
151
app/models/concerns/account_search.rb
Normal file
151
app/models/concerns/account_search.rb
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module AccountSearch
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/
|
||||
|
||||
TEXT_SEARCH_RANKS = <<~SQL.squish
|
||||
(
|
||||
setweight(to_tsvector('simple', accounts.display_name), 'A') ||
|
||||
setweight(to_tsvector('simple', accounts.username), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C')
|
||||
)
|
||||
SQL
|
||||
|
||||
REPUTATION_SCORE_FUNCTION = <<~SQL.squish
|
||||
(
|
||||
greatest(0, coalesce(s.followers_count, 0)) / (
|
||||
greatest(0, coalesce(s.following_count, 0)) + 1.0
|
||||
)
|
||||
)
|
||||
SQL
|
||||
|
||||
FOLLOWERS_SCORE_FUNCTION = <<~SQL.squish
|
||||
log(
|
||||
greatest(0, coalesce(s.followers_count, 0)) + 2
|
||||
)
|
||||
SQL
|
||||
|
||||
TIME_DISTANCE_FUNCTION = <<~SQL.squish
|
||||
(
|
||||
case
|
||||
when s.last_status_at is null then 0
|
||||
else exp(
|
||||
-1.0 * (
|
||||
(
|
||||
greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) /#{' '}
|
||||
(2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
)
|
||||
SQL
|
||||
|
||||
BOOST = <<~SQL.squish
|
||||
(
|
||||
(#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0
|
||||
)
|
||||
SQL
|
||||
|
||||
BASIC_SEARCH_SQL = <<~SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
#{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT JOIN users ON accounts.id = users.account_id
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
|
||||
ADVANCED_SEARCH_WITH_FOLLOWING = <<~SQL.squish
|
||||
WITH first_degree AS (
|
||||
SELECT target_account_id
|
||||
FROM follows
|
||||
WHERE account_id = :id
|
||||
UNION ALL
|
||||
SELECT :id
|
||||
)
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE accounts.id IN (SELECT * FROM first_degree)
|
||||
AND to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id, s.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
|
||||
ADVANCED_SEARCH_WITHOUT_FOLLOWING = <<~SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
#{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank,
|
||||
count(f.id) AS followed
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON
|
||||
(accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
|
||||
LEFT JOIN users ON accounts.id = users.account_id
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
||||
GROUP BY accounts.id, s.id
|
||||
ORDER BY followed DESC, rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
|
||||
def searchable_text
|
||||
PlainTextFormatter.new(note, local?).to_s if discoverable?
|
||||
end
|
||||
|
||||
def searchable_properties
|
||||
[].tap do |properties|
|
||||
properties << 'bot' if bot?
|
||||
properties << 'verified' if fields.any?(&:verified?)
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def search_for(terms, limit: 10, offset: 0)
|
||||
tsquery = generate_query_for_search(terms)
|
||||
|
||||
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
||||
end
|
||||
end
|
||||
|
||||
def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
|
||||
tsquery = generate_query_for_search(terms)
|
||||
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
|
||||
|
||||
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||
ActiveRecord::Associations::Preloader.new(records: records, associations: :account_stat)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_query_for_search(unsanitized_terms)
|
||||
terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
|
||||
|
||||
# The final ":*" is for prefix search.
|
||||
# The trailing space does not seem to fit any purpose, but `to_tsquery`
|
||||
# behaves differently with and without a leading space if the terms start
|
||||
# with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
|
||||
# the same query.
|
||||
"' #{terms} ':*"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -5,7 +5,7 @@ require 'mime/types/columnar'
|
|||
module Attachmentable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
MAX_MATRIX_LIMIT = 16_777_216 # 4096x4096px or approx. 16MB
|
||||
MAX_MATRIX_LIMIT = 33_177_600 # 7680x4320px or approx. 847MB in RAM
|
||||
GIF_MATRIX_LIMIT = 921_600 # 1280x720px
|
||||
|
||||
# For some file extensions, there exist different content
|
||||
|
|
@ -45,7 +45,7 @@ module Attachmentable
|
|||
def set_file_extension(attachment) # rubocop:disable Naming/AccessorMethodName
|
||||
return if attachment.blank?
|
||||
|
||||
attachment.instance_write :file_name, [Paperclip::Interpolations.basename(attachment, :original), appropriate_extension(attachment)].delete_if(&:blank?).join('.')
|
||||
attachment.instance_write :file_name, [Paperclip::Interpolations.basename(attachment, :original), appropriate_extension(attachment)].compact_blank!.join('.')
|
||||
end
|
||||
|
||||
def check_image_dimension(attachment)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ module Expireable
|
|||
end
|
||||
|
||||
def expires_in=(interval)
|
||||
self.expires_at = interval.present? ? interval.to_i.seconds.from_now : nil
|
||||
self.expires_at = interval.present? ? interval.to_i.seconds.from_now : nil
|
||||
@expires_in = interval
|
||||
end
|
||||
|
||||
|
|
|
|||
137
app/models/concerns/has_user_settings.rb
Normal file
137
app/models/concerns/has_user_settings.rb
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module HasUserSettings
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
serialize :settings, UserSettingsSerializer
|
||||
end
|
||||
|
||||
def settings_attributes=(attributes)
|
||||
settings.update(attributes)
|
||||
end
|
||||
|
||||
def prefers_noindex?
|
||||
settings['noindex']
|
||||
end
|
||||
|
||||
def preferred_posting_language
|
||||
valid_locale_cascade(settings['default_language'], locale, I18n.locale)
|
||||
end
|
||||
|
||||
def setting_auto_play_gif
|
||||
settings['web.auto_play']
|
||||
end
|
||||
|
||||
def setting_default_sensitive
|
||||
settings['default_sensitive']
|
||||
end
|
||||
|
||||
def setting_unfollow_modal
|
||||
settings['web.unfollow_modal']
|
||||
end
|
||||
|
||||
def setting_boost_modal
|
||||
settings['web.reblog_modal']
|
||||
end
|
||||
|
||||
def setting_delete_modal
|
||||
settings['web.delete_modal']
|
||||
end
|
||||
|
||||
def setting_reduce_motion
|
||||
settings['web.reduce_motion']
|
||||
end
|
||||
|
||||
def setting_system_font_ui
|
||||
settings['web.use_system_font']
|
||||
end
|
||||
|
||||
def setting_noindex
|
||||
settings['noindex']
|
||||
end
|
||||
|
||||
def setting_theme
|
||||
settings['theme']
|
||||
end
|
||||
|
||||
def setting_display_media
|
||||
settings['web.display_media']
|
||||
end
|
||||
|
||||
def setting_expand_spoilers
|
||||
settings['web.expand_content_warnings']
|
||||
end
|
||||
|
||||
def setting_default_language
|
||||
settings['default_language']
|
||||
end
|
||||
|
||||
def setting_aggregate_reblogs
|
||||
settings['aggregate_reblogs']
|
||||
end
|
||||
|
||||
def setting_show_application
|
||||
settings['show_application']
|
||||
end
|
||||
|
||||
def setting_advanced_layout
|
||||
settings['web.advanced_layout']
|
||||
end
|
||||
|
||||
def setting_use_blurhash
|
||||
settings['web.use_blurhash']
|
||||
end
|
||||
|
||||
def setting_use_pending_items
|
||||
settings['web.use_pending_items']
|
||||
end
|
||||
|
||||
def setting_trends
|
||||
settings['web.trends']
|
||||
end
|
||||
|
||||
def setting_disable_swiping
|
||||
settings['web.disable_swiping']
|
||||
end
|
||||
|
||||
def setting_always_send_emails
|
||||
settings['always_send_emails']
|
||||
end
|
||||
|
||||
def setting_default_privacy
|
||||
settings['default_privacy'] || (account.locked? ? 'private' : 'public')
|
||||
end
|
||||
|
||||
def allows_report_emails?
|
||||
settings['notification_emails.report']
|
||||
end
|
||||
|
||||
def allows_pending_account_emails?
|
||||
settings['notification_emails.pending_account']
|
||||
end
|
||||
|
||||
def allows_appeal_emails?
|
||||
settings['notification_emails.appeal']
|
||||
end
|
||||
|
||||
def allows_trends_review_emails?
|
||||
settings['notification_emails.trends']
|
||||
end
|
||||
|
||||
def aggregates_reblogs?
|
||||
settings['aggregate_reblogs']
|
||||
end
|
||||
|
||||
def shows_application?
|
||||
settings['show_application']
|
||||
end
|
||||
|
||||
def show_all_media?
|
||||
settings['web.display_media'] == 'show_all'
|
||||
end
|
||||
|
||||
def hide_all_media?
|
||||
settings['web.display_media'] == 'hide_all'
|
||||
end
|
||||
end
|
||||
|
|
@ -5,7 +5,7 @@ module Lockable
|
|||
# @param [ActiveSupport::Duration] autorelease Automatically release the lock after this time
|
||||
# @param [Boolean] raise_on_failure Raise an error if a lock cannot be acquired, or fail silently
|
||||
# @raise [Mastodon::RaceConditionError]
|
||||
def with_lock(lock_name, autorelease: 15.minutes, raise_on_failure: true)
|
||||
def with_redis_lock(lock_name, autorelease: 15.minutes, raise_on_failure: true)
|
||||
with_redis do |redis|
|
||||
RedisLock.acquire(redis: redis, key: "lock:#{lock_name}", autorelease: autorelease.seconds) do |lock|
|
||||
if lock.acquired?
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ module Omniauthable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
TEMP_EMAIL_PREFIX = 'change@me'
|
||||
TEMP_EMAIL_REGEX = /\A#{TEMP_EMAIL_PREFIX}/.freeze
|
||||
TEMP_EMAIL_REGEX = /\A#{TEMP_EMAIL_PREFIX}/
|
||||
|
||||
included do
|
||||
devise :omniauthable
|
||||
|
|
@ -56,14 +56,12 @@ module Omniauthable
|
|||
user = User.new(user_params_from_auth(email, auth))
|
||||
|
||||
begin
|
||||
if /\A#{URI::DEFAULT_PARSER.make_regexp(%w(http https))}\z/.match?(auth.info.image)
|
||||
user.account.avatar_remote_url = auth.info.image
|
||||
end
|
||||
user.account.avatar_remote_url = auth.info.image if /\A#{URI::DEFAULT_PARSER.make_regexp(%w(http https))}\z/.match?(auth.info.image)
|
||||
rescue Mastodon::UnexpectedResponseError
|
||||
user.account.avatar_remote_url = nil
|
||||
end
|
||||
|
||||
user.skip_confirmation! if email_is_verified
|
||||
user.confirm! if email_is_verified
|
||||
user.save!
|
||||
user
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ module Paginable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :paginate_by_max_id, ->(limit, max_id = nil, since_id = nil) {
|
||||
scope :paginate_by_max_id, lambda { |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?
|
||||
|
|
@ -14,7 +14,7 @@ module Paginable
|
|||
# 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, max_id = nil) {
|
||||
scope :paginate_by_min_id, lambda { |limit, min_id = nil, max_id = nil|
|
||||
query = reorder(arel_table[:id]).limit(limit)
|
||||
query = query.where(arel_table[:id].gt(min_id)) if min_id.present?
|
||||
query = query.where(arel_table[:id].lt(max_id)) if max_id.present?
|
||||
|
|
|
|||
|
|
@ -42,13 +42,11 @@ module PamAuthenticable
|
|||
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
|
||||
resource = if Devise.check_at_sign && !attributes[:email].index('@')
|
||||
joins(:account).find_by(accounts: { username: attributes[:email] })
|
||||
else
|
||||
find_by(email: attributes[:email])
|
||||
end
|
||||
|
||||
if resource.nil?
|
||||
resource = new(email: attributes[:email], agreement: true)
|
||||
|
|
|
|||
72
app/models/concerns/status_safe_reblog_insert.rb
Normal file
72
app/models/concerns/status_safe_reblog_insert.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module StatusSafeReblogInsert
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
# This patch overwrites the built-in ActiveRecord `_insert_record` method to
|
||||
# ensure that no reblogs of discarded statuses are created, as this cannot be
|
||||
# enforced through DB constraints the same way as reblogs of deleted statuses
|
||||
#
|
||||
# We redefine the internal method responsible for issuing the `INSERT`
|
||||
# statement and replace the `INSERT INTO ... VALUES ...` query with an `INSERT
|
||||
# INTO ... SELECT ...` query with a `WHERE deleted_at IS NULL` clause on the
|
||||
# reblogged status to ensure consistency at the database level.
|
||||
#
|
||||
# The code is kept similar to ActiveRecord::Persistence code and calls it
|
||||
# directly when we are not handling a reblog.
|
||||
def _insert_record(values)
|
||||
return super unless values.is_a?(Hash) && values['reblog_of_id']&.value.present?
|
||||
|
||||
primary_key = self.primary_key
|
||||
primary_key_value = nil
|
||||
|
||||
if prefetch_primary_key? && primary_key
|
||||
values[primary_key] ||= begin
|
||||
primary_key_value = next_sequence_value
|
||||
_default_attributes[primary_key].with_cast_value(primary_key_value)
|
||||
end
|
||||
end
|
||||
|
||||
# The following line departs from stock ActiveRecord
|
||||
# Original code was:
|
||||
# im.insert(values.transform_keys { |name| arel_table[name] })
|
||||
# Instead, we use a custom builder when a reblog is happening:
|
||||
im = _compile_reblog_insert(values)
|
||||
|
||||
connection.insert(im, "#{self} Create", primary_key || false, primary_key_value).tap do |result|
|
||||
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
|
||||
# For our purposes, it's equivalent to a foreign key constraint violation
|
||||
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id'].value}) is not present in table \"statuses\"" if result.nil?
|
||||
end
|
||||
end
|
||||
|
||||
def _compile_reblog_insert(values)
|
||||
# This is somewhat equivalent to the following code of ActiveRecord::Persistence:
|
||||
# `arel_table.compile_insert(_substitute_values(values))`
|
||||
# The main difference is that we use a `SELECT` instead of a `VALUES` clause,
|
||||
# which means we have to build the `SELECT` clause ourselves and do a bit more
|
||||
# manual work.
|
||||
|
||||
# Instead of using Arel::InsertManager#values, we are going to use Arel::InsertManager#select
|
||||
im = Arel::InsertManager.new
|
||||
im.into(arel_table)
|
||||
|
||||
binds = []
|
||||
reblog_bind = nil
|
||||
values.each do |name, attribute|
|
||||
attr = arel_table[name]
|
||||
bind = predicate_builder.build_bind_attribute(attr.name, attribute.value)
|
||||
|
||||
im.columns << attr
|
||||
binds << bind
|
||||
|
||||
reblog_bind = bind if name == 'reblog_of_id'
|
||||
end
|
||||
|
||||
im.select(arel_table.where(arel_table[:id].eq(reblog_bind)).where(arel_table[:deleted_at].eq(nil)).project(*binds))
|
||||
|
||||
im
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -79,7 +79,7 @@ module StatusThreadingConcern
|
|||
statuses = Status.with_accounts(ids).to_a
|
||||
account_ids = statuses.map(&:account_id).uniq
|
||||
domains = statuses.filter_map(&:account_domain).uniq
|
||||
relations = relations_map_for_account(account, account_ids, domains)
|
||||
relations = account&.relations_map(account_ids, domains) || {}
|
||||
|
||||
statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? }
|
||||
|
||||
|
|
@ -108,16 +108,4 @@ module StatusThreadingConcern
|
|||
|
||||
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
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: conversations
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: conversation_mutes
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_emojis
|
||||
|
|
@ -35,7 +36,8 @@ class CustomEmoji < ApplicationRecord
|
|||
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
|
||||
|
||||
belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
|
||||
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
|
||||
|
||||
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode, inverse_of: false
|
||||
|
||||
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce +profile "!icc,*" +set modify-date +set create-date' } }, validate_media_type: false
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_filters
|
||||
|
|
@ -14,7 +15,7 @@
|
|||
#
|
||||
|
||||
class CustomFilter < ApplicationRecord
|
||||
self.ignored_columns = %w(whole_word irreversible)
|
||||
self.ignored_columns += %w(whole_word irreversible)
|
||||
|
||||
alias_attribute :title, :phrase
|
||||
alias_attribute :filter_action, :action
|
||||
|
|
@ -30,11 +31,11 @@ class CustomFilter < ApplicationRecord
|
|||
include Expireable
|
||||
include Redisable
|
||||
|
||||
enum action: [:warn, :hide], _suffix: :action
|
||||
enum action: { warn: 0, hide: 1 }, _suffix: :action
|
||||
|
||||
belongs_to :account
|
||||
has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
|
||||
has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
|
||||
has_many :keywords, class_name: 'CustomFilterKeyword', inverse_of: :custom_filter, dependent: :destroy
|
||||
has_many :statuses, class_name: 'CustomFilterStatus', inverse_of: :custom_filter, dependent: :destroy
|
||||
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
|
||||
|
||||
validates :title, :context, presence: true
|
||||
|
|
@ -101,6 +102,7 @@ class CustomFilter < ApplicationRecord
|
|||
status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present?
|
||||
|
||||
next if keyword_matches.blank? && status_matches.blank?
|
||||
|
||||
FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches)
|
||||
end
|
||||
end
|
||||
|
|
@ -111,6 +113,7 @@ class CustomFilter < ApplicationRecord
|
|||
|
||||
def invalidate_cache!
|
||||
return unless @should_invalidate_cache
|
||||
|
||||
@should_invalidate_cache = false
|
||||
|
||||
Rails.cache.delete("filters:v3:#{account_id}")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_filter_keywords
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_filter_statuses
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: devices
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class DomainAllow < ApplicationRecord
|
|||
def rule_for(domain)
|
||||
return if domain.blank?
|
||||
|
||||
uri = Addressable::URI.new.tap { |u| u.host = domain.gsub(/[\/]/, '') }
|
||||
uri = Addressable::URI.new.tap { |u| u.host = domain.delete('/') }
|
||||
|
||||
find_by(domain: uri.normalized_host)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: domain_blocks
|
||||
|
|
@ -20,11 +21,11 @@ class DomainBlock < ApplicationRecord
|
|||
include DomainNormalizable
|
||||
include DomainMaterializable
|
||||
|
||||
enum severity: [:silence, :suspend, :noop]
|
||||
enum severity: { silence: 0, suspend: 1, noop: 2 }
|
||||
|
||||
validates :domain, presence: true, uniqueness: true, domain: true
|
||||
|
||||
has_many :accounts, foreign_key: :domain, primary_key: :domain
|
||||
has_many :accounts, foreign_key: :domain, primary_key: :domain, inverse_of: false
|
||||
delegate :count, to: :accounts, prefix: true
|
||||
|
||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
|
|
@ -66,9 +67,9 @@ class DomainBlock < ApplicationRecord
|
|||
def rule_for(domain)
|
||||
return if domain.blank?
|
||||
|
||||
uri = Addressable::URI.new.tap { |u| u.host = domain.strip.gsub(/[\/]/, '') }
|
||||
uri = Addressable::URI.new.tap { |u| u.host = domain.strip.delete('/') }
|
||||
segments = uri.normalized_host.split('.')
|
||||
variants = segments.map.with_index { |_, i| segments[i..-1].join('.') }
|
||||
variants = segments.map.with_index { |_, i| segments[i..].join('.') }
|
||||
|
||||
where(domain: variants).order(Arel.sql('char_length(domain) desc')).first
|
||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: email_domain_blocks
|
||||
|
|
@ -11,7 +12,7 @@
|
|||
#
|
||||
|
||||
class EmailDomainBlock < ApplicationRecord
|
||||
self.ignored_columns = %w(
|
||||
self.ignored_columns += %w(
|
||||
ips
|
||||
last_refresh_at
|
||||
)
|
||||
|
|
@ -63,19 +64,17 @@ class EmailDomainBlock < ApplicationRecord
|
|||
|
||||
segments = uri.normalized_host.split('.')
|
||||
|
||||
segments.map.with_index { |_, i| segments[i..-1].join('.') }
|
||||
segments.map.with_index { |_, i| segments[i..].join('.') }
|
||||
end
|
||||
end
|
||||
|
||||
def extract_uris(domain_or_domains)
|
||||
Array(domain_or_domains).map do |str|
|
||||
domain = begin
|
||||
if str.include?('@')
|
||||
str.split('@', 2).last
|
||||
else
|
||||
str
|
||||
end
|
||||
end
|
||||
domain = if str.include?('@')
|
||||
str.split('@', 2).last
|
||||
else
|
||||
str
|
||||
end
|
||||
|
||||
Addressable::URI.new.tap { |u| u.host = domain.strip } if domain.present?
|
||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: encrypted_messages
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: favourites
|
||||
|
|
@ -38,6 +39,7 @@ class Favourite < ApplicationRecord
|
|||
|
||||
def decrement_cache_counters
|
||||
return if association(:status).loaded? && status.marked_for_destruction?
|
||||
|
||||
status&.decrement_count!(:favourites_count)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: featured_tags
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: follows
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: follow_recommendations
|
||||
# Table name: global_follow_recommendations
|
||||
#
|
||||
# account_id :bigint(8) primary key
|
||||
# rank :decimal(, )
|
||||
|
|
@ -10,9 +11,10 @@
|
|||
|
||||
class FollowRecommendation < ApplicationRecord
|
||||
self.primary_key = :account_id
|
||||
self.table_name = :global_follow_recommendations
|
||||
|
||||
belongs_to :account_summary, foreign_key: :account_id
|
||||
belongs_to :account, foreign_key: :account_id
|
||||
belongs_to :account_summary, foreign_key: :account_id, inverse_of: false
|
||||
belongs_to :account
|
||||
|
||||
scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) }
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class FollowRecommendationFilter
|
|||
account_ids = redis.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i)
|
||||
accounts = Account.where(id: account_ids).index_by(&:id)
|
||||
|
||||
account_ids.map { |id| accounts[id] }.compact
|
||||
account_ids.filter_map { |id| accounts[id] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: follow_recommendation_suppressions
|
||||
|
|
@ -19,9 +20,9 @@ class FollowRecommendationSuppression < ApplicationRecord
|
|||
private
|
||||
|
||||
def remove_follow_recommendations
|
||||
redis.pipelined do
|
||||
redis.pipelined do |pipeline|
|
||||
I18n.available_locales.each do |locale|
|
||||
redis.zrem("follow_recommendations:#{locale}", account_id)
|
||||
pipeline.zrem("follow_recommendations:#{locale}", account_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: follow_requests
|
||||
|
|
@ -31,7 +32,8 @@ class FollowRequest < ApplicationRecord
|
|||
validates :languages, language: true
|
||||
|
||||
def authorize!
|
||||
account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
|
||||
follow = account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
|
||||
ListAccount.where(follow_request: self).update_all(follow_request_id: nil, follow_id: follow.id) # rubocop:disable Rails/SkipsModelValidations
|
||||
MergeWorker.perform_async(target_account.id, account.id) if account.local?
|
||||
destroy!
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class Form::AdminSettings
|
|||
content_cache_retention_period
|
||||
backups_retention_period
|
||||
status_page_url
|
||||
captcha_enabled
|
||||
).freeze
|
||||
|
||||
INTEGER_KEYS = %i(
|
||||
|
|
@ -52,6 +53,7 @@ class Form::AdminSettings
|
|||
trendable_by_default
|
||||
noindex
|
||||
require_invite_text
|
||||
captcha_enabled
|
||||
).freeze
|
||||
|
||||
UPLOAD_KEYS = %i(
|
||||
|
|
@ -76,13 +78,11 @@ class Form::AdminSettings
|
|||
define_method(key) do
|
||||
return instance_variable_get("@#{key}") if instance_variable_defined?("@#{key}")
|
||||
|
||||
stored_value = begin
|
||||
if UPLOAD_KEYS.include?(key)
|
||||
SiteUpload.where(var: key).first_or_initialize(var: key)
|
||||
else
|
||||
Setting.public_send(key)
|
||||
end
|
||||
end
|
||||
stored_value = if UPLOAD_KEYS.include?(key)
|
||||
SiteUpload.where(var: key).first_or_initialize(var: key)
|
||||
else
|
||||
Setting.public_send(key)
|
||||
end
|
||||
|
||||
instance_variable_set("@#{key}", stored_value)
|
||||
end
|
||||
|
|
@ -130,6 +130,7 @@ class Form::AdminSettings
|
|||
def validate_site_uploads
|
||||
UPLOAD_KEYS.each do |key|
|
||||
next unless instance_variable_defined?("@#{key}")
|
||||
|
||||
upload = instance_variable_get("@#{key}")
|
||||
next if upload.valid?
|
||||
|
||||
|
|
|
|||
|
|
@ -36,13 +36,11 @@ class Form::CustomEmojiBatch
|
|||
def update!
|
||||
custom_emojis.each { |custom_emoji| authorize(custom_emoji, :update?) }
|
||||
|
||||
category = begin
|
||||
if category_id.present?
|
||||
CustomEmojiCategory.find(category_id)
|
||||
elsif category_name.present?
|
||||
CustomEmojiCategory.find_or_create_by!(name: category_name)
|
||||
end
|
||||
end
|
||||
category = if category_id.present?
|
||||
CustomEmojiCategory.find(category_id)
|
||||
elsif category_name.present?
|
||||
CustomEmojiCategory.find_or_create_by!(name: category_name)
|
||||
end
|
||||
|
||||
custom_emojis.each do |custom_emoji|
|
||||
custom_emoji.update(category_id: category&.id)
|
||||
|
|
|
|||
156
app/models/form/import.rb
Normal file
156
app/models/form/import.rb
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
# A non-ActiveRecord helper class for CSV uploads.
|
||||
# Handles saving contents to database.
|
||||
class Form::Import
|
||||
include ActiveModel::Model
|
||||
|
||||
MODES = %i(merge overwrite).freeze
|
||||
|
||||
FILE_SIZE_LIMIT = 20.megabytes
|
||||
ROWS_PROCESSING_LIMIT = 20_000
|
||||
|
||||
EXPECTED_HEADERS_BY_TYPE = {
|
||||
following: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'],
|
||||
blocking: ['Account address'],
|
||||
muting: ['Account address', 'Hide notifications'],
|
||||
domain_blocking: ['#domain'],
|
||||
bookmarks: ['#uri'],
|
||||
lists: ['List name', 'Account address'],
|
||||
}.freeze
|
||||
|
||||
KNOWN_FIRST_HEADERS = EXPECTED_HEADERS_BY_TYPE.values.map(&:first).uniq.freeze
|
||||
|
||||
ATTRIBUTE_BY_HEADER = {
|
||||
'Account address' => 'acct',
|
||||
'Show boosts' => 'show_reblogs',
|
||||
'Notify on new posts' => 'notify',
|
||||
'Languages' => 'languages',
|
||||
'Hide notifications' => 'hide_notifications',
|
||||
'#domain' => 'domain',
|
||||
'#uri' => 'uri',
|
||||
'List name' => 'list_name',
|
||||
}.freeze
|
||||
|
||||
class EmptyFileError < StandardError; end
|
||||
|
||||
attr_accessor :current_account, :data, :type, :overwrite, :bulk_import
|
||||
|
||||
validates :type, presence: true
|
||||
validates :data, presence: true
|
||||
validate :validate_data
|
||||
|
||||
def guessed_type
|
||||
return :muting if csv_data.headers.include?('Hide notifications')
|
||||
return :following if csv_data.headers.include?('Show boosts') || csv_data.headers.include?('Notify on new posts') || csv_data.headers.include?('Languages')
|
||||
return :following if data.original_filename&.start_with?('follows') || data.original_filename&.start_with?('following_accounts')
|
||||
return :blocking if data.original_filename&.start_with?('blocks') || data.original_filename&.start_with?('blocked_accounts')
|
||||
return :muting if data.original_filename&.start_with?('mutes') || data.original_filename&.start_with?('muted_accounts')
|
||||
return :domain_blocking if data.original_filename&.start_with?('domain_blocks') || data.original_filename&.start_with?('blocked_domains')
|
||||
return :bookmarks if data.original_filename&.start_with?('bookmarks')
|
||||
return :lists if data.original_filename&.start_with?('lists')
|
||||
end
|
||||
|
||||
# Whether the uploaded CSV file seems to correspond to a different import type than the one selected
|
||||
def likely_mismatched?
|
||||
guessed_type.present? && guessed_type != type.to_sym
|
||||
end
|
||||
|
||||
def save
|
||||
return false unless valid?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
now = Time.now.utc
|
||||
@bulk_import = current_account.bulk_imports.create(type: type, overwrite: overwrite || false, state: :unconfirmed, original_filename: data.original_filename, likely_mismatched: likely_mismatched?)
|
||||
nb_items = BulkImportRow.insert_all(parsed_rows.map { |row| { bulk_import_id: bulk_import.id, data: row, created_at: now, updated_at: now } }).length # rubocop:disable Rails/SkipsModelValidations
|
||||
@bulk_import.update(total_items: nb_items)
|
||||
end
|
||||
end
|
||||
|
||||
def mode
|
||||
overwrite ? :overwrite : :merge
|
||||
end
|
||||
|
||||
def mode=(str)
|
||||
self.overwrite = str.to_sym == :overwrite
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_csv_headers
|
||||
case type.to_sym
|
||||
when :following, :blocking, :muting
|
||||
['Account address']
|
||||
when :domain_blocking
|
||||
['#domain']
|
||||
when :bookmarks
|
||||
['#uri']
|
||||
when :lists
|
||||
['List name', 'Account address']
|
||||
end
|
||||
end
|
||||
|
||||
def csv_data
|
||||
return @csv_data if defined?(@csv_data)
|
||||
|
||||
csv_converter = lambda do |field, field_info|
|
||||
case field_info.header
|
||||
when 'Show boosts', 'Notify on new posts', 'Hide notifications'
|
||||
ActiveModel::Type::Boolean.new.cast(field)
|
||||
when 'Languages'
|
||||
field&.split(',')&.map(&:strip)&.presence
|
||||
when 'Account address'
|
||||
field.strip.gsub(/\A@/, '')
|
||||
when '#domain', '#uri', 'List name'
|
||||
field.strip
|
||||
else
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
|
||||
@csv_data.take(1) # Ensure the headers are read
|
||||
raise EmptyFileError if @csv_data.headers == true
|
||||
|
||||
@csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: default_csv_headers, converters: csv_converter) unless KNOWN_FIRST_HEADERS.include?(@csv_data.headers&.first)
|
||||
@csv_data
|
||||
end
|
||||
|
||||
def csv_row_count
|
||||
return @csv_row_count if defined?(@csv_row_count)
|
||||
|
||||
csv_data.rewind
|
||||
@csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
|
||||
end
|
||||
|
||||
def parsed_rows
|
||||
csv_data.rewind
|
||||
|
||||
expected_headers = EXPECTED_HEADERS_BY_TYPE[type.to_sym]
|
||||
|
||||
csv_data.take(ROWS_PROCESSING_LIMIT + 1).map do |row|
|
||||
row.to_h.slice(*expected_headers).transform_keys { |key| ATTRIBUTE_BY_HEADER[key] }
|
||||
end
|
||||
end
|
||||
|
||||
def validate_data
|
||||
return if data.nil?
|
||||
return errors.add(:data, I18n.t('imports.errors.too_large')) if data.size > FILE_SIZE_LIMIT
|
||||
return errors.add(:data, I18n.t('imports.errors.incompatible_type')) unless default_csv_headers.all? { |header| csv_data.headers.include?(header) }
|
||||
|
||||
errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
|
||||
|
||||
if type.to_sym == :following
|
||||
base_limit = FollowLimitValidator.limit_for_account(current_account)
|
||||
limit = base_limit
|
||||
limit -= current_account.following_count unless overwrite
|
||||
errors.add(:data, I18n.t('users.follow_limit_reached', limit: base_limit)) if csv_row_count > limit
|
||||
end
|
||||
rescue CSV::MalformedCSVError => e
|
||||
errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
|
||||
rescue EmptyFileError
|
||||
errors.add(:data, I18n.t('imports.errors.empty'))
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: identities
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: imports
|
||||
|
|
@ -16,6 +17,9 @@
|
|||
# overwrite :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
# NOTE: This is a deprecated model, only kept to not break ongoing imports
|
||||
# on upgrade. See `BulkImport` and `Form::Import` for its replacements.
|
||||
|
||||
class Import < ApplicationRecord
|
||||
FILE_TYPES = %w(text/plain text/csv application/csv).freeze
|
||||
MODES = %i(merge overwrite).freeze
|
||||
|
|
@ -24,10 +28,9 @@ class Import < ApplicationRecord
|
|||
|
||||
belongs_to :account
|
||||
|
||||
enum type: [:following, :blocking, :muting, :domain_blocking, :bookmarks]
|
||||
enum type: { following: 0, blocking: 1, muting: 2, domain_blocking: 3, bookmarks: 4 }
|
||||
|
||||
validates :type, presence: true
|
||||
validates_with ImportValidator, on: :create
|
||||
|
||||
has_attached_file :data
|
||||
validates_attachment_content_type :data, content_type: FILE_TYPES
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: instances
|
||||
|
|
@ -12,13 +13,17 @@ class Instance < ApplicationRecord
|
|||
|
||||
attr_accessor :failure_days
|
||||
|
||||
has_many :accounts, foreign_key: :domain, primary_key: :domain
|
||||
has_many :accounts, foreign_key: :domain, primary_key: :domain, inverse_of: false
|
||||
|
||||
belongs_to :domain_block, foreign_key: :domain, primary_key: :domain
|
||||
belongs_to :domain_allow, foreign_key: :domain, primary_key: :domain
|
||||
belongs_to :unavailable_domain, foreign_key: :domain, primary_key: :domain # skipcq: RB-RL1031
|
||||
with_options foreign_key: :domain, primary_key: :domain, inverse_of: false do
|
||||
belongs_to :domain_block
|
||||
belongs_to :domain_allow
|
||||
belongs_to :unavailable_domain # skipcq: RB-RL1031
|
||||
end
|
||||
|
||||
scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) }
|
||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
scope :by_domain_and_subdomains, ->(domain) { where("reverse('.' || domain) LIKE reverse(?)", "%.#{domain}") }
|
||||
|
||||
def self.refresh
|
||||
Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: invites
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ip_blocks
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: lists
|
||||
|
|
@ -9,6 +10,7 @@
|
|||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# replies_policy :integer default("list"), not null
|
||||
# exclusive :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class List < ApplicationRecord
|
||||
|
|
@ -16,7 +18,7 @@ class List < ApplicationRecord
|
|||
|
||||
PER_ACCOUNT_LIMIT = 50
|
||||
|
||||
enum replies_policy: [:list, :followed, :none], _prefix: :show
|
||||
enum replies_policy: { list: 0, followed: 1, none: 2 }, _prefix: :show
|
||||
|
||||
belongs_to :account, optional: true
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,42 @@
|
|||
# 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)
|
||||
# id :bigint(8) not null, primary key
|
||||
# list_id :bigint(8) not null
|
||||
# account_id :bigint(8) not null
|
||||
# follow_id :bigint(8)
|
||||
# follow_request_id :bigint(8)
|
||||
#
|
||||
|
||||
class ListAccount < ApplicationRecord
|
||||
belongs_to :list
|
||||
belongs_to :account
|
||||
belongs_to :follow, optional: true
|
||||
belongs_to :follow_request, optional: true
|
||||
|
||||
validates :account_id, uniqueness: { scope: :list_id }
|
||||
validate :validate_relationship
|
||||
|
||||
before_validation :set_follow
|
||||
|
||||
private
|
||||
|
||||
def set_follow
|
||||
self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id) unless list.account_id == account.id
|
||||
return if list.account_id == account.id
|
||||
|
||||
self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
self.follow_request = FollowRequest.find_by!(account_id: list.account_id, target_account_id: account.id)
|
||||
end
|
||||
|
||||
def validate_relationship
|
||||
return if list.account_id == account_id
|
||||
|
||||
errors.add(:account_id, 'follow relationship missing') if follow_id.nil? && follow_request_id.nil?
|
||||
errors.add(:follow, 'mismatched accounts') if follow_id.present? && follow.target_account_id != account_id
|
||||
errors.add(:follow_request, 'mismatched accounts') if follow_request_id.present? && follow_request.target_account_id != account_id
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: login_activities
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: media_attachments
|
||||
|
|
@ -33,16 +34,16 @@ class MediaAttachment < ApplicationRecord
|
|||
|
||||
include Attachmentable
|
||||
|
||||
enum type: [:image, :gifv, :video, :unknown, :audio]
|
||||
enum processing: [:queued, :in_progress, :complete, :failed], _prefix: true
|
||||
enum type: { image: 0, gifv: 1, video: 2, unknown: 3, audio: 4 }
|
||||
enum processing: { queued: 0, in_progress: 1, complete: 2, failed: 3 }, _prefix: true
|
||||
|
||||
MAX_DESCRIPTION_LENGTH = 1_500
|
||||
|
||||
IMAGE_LIMIT = 16.megabytes
|
||||
VIDEO_LIMIT = 80.megabytes
|
||||
VIDEO_LIMIT = 99.megabytes
|
||||
|
||||
MAX_VIDEO_MATRIX_LIMIT = 2_304_000 # 1920x1200px
|
||||
MAX_VIDEO_FRAME_RATE = 60
|
||||
MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px
|
||||
MAX_VIDEO_FRAME_RATE = 120
|
||||
|
||||
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze
|
||||
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
|
||||
|
|
@ -56,7 +57,7 @@ class MediaAttachment < ApplicationRecord
|
|||
).freeze
|
||||
|
||||
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif image/heic image/heif image/webp image/avif).freeze
|
||||
IMAGE_CONVERTIBLE_MIME_TYPES = %w(image/heic image/heif).freeze
|
||||
IMAGE_CONVERTIBLE_MIME_TYPES = %w(image/heic image/heif image/avif).freeze
|
||||
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
|
||||
VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
|
||||
AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/vnd.wave audio/ogg audio/vorbis audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
|
||||
|
|
@ -68,7 +69,7 @@ class MediaAttachment < ApplicationRecord
|
|||
|
||||
IMAGE_STYLES = {
|
||||
original: {
|
||||
pixels: 8_847_360, #4096x2160px
|
||||
pixels: 8_294_400, # 3840x2160px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
}.freeze,
|
||||
|
||||
|
|
@ -134,7 +135,7 @@ class MediaAttachment < ApplicationRecord
|
|||
convert_options: {
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
|
||||
:vf => 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
format: 'png',
|
||||
|
|
@ -168,6 +169,8 @@ class MediaAttachment < ApplicationRecord
|
|||
original: IMAGE_STYLES[:small].freeze,
|
||||
}.freeze
|
||||
|
||||
DEFAULT_STYLES = [:original].freeze
|
||||
|
||||
GLOBAL_CONVERT_OPTIONS = {
|
||||
all: '-quality 90 +profile "!icc,*" +set modify-date +set create-date',
|
||||
}.freeze
|
||||
|
|
@ -270,12 +273,12 @@ class MediaAttachment < ApplicationRecord
|
|||
delay_processing? && attachment_name == :file
|
||||
end
|
||||
|
||||
after_commit :enqueue_processing, on: :create
|
||||
after_commit :reset_parent_cache, on: :update
|
||||
|
||||
before_create :set_unknown_type
|
||||
before_create :set_processing
|
||||
|
||||
after_commit :enqueue_processing, on: :create
|
||||
after_commit :reset_parent_cache, on: :update
|
||||
|
||||
after_post_process :set_meta
|
||||
|
||||
class << self
|
||||
|
|
@ -372,7 +375,7 @@ class MediaAttachment < ApplicationRecord
|
|||
return {} if width.nil?
|
||||
|
||||
{
|
||||
width: width,
|
||||
width: width,
|
||||
height: height,
|
||||
size: "#{width}x#{height}",
|
||||
aspect: width.to_f / height,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: mentions
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: mutes
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: notifications
|
||||
|
|
@ -19,12 +20,12 @@ class Notification < ApplicationRecord
|
|||
include Paginable
|
||||
|
||||
LEGACY_TYPE_CLASS_MAP = {
|
||||
'Mention' => :mention,
|
||||
'Status' => :reblog,
|
||||
'Follow' => :follow,
|
||||
'Mention' => :mention,
|
||||
'Status' => :reblog,
|
||||
'Follow' => :follow,
|
||||
'FollowRequest' => :follow_request,
|
||||
'Favourite' => :favourite,
|
||||
'Poll' => :poll,
|
||||
'Favourite' => :favourite,
|
||||
'Poll' => :poll,
|
||||
}.freeze
|
||||
|
||||
TYPES = %i(
|
||||
|
|
@ -54,13 +55,15 @@ class Notification < ApplicationRecord
|
|||
belongs_to :from_account, class_name: 'Account', optional: true
|
||||
belongs_to :activity, polymorphic: true, optional: true
|
||||
|
||||
belongs_to :mention, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :report, foreign_key: 'activity_id', optional: true
|
||||
with_options foreign_key: 'activity_id', optional: true do
|
||||
belongs_to :mention, inverse_of: :notification
|
||||
belongs_to :status, inverse_of: :notification
|
||||
belongs_to :follow, inverse_of: :notification
|
||||
belongs_to :follow_request, inverse_of: :notification
|
||||
belongs_to :favourite, inverse_of: :notification
|
||||
belongs_to :poll, inverse_of: false
|
||||
belongs_to :report, inverse_of: false
|
||||
end
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
|
|
@ -87,13 +90,11 @@ class Notification < ApplicationRecord
|
|||
|
||||
class << self
|
||||
def browserable(types: [], exclude_types: [], from_account_id: nil)
|
||||
requested_types = begin
|
||||
if types.empty?
|
||||
TYPES
|
||||
else
|
||||
types.map(&:to_sym) & TYPES
|
||||
end
|
||||
end
|
||||
requested_types = if types.empty?
|
||||
TYPES
|
||||
else
|
||||
types.map(&:to_sym) & TYPES
|
||||
end
|
||||
|
||||
requested_types -= exclude_types.map(&:to_sym)
|
||||
|
||||
|
|
@ -110,10 +111,10 @@ class Notification < ApplicationRecord
|
|||
|
||||
# Instead of using the usual `includes`, manually preload each type.
|
||||
# If polymorphic associations are loaded with the usual `includes`, other types of associations will be loaded more.
|
||||
ActiveRecord::Associations::Preloader.new.preload(grouped_notifications, associations)
|
||||
ActiveRecord::Associations::Preloader.new(records: grouped_notifications, associations: associations)
|
||||
end
|
||||
|
||||
unique_target_statuses = notifications.map(&:target_status).compact.uniq
|
||||
unique_target_statuses = notifications.filter_map(&:target_status).uniq
|
||||
# Call cache_collection in block
|
||||
cached_statuses_by_id = yield(unique_target_statuses).index_by(&:id)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: one_time_keys
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: polls
|
||||
|
|
@ -74,9 +75,9 @@ class Poll < ApplicationRecord
|
|||
|
||||
def initialize(poll, id, title, votes_count)
|
||||
super(
|
||||
poll: poll,
|
||||
id: id,
|
||||
title: title,
|
||||
poll: poll,
|
||||
id: id,
|
||||
title: title,
|
||||
votes_count: votes_count,
|
||||
)
|
||||
end
|
||||
|
|
@ -100,11 +101,12 @@ class Poll < ApplicationRecord
|
|||
end
|
||||
|
||||
def prepare_options
|
||||
self.options = options.map(&:strip).reject(&:blank?)
|
||||
self.options = options.map(&:strip).compact_blank
|
||||
end
|
||||
|
||||
def reset_parent_cache
|
||||
return if status_id.nil?
|
||||
|
||||
Rails.cache.delete("statuses/#{status_id}")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: poll_votes
|
||||
|
|
@ -22,6 +23,7 @@ class PollVote < ApplicationRecord
|
|||
after_create_commit :increment_counter_cache
|
||||
|
||||
delegate :local?, to: :account
|
||||
delegate :multiple?, :expired?, to: :poll, prefix: true
|
||||
|
||||
def object_type
|
||||
:vote
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: preview_cards
|
||||
|
|
@ -29,13 +30,15 @@
|
|||
# max_score_at :datetime
|
||||
# trendable :boolean
|
||||
# link_type :integer
|
||||
# published_at :datetime
|
||||
# image_description :string default(""), not null
|
||||
#
|
||||
|
||||
class PreviewCard < ApplicationRecord
|
||||
include Attachmentable
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 1.megabytes
|
||||
LIMIT = 2.megabytes
|
||||
|
||||
BLURHASH_OPTIONS = {
|
||||
x_comp: 4,
|
||||
|
|
@ -44,8 +47,8 @@ class PreviewCard < ApplicationRecord
|
|||
|
||||
self.inheritance_column = false
|
||||
|
||||
enum type: [:link, :photo, :video, :rich]
|
||||
enum link_type: [:unknown, :article]
|
||||
enum type: { link: 0, photo: 1, video: 2, rich: 3 }
|
||||
enum link_type: { unknown: 0, article: 1 }
|
||||
|
||||
has_and_belongs_to_many :statuses
|
||||
has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
|
||||
|
|
@ -120,7 +123,7 @@ class PreviewCard < ApplicationRecord
|
|||
def image_styles(file)
|
||||
styles = {
|
||||
original: {
|
||||
geometry: '400x400>',
|
||||
pixels: 230_400, # 640x360px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
convert_options: '-coalesce',
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: preview_card_providers
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
#
|
||||
|
||||
class PreviewCardProvider < ApplicationRecord
|
||||
include Paginable
|
||||
include DomainNormalizable
|
||||
include Attachmentable
|
||||
|
||||
|
|
@ -52,6 +54,6 @@ class PreviewCardProvider < ApplicationRecord
|
|||
|
||||
def self.matching_domain(domain)
|
||||
segments = domain.split('.')
|
||||
where(domain: segments.map.with_index { |_, i| segments[i..-1].join('.') }).order(Arel.sql('char_length(domain) desc')).first
|
||||
where(domain: segments.map.with_index { |_, i| segments[i..].join('.') }).order(Arel.sql('char_length(domain) desc')).first
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ class RelationshipFilter
|
|||
location
|
||||
).freeze
|
||||
|
||||
IGNORED_PARAMS = %w(relationship page).freeze
|
||||
|
||||
attr_reader :params, :account
|
||||
|
||||
def initialize(account, params)
|
||||
|
|
@ -23,7 +25,7 @@ class RelationshipFilter
|
|||
scope = scope_for('relationship', params['relationship'].to_s.strip)
|
||||
|
||||
params.each do |key, value|
|
||||
next if %w(relationship page).include?(key)
|
||||
next if IGNORED_PARAMS.include?(key)
|
||||
|
||||
scope.merge!(scope_for(key.to_s, value.to_s.strip)) if value.present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: relays
|
||||
|
|
@ -14,7 +15,7 @@
|
|||
class Relay < ApplicationRecord
|
||||
validates :inbox_url, presence: true, uniqueness: true, url: true, if: :will_save_change_to_inbox_url?
|
||||
|
||||
enum state: [:idle, :pending, :accepted, :rejected]
|
||||
enum state: { idle: 0, pending: 1, accepted: 2, rejected: 3 }
|
||||
|
||||
scope :enabled, -> { accepted }
|
||||
|
||||
|
|
|
|||
|
|
@ -36,13 +36,11 @@ class RemoteFollow
|
|||
|
||||
username, domain = value.strip.gsub(/\A@/, '').split('@')
|
||||
|
||||
domain = begin
|
||||
if TagManager.instance.local_domain?(domain)
|
||||
nil
|
||||
else
|
||||
TagManager.instance.normalize_domain(domain)
|
||||
end
|
||||
end
|
||||
domain = if TagManager.instance.local_domain?(domain)
|
||||
nil
|
||||
else
|
||||
TagManager.instance.normalize_domain(domain)
|
||||
end
|
||||
|
||||
[username, domain].compact.join('@')
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: reports
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
#
|
||||
|
||||
class Report < ApplicationRecord
|
||||
self.ignored_columns = %w(action_taken)
|
||||
self.ignored_columns += %w(action_taken)
|
||||
|
||||
include Paginable
|
||||
include RateLimitable
|
||||
|
|
@ -32,31 +33,36 @@ class Report < ApplicationRecord
|
|||
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
|
||||
has_many :notes, class_name: 'ReportNote', inverse_of: :report, dependent: :destroy
|
||||
has_many :notifications, as: :activity, dependent: :destroy
|
||||
|
||||
scope :unresolved, -> { where(action_taken_at: nil) }
|
||||
scope :resolved, -> { where.not(action_taken_at: nil) }
|
||||
scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }
|
||||
|
||||
validates :comment, length: { maximum: 1_000 }
|
||||
# A report is considered local if the reporter is local
|
||||
delegate :local?, to: :account
|
||||
|
||||
validates :comment, length: { maximum: 1_000 }, if: :local?
|
||||
validates :rule_ids, absence: true, unless: :violation?
|
||||
|
||||
validate :validate_rule_ids
|
||||
|
||||
# entries here need to be kept in sync with the front-end:
|
||||
# - app/javascript/mastodon/features/notifications/components/report.jsx
|
||||
# - app/javascript/mastodon/features/report/category.jsx
|
||||
# - app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
|
||||
enum category: {
|
||||
other: 0,
|
||||
spam: 1_000,
|
||||
legal: 1_500,
|
||||
violation: 2_000,
|
||||
}
|
||||
|
||||
def local?
|
||||
false # Force uri_for to use uri attribute
|
||||
end
|
||||
|
||||
before_validation :set_uri, only: :create
|
||||
|
||||
after_create_commit :trigger_webhooks
|
||||
after_create_commit :trigger_create_webhooks
|
||||
after_update_commit :trigger_update_webhooks
|
||||
|
||||
def object_type
|
||||
:flag
|
||||
|
|
@ -153,7 +159,11 @@ class Report < ApplicationRecord
|
|||
errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids&.size
|
||||
end
|
||||
|
||||
def trigger_webhooks
|
||||
def trigger_create_webhooks
|
||||
TriggerWebhookWorker.perform_async('report.created', 'Report', id)
|
||||
end
|
||||
|
||||
def trigger_update_webhooks
|
||||
TriggerWebhookWorker.perform_async('report.updated', 'Report', id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: report_notes
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: session_activations
|
||||
|
|
@ -35,8 +36,8 @@ class SessionActivation < ApplicationRecord
|
|||
detection.platform.id
|
||||
end
|
||||
|
||||
before_create :assign_access_token
|
||||
before_save :assign_user_agent
|
||||
before_create :assign_access_token
|
||||
|
||||
class << self
|
||||
def active?(id)
|
||||
|
|
@ -51,6 +52,7 @@ class SessionActivation < ApplicationRecord
|
|||
|
||||
def deactivate(id)
|
||||
return unless id
|
||||
|
||||
where(session_id: id).destroy_all
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: settings
|
||||
|
|
@ -30,6 +31,7 @@ class Setting < RailsSettings::Base
|
|||
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]
|
||||
|
|
@ -43,6 +45,7 @@ class Setting < RailsSettings::Base
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -51,6 +54,7 @@ class Setting < RailsSettings::Base
|
|||
|
||||
def default_settings
|
||||
return {} unless RailsSettings::Default.enabled?
|
||||
|
||||
RailsSettings::Default.instance
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: site_uploads
|
||||
|
|
@ -42,7 +43,7 @@ class SiteUpload < ApplicationRecord
|
|||
|
||||
has_attached_file :file, styles: ->(file) { STYLES[file.instance.var.to_sym] }, convert_options: { all: '-coalesce +profile "!icc,*" +set modify-date +set create-date' }, processors: [:lazy_thumbnail, :blurhash_transcoder, :type_corrector]
|
||||
|
||||
validates_attachment_content_type :file, content_type: /\Aimage\/.*\z/
|
||||
validates_attachment_content_type :file, content_type: %r{\Aimage/.*\z}
|
||||
validates :file, presence: true
|
||||
validates :var, presence: true, uniqueness: true
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: statuses
|
||||
|
|
@ -29,14 +30,13 @@
|
|||
#
|
||||
|
||||
class Status < ApplicationRecord
|
||||
before_destroy :unlink_from_conversations!
|
||||
|
||||
include Discard::Model
|
||||
include Paginable
|
||||
include Cacheable
|
||||
include StatusThreadingConcern
|
||||
include StatusSnapshotConcern
|
||||
include RateLimitable
|
||||
include StatusSafeReblogInsert
|
||||
|
||||
rate_limit by: :account, family: :statuses
|
||||
|
||||
|
|
@ -48,14 +48,14 @@ class Status < ApplicationRecord
|
|||
|
||||
update_index('statuses', :proper)
|
||||
|
||||
enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
|
||||
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 }, _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 :in_reply_to_account, 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 :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true, inverse_of: false
|
||||
|
||||
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
|
||||
|
|
@ -94,22 +94,47 @@ class Status < ApplicationRecord
|
|||
scope :local, -> { where(local: true).or(where(uri: nil)) }
|
||||
scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
|
||||
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 :without_reblogs, -> { where(statuses: { reblog_of_id: nil }) }
|
||||
scope :with_public_visibility, -> { where(visibility: :public) }
|
||||
scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
|
||||
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, ->(tag_ids) {
|
||||
scope :tagged_with_all, lambda { |tag_ids|
|
||||
Array(tag_ids).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}")
|
||||
result.where(<<~SQL.squish, tag_id: id)
|
||||
EXISTS(SELECT 1 FROM statuses_tags WHERE statuses_tags.status_id = statuses.id AND statuses_tags.tag_id = :tag_id)
|
||||
SQL
|
||||
end
|
||||
}
|
||||
scope :tagged_with_none, ->(tag_ids) {
|
||||
scope :tagged_with_none, lambda { |tag_ids|
|
||||
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
|
||||
}
|
||||
|
||||
after_create_commit :trigger_create_webhooks
|
||||
after_update_commit :trigger_update_webhooks
|
||||
|
||||
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?
|
||||
|
||||
before_validation :prepare_contents, if: :local?
|
||||
before_validation :set_reblog
|
||||
before_validation :set_visibility
|
||||
before_validation :set_conversation
|
||||
before_validation :set_local
|
||||
|
||||
around_create Mastodon::Snowflake::Callbacks
|
||||
|
||||
after_create :set_poll_id
|
||||
|
||||
# The `prepend: true` option below ensures this runs before
|
||||
# the `dependent: destroy` callbacks remove relevant records
|
||||
before_destroy :unlink_from_conversations!, prepend: true
|
||||
|
||||
cache_associated :application,
|
||||
:media_attachments,
|
||||
:conversation,
|
||||
|
|
@ -136,6 +161,10 @@ class Status < ApplicationRecord
|
|||
|
||||
REAL_TIME_WINDOW = 6.hours
|
||||
|
||||
def cache_key
|
||||
"v3:#{super}"
|
||||
end
|
||||
|
||||
def searchable_by(preloaded = nil)
|
||||
ids = []
|
||||
|
||||
|
|
@ -303,22 +332,6 @@ class Status < ApplicationRecord
|
|||
attributes['trendable'].nil? && account.requires_review_notification?
|
||||
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?
|
||||
|
||||
before_validation :prepare_contents, if: :local?
|
||||
before_validation :set_reblog
|
||||
before_validation :set_visibility
|
||||
before_validation :set_conversation
|
||||
before_validation :set_local
|
||||
|
||||
around_create Mastodon::Snowflake::Callbacks
|
||||
|
||||
after_create :set_poll_id
|
||||
|
||||
class << self
|
||||
def selectable_visibilities
|
||||
visibilities.keys - %w(direct limited)
|
||||
|
|
@ -354,13 +367,25 @@ class Status < ApplicationRecord
|
|||
|
||||
account_ids.uniq!
|
||||
|
||||
status_ids = cached_items.map { |item| item.reblog? ? item.reblog_of_id : item.id }.uniq
|
||||
|
||||
return if account_ids.empty?
|
||||
|
||||
accounts = Account.where(id: account_ids).includes(:account_stat, :user).index_by(&:id)
|
||||
|
||||
status_stats = StatusStat.where(status_id: status_ids).index_by(&:status_id)
|
||||
|
||||
cached_items.each do |item|
|
||||
item.account = accounts[item.account_id]
|
||||
item.reblog.account = accounts[item.reblog.account_id] if item.reblog?
|
||||
|
||||
if item.reblog?
|
||||
status_stat = status_stats[item.reblog.id]
|
||||
item.reblog.status_stat = status_stat if status_stat.present?
|
||||
else
|
||||
status_stat = status_stats[item.id]
|
||||
item.status_stat = status_stat if status_stat.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -368,13 +393,12 @@ class Status < ApplicationRecord
|
|||
return [] if text.blank?
|
||||
|
||||
text.scan(FetchLinkCardService::URL_PATTERN).map(&:second).uniq.filter_map do |url|
|
||||
status = begin
|
||||
if TagManager.instance.local_url?(url)
|
||||
ActivityPub::TagManager.instance.uri_to_resource(url, Status)
|
||||
else
|
||||
EntityCache.instance.status(url)
|
||||
end
|
||||
end
|
||||
status = if TagManager.instance.local_url?(url)
|
||||
ActivityPub::TagManager.instance.uri_to_resource(url, Status)
|
||||
else
|
||||
EntityCache.instance.status(url)
|
||||
end
|
||||
|
||||
status&.distributable? ? status : nil
|
||||
end
|
||||
end
|
||||
|
|
@ -384,63 +408,6 @@ class Status < ApplicationRecord
|
|||
super || build_status_stat
|
||||
end
|
||||
|
||||
# Hack to use a "INSERT INTO ... SELECT ..." query instead of "INSERT INTO ... VALUES ..." query
|
||||
def self._insert_record(values)
|
||||
if values.is_a?(Hash) && values['reblog_of_id'].present?
|
||||
primary_key = self.primary_key
|
||||
primary_key_value = nil
|
||||
|
||||
if primary_key
|
||||
primary_key_value = values[primary_key]
|
||||
|
||||
if !primary_key_value && prefetch_primary_key?
|
||||
primary_key_value = next_sequence_value
|
||||
values[primary_key] = primary_key_value
|
||||
end
|
||||
end
|
||||
|
||||
# The following line is where we differ from stock ActiveRecord implementation
|
||||
im = _compile_reblog_insert(values)
|
||||
|
||||
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
|
||||
# For our purposes, it's equivalent to a foreign key constraint violation
|
||||
result = connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
|
||||
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id']}) is not present in table \"statuses\"" if result.nil?
|
||||
|
||||
result
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def self._compile_reblog_insert(values)
|
||||
# This is somewhat equivalent to the following code of ActiveRecord::Persistence:
|
||||
# `arel_table.compile_insert(_substitute_values(values))`
|
||||
# The main difference is that we use a `SELECT` instead of a `VALUES` clause,
|
||||
# which means we have to build the `SELECT` clause ourselves and do a bit more
|
||||
# manual work.
|
||||
|
||||
# Instead of using Arel::InsertManager#values, we are going to use Arel::InsertManager#select
|
||||
im = Arel::InsertManager.new
|
||||
im.into(arel_table)
|
||||
|
||||
binds = []
|
||||
reblog_bind = nil
|
||||
values.each do |name, value|
|
||||
attr = arel_table[name]
|
||||
bind = predicate_builder.build_bind_attribute(attr.name, value)
|
||||
|
||||
im.columns << attr
|
||||
binds << bind
|
||||
|
||||
reblog_bind = bind if name == 'reblog_of_id'
|
||||
end
|
||||
|
||||
im.select(arel_table.where(arel_table[:id].eq(reblog_bind)).where(arel_table[:deleted_at].eq(nil)).project(*binds))
|
||||
|
||||
im
|
||||
end
|
||||
|
||||
def discard_with_reblogs
|
||||
discard_time = Time.current
|
||||
Status.unscoped.where(reblog_of_id: id, deleted_at: [nil, deleted_at]).in_batches.update_all(deleted_at: discard_time) unless reblog?
|
||||
|
|
@ -535,4 +502,12 @@ class Status < ApplicationRecord
|
|||
reblog&.decrement_count!(:reblogs_count) if reblog?
|
||||
thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && distributable?
|
||||
end
|
||||
|
||||
def trigger_create_webhooks
|
||||
TriggerWebhookWorker.perform_async('status.created', 'Status', id) if local?
|
||||
end
|
||||
|
||||
def trigger_update_webhooks
|
||||
TriggerWebhookWorker.perform_async('status.updated', 'Status', id) if local?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: status_edits
|
||||
|
|
@ -19,7 +20,7 @@
|
|||
class StatusEdit < ApplicationRecord
|
||||
include RateLimitable
|
||||
|
||||
self.ignored_columns = %w(
|
||||
self.ignored_columns += %w(
|
||||
media_attachments_changed
|
||||
)
|
||||
|
||||
|
|
@ -45,20 +46,19 @@ class StatusEdit < ApplicationRecord
|
|||
|
||||
def emojis
|
||||
return @emojis if defined?(@emojis)
|
||||
|
||||
@emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain)
|
||||
end
|
||||
|
||||
def ordered_media_attachments
|
||||
return @ordered_media_attachments if defined?(@ordered_media_attachments)
|
||||
|
||||
@ordered_media_attachments = begin
|
||||
if ordered_media_attachment_ids.nil?
|
||||
[]
|
||||
else
|
||||
map = status.media_attachments.index_by(&:id)
|
||||
ordered_media_attachment_ids.map.with_index { |media_attachment_id, index| PreservedMediaAttachment.new(media_attachment: map[media_attachment_id], description: media_descriptions[index]) }
|
||||
end
|
||||
end
|
||||
@ordered_media_attachments = if ordered_media_attachment_ids.nil?
|
||||
[]
|
||||
else
|
||||
map = status.media_attachments.index_by(&:id)
|
||||
ordered_media_attachment_ids.map.with_index { |media_attachment_id, index| PreservedMediaAttachment.new(media_attachment: map[media_attachment_id], description: media_descriptions[index]) }
|
||||
end
|
||||
end
|
||||
|
||||
def proper
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: status_pins
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: status_stats
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class SystemKey < ApplicationRecord
|
|||
|
||||
before_validation :set_key
|
||||
|
||||
scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - ROTATION_PERIOD * 3)) }
|
||||
scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - (ROTATION_PERIOD * 3))) }
|
||||
|
||||
class << self
|
||||
def current_key
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tags
|
||||
|
|
@ -30,10 +31,10 @@ class Tag < ApplicationRecord
|
|||
HASHTAG_FIRST_SEQUENCE_CHUNK_ONE = "[[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}]"
|
||||
HASHTAG_FIRST_SEQUENCE_CHUNK_TWO = "[[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_]"
|
||||
HASHTAG_FIRST_SEQUENCE = "(#{HASHTAG_FIRST_SEQUENCE_CHUNK_ONE}#{HASHTAG_FIRST_SEQUENCE_CHUNK_TWO})"
|
||||
HASTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)'
|
||||
HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASTAG_LAST_SEQUENCE}"
|
||||
HASHTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)'
|
||||
HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASHTAG_LAST_SEQUENCE}"
|
||||
|
||||
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_PAT})/i
|
||||
HASHTAG_RE = %r{(?:^|[^/)\w])#(#{HASHTAG_NAME_PAT})}i
|
||||
HASHTAG_NAME_RE = /\A(#{HASHTAG_NAME_PAT})\z/i
|
||||
HASHTAG_INVALID_CHARS_RE = /[^[:alnum:]#{HASHTAG_SEPARATORS}]/
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ class Tag < ApplicationRecord
|
|||
scope :listable, -> { where(listable: [true, nil]) }
|
||||
scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) }
|
||||
scope :not_trendable, -> { where(trendable: false) }
|
||||
scope :recently_used, ->(account) {
|
||||
scope :recently_used, lambda { |account|
|
||||
joins(:statuses)
|
||||
.where(statuses: { id: account.statuses.select(:id).limit(1000) })
|
||||
.group(:id).order(Arel.sql('count(*) desc'))
|
||||
|
|
|
|||
14
app/models/translation.rb
Normal file
14
app/models/translation.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Translation < ActiveModelSerializers::Model
|
||||
attributes :status, :detected_source_language, :language, :provider,
|
||||
:content, :spoiler_text, :poll_options, :media_attachments
|
||||
|
||||
class Option < ActiveModelSerializers::Model
|
||||
attributes :title
|
||||
end
|
||||
|
||||
class MediaAttachment < ActiveModelSerializers::Model
|
||||
attributes :id, :description
|
||||
end
|
||||
end
|
||||
|
|
@ -35,7 +35,7 @@ module Trends
|
|||
return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
|
||||
|
||||
User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
|
||||
AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
|
||||
AdminMailer.with(recipient: user.account).new_trends(links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class Trends::History
|
|||
end
|
||||
|
||||
def uses
|
||||
with_redis { |redis| redis.mget(*@days.map { |day| day.key_for(:uses) }).map(&:to_i).sum }
|
||||
with_redis { |redis| redis.mget(*@days.map { |day| day.key_for(:uses) }).sum(&:to_i) }
|
||||
end
|
||||
|
||||
def accounts
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue