Merge tag 'v3.0.0' into hometown-dev

This commit is contained in:
Darius Kazemi
2019-10-08 13:24:20 -07:00
1012 changed files with 31176 additions and 15165 deletions

View File

@ -45,12 +45,12 @@
# also_known_as :string is an Array
# silenced_at :datetime
# suspended_at :datetime
# trust_level :integer
#
class Account < ApplicationRecord
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
MIN_FOLLOWERS_DISCOVERY = 10
include AccountAssociations
include AccountAvatar
@ -62,6 +62,11 @@ class Account < ApplicationRecord
include AccountCounters
include DomainNormalizable
TRUST_LEVELS = {
untrusted: 0,
trusted: 1,
}.freeze
enum protocol: [:ostatus, :activitypub]
validates :username, presence: true
@ -71,7 +76,7 @@ class Account < ApplicationRecord
validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? }
# Local user validations
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
@ -94,11 +99,13 @@ class Account < ApplicationRecord
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) }
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) }
scope :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 :popular, -> { order('account_stats.followers_count desc') }
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + 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))) }
delegate :email,
:unconfirmed_email,
@ -108,6 +115,7 @@ class Account < ApplicationRecord
:approved?,
:pending?,
:disabled?,
:unconfirmed_or_pending?,
:role,
:admin?,
:moderator?,
@ -121,6 +129,8 @@ class Account < ApplicationRecord
delegate :chosen_languages, to: :user, prefix: false, allow_nil: true
update_index('accounts#account', :self)
def local?
domain.nil?
end
@ -133,6 +143,10 @@ class Account < ApplicationRecord
%w(Application Service).include? actor_type
end
def instance_actor?
id == -99
end
alias bot bot?
def bot=(val)
@ -159,34 +173,39 @@ class Account < ApplicationRecord
subscription_expires_at.present?
end
def searchable?
!(suspended? || moved?)
end
def possibly_stale?
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
end
def trust_level
self[:trust_level] || 0
end
def refresh!
return if local?
ResolveAccountService.new.call(acct)
ResolveAccountService.new.call(acct) unless local?
end
def silenced?
silenced_at.present?
end
def silence!(date = nil)
date ||= Time.now.utc
def silence!(date = Time.now.utc)
update!(silenced_at: date)
end
def unsilence!
update!(silenced_at: nil)
update!(silenced_at: nil, trust_level: trust_level == TRUST_LEVELS[:untrusted] ? TRUST_LEVELS[:trusted] : trust_level)
end
def suspended?
suspended_at.present?
end
def suspend!(date = nil)
date ||= Time.now.utc
def suspend!(date = Time.now.utc)
transaction do
user&.disable! if local?
update!(suspended_at: date)
@ -216,17 +235,7 @@ class Account < ApplicationRecord
end
def tags_as_strings=(tag_names)
tag_names.map! { |name| name.mb_chars.downcase.to_s }
tag_names.uniq!
# Existing hashtags
hashtags_map = Tag.where(name: tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag }
# Initialize not yet existing hashtags
tag_names.each do |name|
next if hashtags_map.key?(name)
hashtags_map[name] = Tag.new(name: name)
end
hashtags_map = Tag.find_or_create_by_names(tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag }
# Remove hashtags that are to be deleted
tags.each do |tag|
@ -294,21 +303,6 @@ class Account < ApplicationRecord
self.fields = tmp
end
def magic_key
modulus, exponent = [keypair.public_key.n, keypair.public_key.e].map do |component|
result = []
until component.zero?
result << [component % 256].pack('C')
component >>= 8
end
result.reverse.join
end
(['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.')
end
def subscription(webhook_url)
@subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
end
@ -506,7 +500,7 @@ class Account < ApplicationRecord
end
def generate_keys
return unless local? && !Rails.env.test?
return unless local? && private_key.blank? && public_key.blank?
keypair = OpenSSL::PKey::RSA.new(2048)
self.private_key = keypair.to_pem
@ -520,7 +514,7 @@ class Account < ApplicationRecord
end
def emojifiable_text
[note, display_name, fields.map(&:value)].join(' ')
[note, display_name, fields.map(&:name), fields.map(&:value)].join(' ')
end
def clean_feed_manager

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_aliases
#
# id :bigint(8) not null, primary key
# account_id :bigint(8)
# acct :string default(""), not null
# uri :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class AccountAlias < ApplicationRecord
belongs_to :account
validates :acct, presence: true, domain: { acct: true }
validates :uri, presence: true
validates :uri, uniqueness: { scope: :account_id }
before_validation :set_uri
after_create :add_to_account
after_destroy :remove_from_account
def acct=(val)
val = val.to_s.strip
super(val.start_with?('@') ? val[1..-1] : val)
end
private
def set_uri
target_account = ResolveAccountService.new.call(acct)
self.uri = ActivityPub::TagManager.instance.uri_for(target_account) unless target_account.nil?
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
# Validation will take care of it
end
def add_to_account
account.update(also_known_as: account.also_known_as + [uri])
end
def remove_from_account
account.update(also_known_as: account.also_known_as.reject { |x| x == uri })
end
end

View File

@ -0,0 +1,78 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_migrations
#
# id :bigint(8) not null, primary key
# account_id :bigint(8)
# acct :string default(""), not null
# followers_count :bigint(8) default(0), not null
# target_account_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
#
class AccountMigration < ApplicationRecord
COOLDOWN_PERIOD = 30.days.freeze
belongs_to :account
belongs_to :target_account, class_name: 'Account'
before_validation :set_target_account
before_validation :set_followers_count
validates :acct, presence: true, domain: { acct: true }
validate :validate_migration_cooldown
validate :validate_target_account
scope :within_cooldown, ->(now = Time.now.utc) { where(arel_table[:created_at].gteq(now - COOLDOWN_PERIOD)) }
attr_accessor :current_password, :current_username
def save_with_challenge(current_user)
if current_user.encrypted_password.present?
errors.add(:current_password, :invalid) unless current_user.valid_password?(current_password)
else
errors.add(:current_username, :invalid) unless account.username == current_username
end
return false unless errors.empty?
save
end
def cooldown_at
created_at + COOLDOWN_PERIOD
end
def acct=(val)
super(val.to_s.strip.gsub(/\A@/, ''))
end
private
def set_target_account
self.target_account = ResolveAccountService.new.call(acct)
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
# Validation will take care of it
end
def set_followers_count
self.followers_count = account.followers_count
end
def validate_target_account
if target_account.nil?
errors.add(:acct, I18n.t('migrations.errors.not_found'))
else
errors.add(:acct, I18n.t('migrations.errors.missing_also_known_as')) unless target_account.also_known_as.include?(ActivityPub::TagManager.instance.uri_for(account))
errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved_to_account_id.present? && account.moved_to_account_id == target_account.id
errors.add(:acct, I18n.t('migrations.errors.move_to_self')) if account.id == target_account.id
end
end
def validate_migration_cooldown
errors.add(:base, I18n.t('migrations.errors.on_cooldown')) if account.migrations.within_cooldown.exists?
end
end

View File

@ -11,17 +11,36 @@
# created_at :datetime not null
# updated_at :datetime not null
# last_status_at :datetime
# lock_version :integer default(0), not null
#
class AccountStat < ApplicationRecord
belongs_to :account, inverse_of: :account_stat
update_index('accounts#account', :account)
def increment_count!(key)
update(attributes_for_increment(key))
rescue ActiveRecord::StaleObjectError
begin
reload_with_id
rescue ActiveRecord::RecordNotFound
# Nothing to do
else
retry
end
end
def decrement_count!(key)
update(key => [public_send(key) - 1, 0].max)
rescue ActiveRecord::StaleObjectError
begin
reload_with_id
rescue ActiveRecord::RecordNotFound
# Nothing to do
else
retry
end
end
private
@ -31,4 +50,9 @@ class AccountStat < ApplicationRecord
attrs[:last_status_at] = Time.now.utc if key == :statuses_count
attrs
end
def reload_with_id
self.id = find_by!(account: account).id if new_record?
reload
end
end

View File

@ -19,20 +19,25 @@ class Admin::AccountAction
:report_id,
:warning_preset_id
attr_reader :warning, :send_email_notification
attr_reader :warning, :send_email_notification, :include_statuses
def send_email_notification=(value)
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
end
def include_statuses=(value)
@include_statuses = ActiveModel::Type::Boolean.new.cast(value)
end
def save!
ApplicationRecord.transaction do
process_action!
process_warning!
end
queue_email!
process_email!
process_reports!
process_queue!
end
def report
@ -78,19 +83,23 @@ class Admin::AccountAction
# A log entry is only interesting if the warning contains
# custom text from someone. Otherwise it's just noise.
log_action(:create, warning) if warning.text.present?
end
def process_reports!
return if report_id.blank?
# If we're doing "mark as resolved" on a single report,
# then we want to keep other reports open in case they
# contain new actionable information.
#
# Otherwise, we will mark all unresolved reports about
# the account as resolved.
authorize(report, :update?)
reports.each { |report| authorize(report, :update?) }
if type == 'none'
reports.each do |report|
log_action(:resolve, report)
report.resolve!(current_account)
else
Report.where(target_account: target_account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
end
end
@ -110,7 +119,6 @@ class Admin::AccountAction
authorize(target_account, :suspend?)
log_action(:suspend, target_account)
target_account.suspend!
queue_suspension_worker!
end
def text_for_warning
@ -121,16 +129,32 @@ class Admin::AccountAction
Admin::SuspensionWorker.perform_async(target_account.id)
end
def queue_email!
return unless warnable?
def process_queue!
queue_suspension_worker! if type == 'suspend'
end
UserMailer.warning(target_account.user, warning).deliver_later!
def process_email!
UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable?
end
def warnable?
send_email_notification && target_account.local?
end
def status_ids
@report.status_ids if @report && include_statuses
end
def reports
@reports ||= begin
if type == 'none' && with_report?
[report]
else
Report.where(target_account: target_account).unresolved
end
end
end
def warning_preset
@warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
end

View File

@ -2,5 +2,22 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
include Remotable
class << self
def update_index(_type_name, *_args, &_block)
super if Chewy.enabled?
end
end
def boolean_with_default(key, default_value)
value = attributes[key]
if value.nil?
default_value
else
value
end
end
end

View File

@ -11,7 +11,6 @@ module AccountAssociations
has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account
# Timelines
has_many :stream_entries, inverse_of: :account, dependent: :destroy
has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account, dependent: :destroy
@ -31,9 +30,6 @@ module AccountAssociations
has_many :media_attachments, dependent: :destroy
has_many :polls, dependent: :destroy
# PuSH subscriptions
has_many :subscriptions, dependent: :destroy
# Report relationships
has_many :reports, dependent: :destroy, inverse_of: :account
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
@ -56,6 +52,8 @@ module AccountAssociations
# Account migrations
belongs_to :moved_to_account, class_name: 'Account', optional: true
has_many :migrations, class_name: 'AccountMigration', dependent: :destroy, inverse_of: :account
has_many :aliases, class_name: 'AccountAlias', dependent: :destroy, inverse_of: :account
# Hashtags
has_and_belongs_to_many :tags

View File

@ -3,7 +3,7 @@
module AccountAvatar
extend ActiveSupport::Concern
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
LIMIT = 2.megabytes
class_methods do

View File

@ -26,7 +26,8 @@ module AccountCounters
private
def save_account_stat
return unless account_stat&.changed?
return unless association(:account_stat).loaded? && account_stat&.changed?
account_stat.save
end
end

View File

@ -13,7 +13,7 @@ module AccountFinderConcern
end
def representative
find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) || Account.local.without_suspended.first
Account.find(-99)
end
def find_local(username)

View File

@ -3,7 +3,7 @@
module AccountHeader
extend ActiveSupport::Concern
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
LIMIT = 2.megabytes
MAX_PIXELS = 750_000 # 1500x500px

View File

@ -6,6 +6,7 @@ module Attachmentable
extend ActiveSupport::Concern
MAX_MATRIX_LIMIT = 16_777_216 # 4096x4096px or approx. 16MB
GIF_MATRIX_LIMIT = 921_600 # 1280x720px
included do
before_post_process :set_file_extensions
@ -42,8 +43,9 @@ module Attachmentable
next if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank?
width, height = FastImage.size(attachment.queued_for_write[:original].path)
matrix_limit = attachment.content_type == 'image/gif' ? GIF_MATRIX_LIMIT : MAX_MATRIX_LIMIT
raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height >= MAX_MATRIX_LIMIT)
raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height > matrix_limit)
end
end

View File

@ -3,24 +3,50 @@
module LdapAuthenticable
extend ActiveSupport::Concern
def ldap_setup(_attributes)
self.confirmed_at = Time.now.utc
self.admin = false
self.external = true
save!
end
class_methods do
def authenticate_with_ldap(params = {})
ldap = Net::LDAP.new(ldap_options)
filter = format(Devise.ldap_search_filter, uid: Devise.ldap_uid, email: params[:email])
if (user_info = ldap.bind_as(base: Devise.ldap_base, filter: filter, password: params[:password]))
ldap_get_user(user_info.first)
end
end
def ldap_get_user(attributes = {})
resource = joins(:account).find_by(accounts: { username: attributes[Devise.ldap_uid.to_sym].first })
if resource.blank?
resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: attributes[Devise.ldap_uid.to_sym].first })
resource.ldap_setup(attributes)
resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: attributes[Devise.ldap_uid.to_sym].first }, admin: false, external: true, confirmed_at: Time.now.utc)
resource.save!
end
resource
end
def ldap_options
opts = {
host: Devise.ldap_host,
port: Devise.ldap_port,
base: Devise.ldap_base,
auth: {
method: :simple,
username: Devise.ldap_bind_dn,
password: Devise.ldap_password,
},
connect_timeout: 10,
}
if [:simple_tls, :start_tls].include?(Devise.ldap_method)
opts[:encryption] = {
method: Devise.ldap_method,
tls_options: OpenSSL::SSL::SSLContext::DEFAULT_PARAMS.tap { |options| options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE if Devise.ldap_tls_no_verify },
}
end
opts
end
end
end

View File

@ -4,7 +4,7 @@ module Omniauthable
extend ActiveSupport::Concern
TEMP_EMAIL_PREFIX = 'change@me'
TEMP_EMAIL_REGEX = /\Achange@me/
TEMP_EMAIL_REGEX = /\A#{TEMP_EMAIL_PREFIX}/.freeze
included do
devise :omniauthable
@ -28,8 +28,8 @@ module Omniauthable
# to prevent the identity being locked with accidentally created accounts.
# Note that this may leave zombie accounts (with no associated identity) which
# can be cleaned up at a later date.
user = signed_in_resource || identity.user
user = create_for_oauth(auth) if user.nil?
user = signed_in_resource || identity.user
user ||= create_for_oauth(auth)
if identity.user.nil?
identity.user = user
@ -43,9 +43,20 @@ module Omniauthable
# Check if the user exists with provided email if the provider gives us a
# verified email. If no verified email was provided or the user already
# exists, we assign a temporary email and ask the user to verify it on
# the next step via Auth::ConfirmationsController.finish_signup
# the next step via Auth::SetupController.show
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
assume_verified = strategy&.security&.assume_email_is_verified
email_is_verified = auth.info.verified || auth.info.verified_email || assume_verified
email = auth.info.verified_email || auth.info.email
email = nil unless email_is_verified
user = User.find_by(email: email) if email_is_verified
return user unless user.nil?
user = User.new(user_params_from_auth(email, auth))
user = User.new(user_params_from_auth(auth))
user.account.avatar_remote_url = auth.info.image if auth.info.image =~ /\A#{URI.regexp(%w(http https))}\z/
user.skip_confirmation!
user.save!
@ -54,14 +65,7 @@ module Omniauthable
private
def user_params_from_auth(auth)
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
assume_verified = strategy.try(:security).try(:assume_email_is_verified)
email_is_verified = auth.info.verified || auth.info.verified_email || assume_verified
email = auth.info.verified_email || auth.info.email
email = email_is_verified && !User.exists?(email: auth.info.email) && email
display_name = auth.info.full_name || [auth.info.first_name, auth.info.last_name].join(' ')
def user_params_from_auth(email, auth)
{
email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
password: Devise.friendly_token[0, 20],
@ -69,7 +73,7 @@ module Omniauthable
external: true,
account_attributes: {
username: ensure_unique_username(auth.uid),
display_name: display_name,
display_name: auth.info.full_name || [auth.info.first_name, auth.info.last_name].join(' '),
},
}
end

View File

@ -4,7 +4,7 @@ module Remotable
extend ActiveSupport::Concern
class_methods do
def remotable_attachment(attachment_name, limit)
def remotable_attachment(attachment_name, limit, suppress_errors: true)
attribute_name = "#{attachment_name}_remote_url".to_sym
method_name = "#{attribute_name}=".to_sym
alt_method_name = "reset_#{attachment_name}!".to_sym
@ -22,7 +22,7 @@ module Remotable
begin
Request.new(:get, url).perform do |response|
next if response.code != 200
raise Mastodon::UnexpectedResponseError, response unless (200...300).cover?(response.code)
content_type = parse_content_type(response.headers.get('content-type').last)
extname = detect_extname_from_content_type(content_type)
@ -41,11 +41,11 @@ module Remotable
self[attribute_name] = url if has_attribute?(attribute_name)
end
rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
raise e unless suppress_errors
rescue Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, Paperclip::Error, Mastodon::DimensionsValidationError => e
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
nil
rescue Paperclip::Error, Mastodon::DimensionsValidationError => e
Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
nil
end
end

View File

@ -1,43 +0,0 @@
# frozen_string_literal: true
module Streamable
extend ActiveSupport::Concern
included do
has_one :stream_entry, as: :activity
after_create do
account.stream_entries.create!(activity: self, hidden: hidden?) if needs_stream_entry?
end
end
def title
super
end
def content
title
end
def target
super
end
def object_type
:activity
end
def thread
super
end
def hidden?
false
end
private
def needs_stream_entry?
account.local?
end
end

View File

@ -16,6 +16,7 @@
# uri :string
# image_remote_url :string
# visible_in_picker :boolean default(TRUE), not null
# category_id :bigint(8)
#
class CustomEmoji < ApplicationRecord
@ -27,8 +28,9 @@ class CustomEmoji < ApplicationRecord
:(#{SHORTCODE_RE_FRAGMENT}):
(?=[^[:alnum:]:]|$)/x
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
IMAGE_MIME_TYPES = %w(image/png image/gif).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_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
@ -38,10 +40,11 @@ class CustomEmoji < ApplicationRecord
validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
scope :local, -> { where(domain: nil) }
scope :remote, -> { where.not(domain: nil) }
scope :local, -> { where(domain: nil) }
scope :remote, -> { where.not(domain: nil) }
scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) }
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
scope :listed, -> { local.where(disabled: false).where(visible_in_picker: true) }
remotable_attachment :image, LIMIT
@ -57,6 +60,12 @@ class CustomEmoji < ApplicationRecord
:emoji
end
def copy!
copy = self.class.find_or_initialize_by(domain: nil, shortcode: shortcode)
copy.image = image
copy.tap(&:save!)
end
class << self
def from_text(text, domain)
return [] if text.blank?

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: custom_emoji_categories
#
# id :bigint(8) not null, primary key
# name :string
# created_at :datetime not null
# updated_at :datetime not null
#
class CustomEmojiCategory < ApplicationRecord
has_many :emojis, class_name: 'CustomEmoji', foreign_key: 'category_id', inverse_of: :category
validates :name, presence: true, uniqueness: true
end

View File

@ -11,6 +11,8 @@ class CustomEmojiFilter
scope = CustomEmoji.alphabetic
params.each do |key, value|
next if key.to_s == 'page'
scope.merge!(scope_for(key, value)) if value.present?
end
@ -22,13 +24,13 @@ class CustomEmojiFilter
def scope_for(key, value)
case key.to_s
when 'local'
CustomEmoji.local
CustomEmoji.local.left_joins(:category).reorder(Arel.sql('custom_emoji_categories.name ASC NULLS FIRST, custom_emojis.shortcode ASC'))
when 'remote'
CustomEmoji.remote
when 'by_domain'
CustomEmoji.where(domain: value.downcase)
CustomEmoji.where(domain: value.strip.downcase)
when 'shortcode'
CustomEmoji.search(value)
CustomEmoji.search(value.strip)
else
raise "Unknown filter: #{key}"
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: domain_allows
#
# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class DomainAllow < ApplicationRecord
include DomainNormalizable
validates :domain, presence: true, uniqueness: true, domain: true
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
class << self
def allowed?(domain)
!rule_for(domain).nil?
end
def rule_for(domain)
return if domain.blank?
uri = Addressable::URI.new.tap { |u| u.host = domain.gsub(/[\/]/, '') }
find_by(domain: uri.normalized_host)
end
end
end

View File

@ -3,13 +3,15 @@
#
# Table name: domain_blocks
#
# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# severity :integer default("silence")
# reject_media :boolean default(FALSE), not null
# reject_reports :boolean default(FALSE), not null
# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# severity :integer default("silence")
# reject_media :boolean default(FALSE), not null
# reject_reports :boolean default(FALSE), not null
# private_comment :text
# public_comment :text
#
class DomainBlock < ApplicationRecord
@ -23,6 +25,8 @@ class DomainBlock < ApplicationRecord
delegate :count, to: :accounts, prefix: true
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) }
scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain')) }
class << self
def suspend?(domain)

View File

@ -13,7 +13,7 @@
class Favourite < ApplicationRecord
include Paginable
update_index('statuses#status', :status) if Chewy.enabled?
update_index('statuses#status', :status)
belongs_to :account, inverse_of: :favourites
belongs_to :status, inverse_of: :favourites

View File

@ -23,7 +23,7 @@ class FeaturedTag < ApplicationRecord
validate :validate_featured_tags_limit, on: :create
def name=(str)
self.tag = Tag.find_or_initialize_by(name: str.strip.delete('#').mb_chars.downcase.to_s)
self.tag = Tag.find_or_create_by_names(str.strip)&.first
end
def increment(timestamp)

View File

@ -9,6 +9,11 @@ class Feed
end
def get(limit, max_id = nil, since_id = nil, min_id = nil)
limit = limit.to_i
max_id = max_id.to_i if max_id.present?
since_id = since_id.to_i if since_id.present?
min_id = min_id.to_i if min_id.present?
from_redis(limit, max_id, since_id, min_id)
end

View File

@ -69,6 +69,6 @@ class Form::AccountBatch
records = accounts.includes(:user)
records.each { |account| authorize(account.user, :reject?) }
.each { |account| SuspendAccountService.new.call(account, including_user: true, destroy: true, skip_distribution: true) }
.each { |account| SuspendAccountService.new.call(account, reserve_email: false, reserve_username: false) }
end
end

View File

@ -28,6 +28,11 @@ class Form::AdminSettings
thumbnail
hero
mascot
spam_check_enabled
trends
show_domain_blocks
show_domain_blocks_rationale
noindex
).freeze
BOOLEAN_KEYS = %i(
@ -39,6 +44,9 @@ class Form::AdminSettings
show_known_fediverse_at_about_page
preview_sensitive_media
profile_directory
spam_check_enabled
trends
noindex
).freeze
UPLOAD_KEYS = %i(
@ -56,6 +64,8 @@ class Form::AdminSettings
validates :site_contact_email, :site_contact_username, presence: true
validates :site_contact_username, existing_username: true
validates :bootstrap_timeline_accounts, existing_username: { multiple: true }
validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }
validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }
def initialize(_attributes = {})
super

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Form::Challenge
include ActiveModel::Model
attr_accessor :current_password, :current_username,
:return_to
end

View File

@ -0,0 +1,106 @@
# frozen_string_literal: true
class Form::CustomEmojiBatch
include ActiveModel::Model
include Authorization
include AccountableConcern
attr_accessor :custom_emoji_ids, :action, :current_account,
:category_id, :category_name, :visible_in_picker
def save
case action
when 'update'
update!
when 'list'
list!
when 'unlist'
unlist!
when 'enable'
enable!
when 'disable'
disable!
when 'copy'
copy!
when 'delete'
delete!
end
end
private
def custom_emojis
CustomEmoji.where(id: custom_emoji_ids)
end
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.create!(name: category_name)
end
end
custom_emojis.each do |custom_emoji|
custom_emoji.update(category_id: category&.id)
log_action :update, custom_emoji
end
end
def list!
custom_emojis.each { |custom_emoji| authorize(custom_emoji, :update?) }
custom_emojis.each do |custom_emoji|
custom_emoji.update(visible_in_picker: true)
log_action :update, custom_emoji
end
end
def unlist!
custom_emojis.each { |custom_emoji| authorize(custom_emoji, :update?) }
custom_emojis.each do |custom_emoji|
custom_emoji.update(visible_in_picker: false)
log_action :update, custom_emoji
end
end
def enable!
custom_emojis.each { |custom_emoji| authorize(custom_emoji, :enable?) }
custom_emojis.each do |custom_emoji|
custom_emoji.update(disabled: false)
log_action :enable, custom_emoji
end
end
def disable!
custom_emojis.each { |custom_emoji| authorize(custom_emoji, :disable?) }
custom_emojis.each do |custom_emoji|
custom_emoji.update(disabled: true)
log_action :disable, custom_emoji
end
end
def copy!
custom_emojis.each { |custom_emoji| authorize(custom_emoji, :copy?) }
custom_emojis.each do |custom_emoji|
copied_custom_emoji = custom_emoji.copy!
log_action :create, copied_custom_emoji
end
end
def delete!
custom_emojis.each { |custom_emoji| authorize(custom_emoji, :destroy?) }
custom_emojis.each do |custom_emoji|
custom_emoji.destroy
log_action :destroy, custom_emoji
end
end
end

View File

@ -3,5 +3,5 @@
class Form::DeleteConfirmation
include ActiveModel::Model
attr_accessor :password
attr_accessor :password, :username
end

View File

@ -1,25 +0,0 @@
# frozen_string_literal: true
class Form::Migration
include ActiveModel::Validations
attr_accessor :acct, :account
def initialize(attrs = {})
@account = attrs[:account]
@acct = attrs[:account].acct unless @account.nil?
@acct = attrs[:acct].gsub(/\A@/, '').strip unless attrs[:acct].nil?
end
def valid?
return false unless super
set_account
errors.empty?
end
private
def set_account
self.account = (ResolveAccountService.new.call(acct) if account.nil? && acct.present?)
end
end

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
class Form::Redirect
include ActiveModel::Model
attr_accessor :account, :target_account, :current_password,
:current_username
attr_reader :acct
validates :acct, presence: true, domain: { acct: true }
validate :validate_target_account
def valid_with_challenge?(current_user)
if current_user.encrypted_password.present?
errors.add(:current_password, :invalid) unless current_user.valid_password?(current_password)
else
errors.add(:current_username, :invalid) unless account.username == current_username
end
return false unless errors.empty?
set_target_account
valid?
end
def acct=(val)
@acct = val.to_s.strip.gsub(/\A@/, '')
end
private
def set_target_account
@target_account = ResolveAccountService.new.call(acct)
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
# Validation will take care of it
end
def validate_target_account
if target_account.nil?
errors.add(:acct, I18n.t('migrations.errors.not_found'))
else
errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved_to_account_id.present? && account.moved_to_account_id == target_account.id
errors.add(:acct, I18n.t('migrations.errors.move_to_self')) if account.id == target_account.id
end
end
end

View File

@ -34,7 +34,8 @@ class Form::StatusBatch
def delete_statuses
Status.where(id: status_ids).reorder(nil).find_each do |status|
RemovalWorker.perform_async(status.id)
status.discard
RemovalWorker.perform_async(status.id, immediate: true)
Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
log_action :destroy, status
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Form::TagBatch
include ActiveModel::Model
include Authorization
attr_accessor :tag_ids, :action, :current_account
def save
case action
when 'approve'
approve!
when 'reject'
reject!
end
end
private
def tags
Tag.where(id: tag_ids)
end
def approve!
tags.each { |tag| authorize(tag, :update?) }
tags.update_all(trendable: true, reviewed_at: Time.now.utc)
end
def reject!
tags.each { |tag| authorize(tag, :update?) }
tags.update_all(trendable: false, reviewed_at: Time.now.utc)
end
end

View File

@ -3,5 +3,5 @@
class Form::TwoFactorConfirmation
include ActiveModel::Model
attr_accessor :code
attr_accessor :otp_attempt
end

View File

@ -7,8 +7,9 @@ class Instance
def initialize(resource)
@domain = resource.domain
@accounts_count = resource.is_a?(DomainBlock) ? nil : resource.accounts_count
@accounts_count = resource.respond_to?(:accounts_count) ? resource.accounts_count : nil
@domain_block = resource.is_a?(DomainBlock) ? resource : DomainBlock.rule_for(domain)
@domain_allow = resource.is_a?(DomainAllow) ? resource : DomainAllow.rule_for(domain)
end
def countable?

View File

@ -12,6 +12,10 @@ class InstanceFilter
scope = DomainBlock
scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
scope.order(id: :desc)
elsif params[:allowed].present?
scope = DomainAllow
scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?
scope.order(id: :desc)
else
scope = Account.remote
scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present?

View File

@ -12,6 +12,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# autofollow :boolean default(FALSE), not null
# comment :text
#
class Invite < ApplicationRecord
@ -22,6 +23,8 @@ class Invite < ApplicationRecord
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
validates :comment, length: { maximum: 420 }
before_validation :set_code
def valid_for_use?

23
app/models/marker.rb Normal file
View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: markers
#
# id :bigint(8) not null, primary key
# user_id :bigint(8)
# timeline :string default(""), not null
# last_read_id :bigint(8) default(0), not null
# lock_version :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Marker < ApplicationRecord
TIMELINES = %w(home notifications).freeze
belongs_to :user
validates :timeline, :last_read_id, presence: true
validates :timeline, inclusion: { in: TIMELINES }
end

View File

@ -26,14 +26,14 @@ class MediaAttachment < ApplicationRecord
enum type: [:image, :gifv, :video, :unknown, :audio]
IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].freeze
VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v', '.mov'].freeze
AUDIO_FILE_EXTENSIONS = ['.ogg', '.oga', '.mp3', '.wav', '.flac', '.opus'].freeze
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime', 'video/ogg'].freeze
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
AUDIO_MIME_TYPES = ['audio/wave', 'audio/wav', 'audio/x-wav', 'audio/x-pn-wave', 'audio/ogg', 'audio/mpeg', 'audio/mp3', 'audio/webm', 'audio/flac'].freeze
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).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/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
BLURHASH_OPTIONS = {
x_comp: 4,
@ -65,6 +65,17 @@ class MediaAttachment < ApplicationRecord
file_geometry_parser: FastGeometryParser,
blurhash: BLURHASH_OPTIONS,
},
original: {
keep_same_format: true,
convert_options: {
output: {
'map_metadata' => '-1',
'c:v' => 'copy',
'c:a' => 'copy',
},
},
},
}.freeze
AUDIO_STYLES = {
@ -86,14 +97,15 @@ class MediaAttachment < ApplicationRecord
output: {
'loglevel' => 'fatal',
'movflags' => 'faststart',
'pix_fmt' => 'yuv420p',
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
'vsync' => 'cfr',
'c:v' => 'h264',
'b:v' => '500K',
'maxrate' => '1300K',
'bufsize' => '1300K',
'crf' => 18,
'pix_fmt' => 'yuv420p',
'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
'vsync' => 'cfr',
'c:v' => 'h264',
'maxrate' => '1300K',
'bufsize' => '1300K',
'frames:v' => 60 * 60 * 3,
'crf' => 18,
'map_metadata' => '-1',
},
},
}.freeze
@ -103,7 +115,7 @@ class MediaAttachment < ApplicationRecord
original: VIDEO_FORMAT,
}.freeze
IMAGE_LIMIT = 8.megabytes
IMAGE_LIMIT = 10.megabytes
VIDEO_LIMIT = 40.megabytes
belongs_to :account, inverse_of: :media_attachments, optional: true
@ -118,17 +130,18 @@ class MediaAttachment < ApplicationRecord
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :larger_media_format?
remotable_attachment :file, VIDEO_LIMIT
remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false
include Attachmentable
validates :account, presence: true
validates :description, length: { maximum: 420 }, if: :local?
validates :description, length: { maximum: 1_500 }, if: :local?
scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
scope :local, -> { where(remote_url: '') }
scope :remote, -> { where.not(remote_url: '') }
scope :cached, -> { remote.where.not(file_file_name: nil) }
default_scope { order(id: :asc) }
@ -243,7 +256,9 @@ class MediaAttachment < ApplicationRecord
def set_meta
meta = populate_meta
return if meta == {}
file.instance_write :meta, meta
end
@ -286,6 +301,7 @@ class MediaAttachment < ApplicationRecord
def reset_parent_cache
return if status_id.nil?
Rails.cache.delete("statuses/#{status_id}")
end
end

View File

@ -16,6 +16,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# lock_version :integer default(0), not null
# voters_count :bigint(8)
#
class Poll < ApplicationRecord
@ -54,6 +55,10 @@ class Poll < ApplicationRecord
account.id == account_id || votes.where(account: account).exists?
end
def own_votes(account)
votes.where(account: account).pluck(:choice)
end
delegate :local?, to: :account
def remote?

View File

@ -25,7 +25,7 @@
#
class PreviewCard < ApplicationRecord
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
LIMIT = 1.megabytes
self.inheritance_column = false
@ -43,8 +43,14 @@ class PreviewCard < ApplicationRecord
validates_attachment_size :image, less_than: LIMIT
remotable_attachment :image, LIMIT
scope :cached, -> { where.not(image_file_name: [nil, '']) }
before_save :extract_dimensions, if: :link?
def missing_image?
width.present? && height.present? && image_file_name.blank?
end
def save_with_optional_image!
save!
rescue ActiveRecord::RecordInvalid

View File

@ -12,8 +12,6 @@
#
class Relay < ApplicationRecord
PRESET_RELAY = 'https://relay.joinmastodon.org/inbox'
validates :inbox_url, presence: true, uniqueness: true, url: true, if: :will_save_change_to_inbox_url?
enum state: [:idle, :pending, :accepted, :rejected]
@ -74,7 +72,6 @@ class Relay < ApplicationRecord
end
def ensure_disabled
return unless enabled?
disable!
disable! if enabled?
end
end

View File

@ -2,24 +2,26 @@
class RemoteFollow
include ActiveModel::Validations
include RoutingHelper
attr_accessor :acct, :addressable_template
validates :acct, presence: true
validates :acct, presence: true, domain: { acct: true }
def initialize(attrs = nil)
@acct = attrs[:acct].gsub(/\A@/, '').strip if !attrs.nil? && !attrs[:acct].nil?
def initialize(attrs = {})
@acct = normalize_acct(attrs[:acct])
end
def valid?
return false unless super
populate_template
fetch_template!
errors.empty?
end
def subscribe_address_for(account)
addressable_template.expand(uri: account.local_username_and_domain).to_s
addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(account)).to_s
end
def interact_address_for(status)
@ -28,8 +30,32 @@ class RemoteFollow
private
def populate_template
if acct.blank? || redirect_url_link.nil? || redirect_url_link.template.nil?
def normalize_acct(value)
return if value.blank?
username, domain = value.strip.gsub(/\A@/, '').split('@')
domain = begin
if TagManager.instance.local_domain?(domain)
nil
else
TagManager.instance.normalize_domain(domain)
end
end
[username, domain].compact.join('@')
rescue Addressable::URI::InvalidURIError
value
end
def fetch_template!
return missing_resource_error if acct.blank?
_, domain = acct.split('@')
if domain.nil?
@addressable_template = Addressable::Template.new("#{authorize_interaction_url}?uri={uri}")
elsif redirect_url_link.nil? || redirect_url_link.template.nil?
missing_resource_error
else
@addressable_template = Addressable::Template.new(redirect_uri_template)
@ -45,7 +71,7 @@ class RemoteFollow
end
def acct_resource
@_acct_resource ||= Goldfinger.finger("acct:#{acct}")
@acct_resource ||= Goldfinger.finger("acct:#{acct}")
rescue Goldfinger::Error, HTTP::ConnectionError
nil
end

View File

@ -1,57 +0,0 @@
# frozen_string_literal: true
class RemoteProfile
include ActiveModel::Model
attr_reader :document
def initialize(body)
@document = Nokogiri::XML.parse(body, nil, 'utf-8')
end
def root
@root ||= document.at_xpath('/atom:feed|/atom:entry', atom: OStatus::TagManager::XMLNS)
end
def author
@author ||= root.at_xpath('./atom:author|./dfrn:owner', atom: OStatus::TagManager::XMLNS, dfrn: OStatus::TagManager::DFRN_XMLNS)
end
def hub_link
@hub_link ||= link_href_from_xml(root, 'hub')
end
def display_name
@display_name ||= author.at_xpath('./poco:displayName', poco: OStatus::TagManager::POCO_XMLNS)&.content
end
def note
@note ||= author.at_xpath('./atom:summary|./poco:note', atom: OStatus::TagManager::XMLNS, poco: OStatus::TagManager::POCO_XMLNS)&.content
end
def scope
@scope ||= author.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content
end
def avatar
@avatar ||= link_href_from_xml(author, 'avatar')
end
def header
@header ||= link_href_from_xml(author, 'header')
end
def emojis
@emojis ||= author.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS)
end
def locked?
scope == 'private'
end
private
def link_href_from_xml(xml, type)
xml.at_xpath(%(./atom:link[@rel="#{type}"]/@href), atom: OStatus::TagManager::XMLNS)&.content
end
end

View File

@ -43,7 +43,7 @@ class Report < ApplicationRecord
end
def statuses
Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions)
end
def media_attachments
@ -59,6 +59,7 @@ class Report < ApplicationRecord
end
def resolve!(acting_account)
RemovalWorker.push_bulk(Status.with_discarded.discarded.where(id: status_ids).pluck(:id)) { |status_id| [status_id, { immediate: true }] }
update!(action_taken: true, action_taken_by_account_id: acting_account.id)
end

View File

@ -24,21 +24,24 @@
# poll_id :bigint(8)
# local_only :boolean
# activity_pub_type :string
# deleted_at :datetime
#
class Status < ApplicationRecord
before_destroy :unlink_from_conversations
include Discard::Model
include Paginable
include Streamable
include Cacheable
include StatusThreadingConcern
self.discard_column = :deleted_at
# If `override_timestamps` is set at creation time, Snowflake ID creation
# will be based on current time instead of `created_at`
attr_accessor :override_timestamps
update_index('statuses#status', :proper) if Chewy.enabled?
update_index('statuses#status', :proper)
enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
@ -63,7 +66,6 @@ class Status < ApplicationRecord
has_and_belongs_to_many :preview_cards
has_one :notification, as: :activity, dependent: :destroy
has_one :stream_entry, as: :activity, inverse_of: :status
has_one :status_stat, inverse_of: :status
has_one :poll, inverse_of: :status, dependent: :destroy
@ -76,7 +78,7 @@ class Status < ApplicationRecord
accepts_nested_attributes_for :poll
default_scope { recent }
default_scope { recent.kept }
scope :recent, -> { reorder(id: :desc) }
scope :remote, -> { where(local: false).where.not(uri: nil) }
@ -109,13 +111,11 @@ class Status < ApplicationRecord
:status_stat,
:tags,
:preview_cards,
:stream_entry,
:preloadable_poll,
account: :account_stat,
active_mentions: { account: :account_stat },
reblog: [
:application,
:stream_entry,
:tags,
:preview_cards,
:media_attachments,
@ -132,12 +132,14 @@ class Status < ApplicationRecord
REAL_TIME_WINDOW = 6.hours
def searchable_by(preloaded = nil)
ids = [account_id]
ids = []
ids << account_id if local?
if preloaded.nil?
ids += mentions.pluck(:account_id)
ids += favourites.pluck(:account_id)
ids += reblogs.pluck(:account_id)
ids += mentions.where(account: Account.local).pluck(:account_id)
ids += favourites.where(account: Account.local).pluck(:account_id)
ids += reblogs.where(account: Account.local).pluck(:account_id)
else
ids += preloaded.mentions[id] || []
ids += preloaded.favourites[id] || []
@ -204,7 +206,7 @@ class Status < ApplicationRecord
end
def hidden?
private_visibility? || direct_visibility? || limited_visibility?
!distributable?
end
def distributable?
@ -221,6 +223,10 @@ class Status < ApplicationRecord
!sensitive? && with_media?
end
def reported?
@reported ||= Report.where(target_account: account).unresolved.where('? = ANY(status_ids)', id).exists?
end
def emojis
return @emojis if defined?(@emojis)
@ -289,47 +295,6 @@ class Status < ApplicationRecord
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
end
def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
# direct timeline is mix of direct message from_me and to_me.
# 2 queries are executed with pagination.
# constant expression using arel_table is required for partial index
# _from_me part does not require any timeline filters
query_from_me = where(account_id: account.id)
.where(Status.arel_table[:visibility].eq(3))
.limit(limit)
.order('statuses.id DESC')
# _to_me part requires mute and block filter.
# FIXME: may we check mutes.hide_notifications?
query_to_me = Status
.joins(:mentions)
.merge(Mention.where(account_id: account.id))
.where(Status.arel_table[:visibility].eq(3))
.limit(limit)
.order('mentions.status_id DESC')
.not_excluded_by_account(account)
if max_id.present?
query_from_me = query_from_me.where('statuses.id < ?', max_id)
query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
end
if since_id.present?
query_from_me = query_from_me.where('statuses.id > ?', since_id)
query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
end
if cache_ids
# returns array of cache_ids object that have id and updated_at
(query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
else
# returns ActiveRecord.Relation
items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
Status.where(id: items.map(&:id))
end
end
def as_public_timeline(account = nil, local_only = false)
query = timeline_scope(local_only).without_replies
@ -351,7 +316,7 @@ class Status < ApplicationRecord
end
def reblogs_map(status_ids, account_id)
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).each_with_object({}) { |s, h| h[s.reblog_of_id] = true }
unscoped.select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).each_with_object({}) { |s, h| h[s.reblog_of_id] = true }
end
def mutes_map(conversation_ids, account_id)
@ -442,13 +407,16 @@ class Status < ApplicationRecord
end
end
def status_stat
super || build_status_stat
end
private
def update_status_stat!(attrs)
return if marked_for_destruction? || destroyed?
record = status_stat || build_status_stat
record.update(attrs)
status_stat.update(attrs)
end
def store_uri
@ -504,7 +472,8 @@ class Status < ApplicationRecord
end
def update_statistics
return unless public_visibility? || unlisted_visibility?
return unless distributable?
ActivityTracker.increment('activity:statuses:local')
end
@ -513,7 +482,7 @@ class Status < ApplicationRecord
account&.increment_count!(:statuses_count)
reblog&.increment_count!(:reblogs_count) if reblog?
thread&.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
thread&.increment_count!(:replies_count) if in_reply_to_id.present? && distributable?
end
def decrement_counter_caches
@ -521,7 +490,7 @@ class Status < ApplicationRecord
account&.decrement_count!(:statuses_count)
reblog&.decrement_count!(:reblogs_count) if reblog?
thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && distributable?
end
def unlink_from_conversations

View File

@ -1,60 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: stream_entries
#
# id :bigint(8) not null, primary key
# activity_id :bigint(8)
# activity_type :string
# created_at :datetime not null
# updated_at :datetime not null
# hidden :boolean default(FALSE), not null
# account_id :bigint(8)
#
class StreamEntry < ApplicationRecord
include Paginable
belongs_to :account, inverse_of: :stream_entries
belongs_to :activity, polymorphic: true
belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', inverse_of: :stream_entry
validates :account, :activity, presence: true
STATUS_INCLUDES = [:account, :stream_entry, :conversation, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :conversation, :media_attachments, :tags, mentions: :account], thread: [:stream_entry, :account]].freeze
default_scope { where(activity_type: 'Status') }
scope :recent, -> { reorder(id: :desc) }
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
scope :without_local_only, -> { where(statuses: { local_only: [false, nil] }) }
delegate :target, :title, :content, :thread, :local_only?,
to: :status,
allow_nil: true
def object_type
orphaned? || targeted? ? :activity : status.object_type
end
def verb
orphaned? ? :delete : status.verb
end
def targeted?
[:follow, :request_friend, :authorize, :reject, :unfollow, :block, :unblock, :share, :favorite].include? verb
end
def threaded?
(verb == :favorite || object_type == :comment) && !thread.nil?
end
def mentions
orphaned? ? [] : status.active_mentions.map(&:account)
end
private
def orphaned?
status.nil?
end
end

View File

@ -1,62 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: subscriptions
#
# id :bigint(8) not null, primary key
# callback_url :string default(""), not null
# secret :string
# expires_at :datetime
# confirmed :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# last_successful_delivery_at :datetime
# domain :string
# account_id :bigint(8) not null
#
class Subscription < ApplicationRecord
MIN_EXPIRATION = 1.day.to_i
MAX_EXPIRATION = 30.days.to_i
belongs_to :account
validates :callback_url, presence: true
validates :callback_url, uniqueness: { scope: :account_id }
scope :confirmed, -> { where(confirmed: true) }
scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) }
scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) }
scope :active, -> { confirmed.future_expiration }
def lease_seconds=(value)
self.expires_at = future_expiration(value)
end
def lease_seconds
(expires_at - Time.now.utc).to_i
end
def expired?
Time.now.utc > expires_at
end
before_validation :set_min_expiration
private
def future_expiration(value)
Time.now.utc + future_offset(value).seconds
end
def future_offset(seconds)
[
[MIN_EXPIRATION, seconds.to_i].max,
MAX_EXPIRATION,
].min
end
def set_min_expiration
self.lease_seconds = 0 unless expires_at
end
end

View File

@ -3,38 +3,54 @@
#
# Table name: tags
#
# id :bigint(8) not null, primary key
# name :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint(8) not null, primary key
# name :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# usable :boolean
# trendable :boolean
# listable :boolean
# reviewed_at :datetime
# requested_review_at :datetime
# last_status_at :datetime
# max_score :float
# max_score_at :datetime
#
class Tag < ApplicationRecord
has_and_belongs_to_many :statuses
has_and_belongs_to_many :accounts
has_and_belongs_to_many :sample_accounts, -> { searchable.discoverable.popular.limit(3) }, class_name: 'Account'
has_and_belongs_to_many :sample_accounts, -> { local.discoverable.popular.limit(3) }, class_name: 'Account'
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
has_one :account_tag_stat, dependent: :destroy
HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)'
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
HASHTAG_SEPARATORS = "_\u00B7\u200c"
HASHTAG_NAME_RE = "([[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}][[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)"
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
validates :name, presence: true, uniqueness: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
validate :validate_name_change, if: -> { !new_record? && name_changed? }
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
scope :reviewed, -> { where.not(reviewed_at: nil) }
scope :unreviewed, -> { where(reviewed_at: nil) }
scope :pending_review, -> { unreviewed.where.not(requested_review_at: nil) }
scope :usable, -> { where(usable: [true, nil]) }
scope :listable, -> { where(listable: [true, nil]) }
scope :discoverable, -> { listable.joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) }
scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
scope :matches_name, ->(value) { where(arel_table[:name].matches("#{value}%")) }
delegate :accounts_count,
:accounts_count=,
:increment_count!,
:decrement_count!,
:hidden?,
to: :account_tag_stat
after_save :save_account_tag_stat
update_index('tags#tag', :self)
def account_tag_stat
super || build_account_tag_stat
end
@ -47,6 +63,40 @@ class Tag < ApplicationRecord
name
end
def usable
boolean_with_default('usable', true)
end
alias usable? usable
def listable
boolean_with_default('listable', true)
end
alias listable? listable
def trendable
boolean_with_default('trendable', false)
end
alias trendable? trendable
def requires_review?
reviewed_at.nil?
end
def reviewed?
reviewed_at.present?
end
def requested_review?
requested_review_at.present?
end
def trending?
TrendingTags.trending?(self)
end
def history
days = []
@ -64,22 +114,50 @@ class Tag < ApplicationRecord
end
class << self
def search_for(term, limit = 5, offset = 0)
pattern = sanitize_sql_like(term.strip) + '%'
def find_or_create_by_names(name_or_names)
Array(name_or_names).map(&method(:normalize)).uniq { |str| str.mb_chars.downcase.to_s }.map do |normalized_name|
tag = matching_name(normalized_name).first || create!(name: normalized_name)
Tag.where('lower(name) like lower(?)', pattern)
.order(:name)
.limit(limit)
.offset(offset)
yield tag if block_given?
tag
end
end
def search_for(term, limit = 5, offset = 0, options = {})
normalized_term = normalize(term.strip).mb_chars.downcase.to_s
pattern = sanitize_sql_like(normalized_term) + '%'
query = Tag.listable.where(arel_table[:name].lower.matches(pattern))
query = query.where(arel_table[:name].lower.eq(normalized_term).or(arel_table[:reviewed_at].not_eq(nil))) if options[:exclude_unreviewed]
query.order(Arel.sql('length(name) ASC, name ASC'))
.limit(limit)
.offset(offset)
end
def find_normalized(name)
find_by(name: name.mb_chars.downcase.to_s)
matching_name(name).first
end
def find_normalized!(name)
find_normalized(name) || raise(ActiveRecord::RecordNotFound)
end
def matching_name(name_or_names)
names = Array(name_or_names).map { |name| normalize(name).mb_chars.downcase.to_s }
if names.size == 1
where(arel_table[:name].lower.eq(names.first))
else
where(arel_table[:name].lower.in(names))
end
end
private
def normalize(str)
str.gsub(/\A#/, '')
end
end
private
@ -88,4 +166,8 @@ class Tag < ApplicationRecord
return unless account_tag_stat&.changed?
account_tag_stat.save
end
def validate_name_change
errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.mb_chars.casecmp(name.mb_chars).zero?
end
end

44
app/models/tag_filter.rb Normal file
View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
class TagFilter
attr_reader :params
def initialize(params)
@params = params
end
def results
scope = Tag.unscoped
params.each do |key, value|
next if key.to_s == 'page'
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
end
scope.order(id: :desc)
end
private
def scope_for(key, value)
case key.to_s
when 'directory'
Tag.discoverable
when 'reviewed'
Tag.reviewed.order(reviewed_at: :desc)
when 'unreviewed'
Tag.unreviewed
when 'pending_review'
Tag.pending_review.order(requested_review_at: :desc)
when 'popular'
Tag.order('max_score DESC NULLS LAST')
when 'active'
Tag.order('last_status_at DESC NULLS LAST')
when 'name'
Tag.matches_name(value)
else
raise "Unknown filter: #{key}"
end
end
end

View File

@ -5,23 +5,100 @@ class TrendingTags
EXPIRE_HISTORY_AFTER = 7.days.seconds
EXPIRE_TRENDS_AFTER = 1.day.seconds
THRESHOLD = 5
LIMIT = 10
REVIEW_THRESHOLD = 3
MAX_SCORE_COOLDOWN = 2.days.freeze
MAX_SCORE_HALFLIFE = 2.hours.freeze
class << self
include Redisable
def record_use!(tag, account, at_time = Time.now.utc)
return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
return if account.silenced? || account.bot? || !tag.usable? || !(tag.trendable? || tag.requires_review?)
increment_historical_use!(tag.id, at_time)
increment_unique_use!(tag.id, account.id, at_time)
increment_vote!(tag.id, at_time)
increment_use!(tag.id, at_time)
tag.update(last_status_at: Time.now.utc) if tag.last_status_at.nil? || tag.last_status_at < 12.hours.ago
end
def get(limit)
key = "#{KEY}:#{Time.now.utc.beginning_of_day.to_i}"
tag_ids = redis.zrevrange(key, 0, limit - 1).map(&:to_i)
tags = Tag.where(id: tag_ids).to_a.each_with_object({}) { |tag, h| h[tag.id] = tag }
tag_ids.map { |tag_id| tags[tag_id] }.compact
def update!(at_time = Time.now.utc)
tag_ids = redis.smembers("#{KEY}:used:#{at_time.beginning_of_day.to_i}") + redis.zrange(KEY, 0, -1)
tags = Tag.where(id: tag_ids.uniq)
# First pass to calculate scores and update the set
tags.each do |tag|
expected = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
expected = 1.0 if expected.zero?
observed = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
max_time = tag.max_score_at
max_score = tag.max_score
max_score = 0 if max_time.nil? || max_time < (at_time - MAX_SCORE_COOLDOWN)
score = begin
if expected > observed || observed < THRESHOLD
0
else
((observed - expected)**2) / expected
end
end
if score > max_score
max_score = score
max_time = at_time
# Not interested in triggering any callbacks for this
tag.update_columns(max_score: max_score, max_score_at: max_time)
end
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / MAX_SCORE_HALFLIFE.to_f))
if decaying_score.zero?
redis.zrem(KEY, tag.id)
else
redis.zadd(KEY, decaying_score, tag.id)
end
end
users_for_review = User.staff.includes(:account).to_a.select(&:allows_trending_tag_emails?)
# Second pass to notify about previously unreviewed trends
tags.each do |tag|
current_rank = redis.zrevrank(KEY, tag.id)
needs_review_notification = tag.requires_review? && !tag.requested_review?
rank_passes_threshold = current_rank.present? && current_rank <= REVIEW_THRESHOLD
next unless !tag.trendable? && rank_passes_threshold && needs_review_notification
tag.touch(:requested_review_at)
users_for_review.each do |user|
AdminMailer.new_trending_tag(user.account, tag).deliver_later!
end
end
# Trim older items
redis.zremrangebyrank(KEY, 0, -(LIMIT + 1))
redis.zremrangebyscore(KEY, '(0.3', '-inf')
end
def get(limit, filtered: true)
tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i)
tags = Tag.where(id: tag_ids)
tags = tags.where(trendable: true) if filtered
tags = tags.each_with_object({}) { |tag, h| h[tag.id] = tag }
tag_ids.map { |tag_id| tags[tag_id] }.compact.take(limit)
end
def trending?(tag)
rank = redis.zrevrank(KEY, tag.id)
rank.present? && rank < LIMIT
end
private
@ -38,28 +115,10 @@ class TrendingTags
redis.expire(key, EXPIRE_HISTORY_AFTER)
end
def increment_vote!(tag_id, at_time)
key = "#{KEY}:#{at_time.beginning_of_day.to_i}"
expected = redis.pfcount("activity:tags:#{tag_id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
expected = 1.0 if expected.zero?
observed = redis.pfcount("activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
if expected > observed || observed < THRESHOLD
redis.zrem(key, tag_id.to_s)
else
score = ((observed - expected)**2) / expected
redis.zadd(key, score, tag_id.to_s)
end
redis.expire(key, EXPIRE_TRENDS_AFTER)
end
def disallowed_hashtags
return @disallowed_hashtags if defined?(@disallowed_hashtags)
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
def increment_use!(tag_id, at_time)
key = "#{KEY}:used:#{at_time.beginning_of_day.to_i}"
redis.sadd(key, tag_id)
redis.expire(key, EXPIRE_HISTORY_AFTER)
end
end
end

View File

@ -74,6 +74,7 @@ class User < ApplicationRecord
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
has_many :backups, inverse_of: :user
has_many :invites, inverse_of: :user
has_many :markers, inverse_of: :user, dependent: :destroy
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
@ -107,7 +108,8 @@ class User < ApplicationRecord
delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
:reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network,
:expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
:advanced_layout, :default_federation, to: :settings, prefix: :setting, allow_nil: false
:advanced_layout, :default_federation, :use_blurhash, :use_pending_items, :trends,
to: :settings, prefix: :setting, allow_nil: false
attr_reader :invite_code
attr_writer :external
@ -162,7 +164,15 @@ class User < ApplicationRecord
end
def active_for_authentication?
super && approved?
true
end
def functional?
confirmed? && approved? && !disabled? && !account.suspended? && account.moved_to_account_id.nil?
end
def unconfirmed_or_pending?
!(confirmed? && approved?)
end
def inactive_message
@ -203,6 +213,10 @@ class User < ApplicationRecord
settings.notification_emails['pending_account']
end
def allows_trending_tag_emails?
settings.notification_emails['trending_tag']
end
def hides_network?
@hides_network ||= settings.hide_network
end
@ -250,17 +264,20 @@ class User < ApplicationRecord
end
def password_required?
return false if Devise.pam_authentication || Devise.ldap_authentication
return false if external?
super
end
def send_reset_password_instructions
return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication)
return false if encrypted_password.blank?
super
end
def reset_password!(new_password, new_password_confirmation)
return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication)
return false if encrypted_password.blank?
super
end

View File

@ -20,6 +20,10 @@ class Web::PushSubscription < ApplicationRecord
has_one :session_activation, foreign_key: 'web_push_subscription_id', inverse_of: :web_push_subscription
validates :endpoint, presence: true
validates :key_p256dh, presence: true
validates :key_auth, presence: true
def push(notification)
I18n.with_locale(associated_user&.locale || I18n.default_locale) do
push_payload(payload_for_notification(notification), 48.hours.seconds)