Merge tag 'v3.0.0' into hometown-dev
This commit is contained in:
@ -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
|
||||
|
47
app/models/account_alias.rb
Normal file
47
app/models/account_alias.rb
Normal 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
|
78
app/models/account_migration.rb
Normal file
78
app/models/account_migration.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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?
|
||||
|
17
app/models/custom_emoji_category.rb
Normal file
17
app/models/custom_emoji_category.rb
Normal 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
|
@ -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
|
||||
|
33
app/models/domain_allow.rb
Normal file
33
app/models/domain_allow.rb
Normal 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
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
8
app/models/form/challenge.rb
Normal file
8
app/models/form/challenge.rb
Normal file
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Form::Challenge
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :current_password, :current_username,
|
||||
:return_to
|
||||
end
|
106
app/models/form/custom_emoji_batch.rb
Normal file
106
app/models/form/custom_emoji_batch.rb
Normal 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
|
@ -3,5 +3,5 @@
|
||||
class Form::DeleteConfirmation
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :password
|
||||
attr_accessor :password, :username
|
||||
end
|
||||
|
@ -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
|
47
app/models/form/redirect.rb
Normal file
47
app/models/form/redirect.rb
Normal 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
|
@ -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
|
||||
|
33
app/models/form/tag_batch.rb
Normal file
33
app/models/form/tag_batch.rb
Normal 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
|
@ -3,5 +3,5 @@
|
||||
class Form::TwoFactorConfirmation
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :code
|
||||
attr_accessor :otp_attempt
|
||||
end
|
||||
|
@ -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?
|
||||
|
@ -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?
|
||||
|
@ -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
23
app/models/marker.rb
Normal 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
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
@ -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
44
app/models/tag_filter.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
Reference in New Issue
Block a user