Merge tag 'v4.2.0-beta2'

This commit is contained in:
bgme 2023-08-22 03:51:20 +08:00
commit 9e38d55101
3010 changed files with 81215 additions and 55173 deletions

View file

@ -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

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_domain_blocks

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_moderation_notes

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_notes

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_pins

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_summaries

View file

@ -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

View file

@ -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

View file

@ -17,7 +17,7 @@
#
class Admin::ActionLog < ApplicationRecord
self.ignored_columns = %w(
self.ignored_columns += %w(
recorded_changes
)

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: bookmarks

54
app/models/bulk_import.rb Normal file
View 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

View 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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: canonical_email_blocks

View file

@ -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

View file

@ -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)

View file

@ -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

View 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

View file

@ -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)

View file

@ -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

View 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

View file

@ -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?

View file

@ -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

View file

@ -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?

View file

@ -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)

View 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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: conversations

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: conversation_mutes

View file

@ -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

View file

@ -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}")

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: custom_filter_keywords

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: custom_filter_statuses

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: devices

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: encrypted_messages

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: featured_tags

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: follows

View file

@ -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)) }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -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
View 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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: identities

View file

@ -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

View file

@ -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)

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: invites

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: ip_blocks

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: login_activities

View file

@ -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,

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: mentions

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: mutes

View file

@ -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)

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: one_time_keys

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: report_notes

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: status_pins

View file

@ -1,4 +1,5 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: status_stats

View file

@ -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

View file

@ -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
View 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

View file

@ -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

View file

@ -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