mirror of
https://github.com/yingziwu/mastodon.git
synced 2026-02-21 17:53:18 +00:00
Merge tag 'v4.0.0rc1'
This commit is contained in:
commit
7ef0a46ebb
1463 changed files with 51604 additions and 34943 deletions
|
|
@ -88,7 +88,7 @@ class Account < ApplicationRecord
|
|||
|
||||
# Local user validations
|
||||
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
|
||||
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
|
||||
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
|
||||
validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
|
||||
validates :note, note_length: { maximum: 500 }, if: -> { local? && will_save_change_to_note? }
|
||||
validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
|
||||
|
|
@ -116,7 +116,7 @@ class Account < ApplicationRecord
|
|||
scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
|
||||
scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
|
||||
scope :popular, -> { order('account_stats.followers_count desc') }
|
||||
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
|
||||
scope :by_domain_and_subdomains, ->(domain) { where(domain: 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))) }
|
||||
|
||||
|
|
@ -132,11 +132,9 @@ class Account < ApplicationRecord
|
|||
:unconfirmed?,
|
||||
:unconfirmed_or_pending?,
|
||||
:role,
|
||||
:admin?,
|
||||
:moderator?,
|
||||
:staff?,
|
||||
:locale,
|
||||
:shows_application?,
|
||||
:prefers_noindex?,
|
||||
to: :user,
|
||||
prefix: true,
|
||||
allow_nil: true
|
||||
|
|
@ -193,10 +191,6 @@ class Account < ApplicationRecord
|
|||
"acct:#{local_username_and_domain}"
|
||||
end
|
||||
|
||||
def searchable?
|
||||
!(suspended? || moved?) && (!local? || (approved? && confirmed?))
|
||||
end
|
||||
|
||||
def possibly_stale?
|
||||
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
||||
end
|
||||
|
|
@ -261,6 +255,10 @@ class Account < ApplicationRecord
|
|||
update!(memorial: true)
|
||||
end
|
||||
|
||||
def trendable
|
||||
boolean_with_default('trendable', Setting.trendable_by_default)
|
||||
end
|
||||
|
||||
def sign?
|
||||
true
|
||||
end
|
||||
|
|
@ -365,6 +363,10 @@ class Account < ApplicationRecord
|
|||
username
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
acct
|
||||
end
|
||||
|
||||
def excluded_from_timeline_account_ids
|
||||
Rails.cache.fetch("exclude_account_ids_for:#{id}") { block_relationships.pluck(:target_account_id) + blocked_by_relationships.pluck(:account_id) + mute_relationships.pluck(:target_account_id) }
|
||||
end
|
||||
|
|
@ -443,7 +445,12 @@ class Account < ApplicationRecord
|
|||
|
||||
class << self
|
||||
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/.freeze
|
||||
TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
|
||||
TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'A') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
|
||||
|
||||
REPUTATION_SCORE_FUNCTION = '(greatest(0, coalesce(s.followers_count, 0)) / (greatest(0, coalesce(s.following_count, 0)) + 1.0))'
|
||||
FOLLOWERS_SCORE_FUNCTION = 'log(greatest(0, coalesce(s.followers_count, 0)) + 2)'
|
||||
TIME_DISTANCE_FUNCTION = '(case when s.last_status_at is null then 0 else exp(-1.0 * ((greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) / (2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))))) end)'
|
||||
BOOST = "((#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0)"
|
||||
|
||||
def readonly_attributes
|
||||
super - %w(statuses_count following_count followers_count)
|
||||
|
|
@ -454,15 +461,16 @@ class Account < ApplicationRecord
|
|||
DeliveryFailureTracker.without_unavailable(urls)
|
||||
end
|
||||
|
||||
def search_for(terms, limit = 10, offset = 0)
|
||||
def search_for(terms, limit: 10, offset: 0)
|
||||
tsquery = generate_query_for_search(terms)
|
||||
|
||||
sql = <<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
#{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT JOIN users ON accounts.id = users.account_id
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
|
|
@ -476,7 +484,7 @@ class Account < ApplicationRecord
|
|||
records
|
||||
end
|
||||
|
||||
def advanced_search_for(terms, account, limit = 10, following = false, offset = 0)
|
||||
def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
|
||||
tsquery = generate_query_for_search(terms)
|
||||
sql = advanced_search_for_sql_template(following)
|
||||
records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
|
||||
|
|
@ -524,14 +532,15 @@ class Account < ApplicationRecord
|
|||
)
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE accounts.id IN (SELECT * FROM first_degree)
|
||||
AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
GROUP BY accounts.id
|
||||
GROUP BY accounts.id, s.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
|
|
@ -539,15 +548,16 @@ class Account < ApplicationRecord
|
|||
<<-SQL.squish
|
||||
SELECT
|
||||
accounts.*,
|
||||
(count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||
FROM accounts
|
||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
|
||||
LEFT JOIN users ON accounts.id = users.account_id
|
||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
||||
GROUP BY accounts.id
|
||||
GROUP BY accounts.id, s.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ class AccountFilter
|
|||
KEYS = %i(
|
||||
origin
|
||||
status
|
||||
permissions
|
||||
role_ids
|
||||
username
|
||||
by_domain
|
||||
display_name
|
||||
|
|
@ -26,7 +26,7 @@ class AccountFilter
|
|||
params.each do |key, value|
|
||||
next if key.to_s == 'page'
|
||||
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||
scope.merge!(scope_for(key, value)) if value.present?
|
||||
end
|
||||
|
||||
scope
|
||||
|
|
@ -38,18 +38,18 @@ class AccountFilter
|
|||
case key.to_s
|
||||
when 'origin'
|
||||
origin_scope(value)
|
||||
when 'permissions'
|
||||
permissions_scope(value)
|
||||
when 'role_ids'
|
||||
role_scope(value)
|
||||
when 'status'
|
||||
status_scope(value)
|
||||
when 'by_domain'
|
||||
Account.where(domain: value)
|
||||
Account.where(domain: value.to_s)
|
||||
when 'username'
|
||||
Account.matches_username(value)
|
||||
Account.matches_username(value.to_s)
|
||||
when 'display_name'
|
||||
Account.matches_display_name(value)
|
||||
Account.matches_display_name(value.to_s)
|
||||
when 'email'
|
||||
accounts_with_users.merge(User.matches_email(value))
|
||||
accounts_with_users.merge(User.matches_email(value.to_s))
|
||||
when 'ip'
|
||||
valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group('users.id, accounts.id')) : Account.none
|
||||
when 'invited_by'
|
||||
|
|
@ -104,13 +104,8 @@ class AccountFilter
|
|||
Account.left_joins(user: :invite).merge(Invite.where(user_id: value.to_s))
|
||||
end
|
||||
|
||||
def permissions_scope(value)
|
||||
case value.to_s
|
||||
when 'staff'
|
||||
accounts_with_users.merge(User.staff)
|
||||
else
|
||||
raise "Unknown permissions: #{value}"
|
||||
end
|
||||
def role_scope(value)
|
||||
accounts_with_users.merge(User.where(role_id: Array(value).map(&:to_s)))
|
||||
end
|
||||
|
||||
def accounts_with_users
|
||||
|
|
@ -118,7 +113,7 @@ class AccountFilter
|
|||
end
|
||||
|
||||
def valid_ip?(value)
|
||||
IPAddr.new(value) && true
|
||||
IPAddr.new(value.to_s) && true
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,4 +43,8 @@ class AccountWarning < ApplicationRecord
|
|||
def overruled?
|
||||
overruled_at.present?
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
target_account.acct
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ class Admin::AccountAction
|
|||
alias send_email_notification? send_email_notification
|
||||
alias include_statuses? include_statuses
|
||||
|
||||
validates :type, :target_account, :current_account, presence: true
|
||||
|
||||
def initialize(attributes = {})
|
||||
@send_email_notification = true
|
||||
@include_statuses = true
|
||||
|
|
@ -41,13 +43,15 @@ class Admin::AccountAction
|
|||
end
|
||||
|
||||
def save!
|
||||
raise ActiveRecord::RecordInvalid, self unless valid?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
process_action!
|
||||
process_strike!
|
||||
process_reports!
|
||||
end
|
||||
|
||||
process_email!
|
||||
process_reports!
|
||||
process_queue!
|
||||
end
|
||||
|
||||
|
|
@ -106,9 +110,8 @@ class Admin::AccountAction
|
|||
# Otherwise, we will mark all unresolved reports about
|
||||
# the account as resolved.
|
||||
|
||||
reports.each { |report| authorize(report, :update?) }
|
||||
|
||||
reports.each do |report|
|
||||
authorize(report, :update?)
|
||||
log_action(:resolve, report)
|
||||
report.resolve!(current_account)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: admin_action_logs
|
||||
|
|
@ -8,38 +9,42 @@
|
|||
# action :string default(""), not null
|
||||
# target_type :string
|
||||
# target_id :bigint(8)
|
||||
# recorded_changes :text default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# human_identifier :string
|
||||
# route_param :string
|
||||
# permalink :string
|
||||
#
|
||||
|
||||
class Admin::ActionLog < ApplicationRecord
|
||||
serialize :recorded_changes
|
||||
self.ignored_columns = %w(
|
||||
recorded_changes
|
||||
)
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target, polymorphic: true, optional: true
|
||||
|
||||
default_scope -> { order('id desc') }
|
||||
|
||||
before_validation :set_human_identifier
|
||||
before_validation :set_route_param
|
||||
before_validation :set_permalink
|
||||
|
||||
def action
|
||||
super.to_sym
|
||||
end
|
||||
|
||||
before_validation :set_changes
|
||||
|
||||
private
|
||||
|
||||
def set_changes
|
||||
case action
|
||||
when :destroy, :create
|
||||
self.recorded_changes = target.attributes
|
||||
when :update, :promote, :demote
|
||||
self.recorded_changes = target.previous_changes
|
||||
when :change_email
|
||||
self.recorded_changes = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
email: [target.email, nil],
|
||||
unconfirmed_email: [nil, target.unconfirmed_email]
|
||||
)
|
||||
end
|
||||
def set_human_identifier
|
||||
self.human_identifier = target.to_log_human_identifier if target.respond_to?(:to_log_human_identifier)
|
||||
end
|
||||
|
||||
def set_route_param
|
||||
self.route_param = target.to_log_route_param if target.respond_to?(:to_log_route_param)
|
||||
end
|
||||
|
||||
def set_permalink
|
||||
self.permalink = target.to_log_permalink if target.respond_to?(:to_log_permalink)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class Admin::ActionLogFilter
|
|||
reject_appeal: { target_type: 'Appeal', action: 'reject' }.freeze,
|
||||
assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
|
||||
change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
|
||||
change_role_user: { target_type: 'User', action: 'change_role' }.freeze,
|
||||
confirm_user: { target_type: 'User', action: 'confirm' }.freeze,
|
||||
approve_user: { target_type: 'User', action: 'approve' }.freeze,
|
||||
reject_user: { target_type: 'User', action: 'reject' }.freeze,
|
||||
|
|
@ -21,16 +22,22 @@ class Admin::ActionLogFilter
|
|||
create_domain_allow: { target_type: 'DomainAllow', action: 'create' }.freeze,
|
||||
create_domain_block: { target_type: 'DomainBlock', action: 'create' }.freeze,
|
||||
create_email_domain_block: { target_type: 'EmailDomainBlock', action: 'create' }.freeze,
|
||||
create_ip_block: { target_type: 'IpBlock', action: 'create' }.freeze,
|
||||
create_unavailable_domain: { target_type: 'UnavailableDomain', action: 'create' }.freeze,
|
||||
create_user_role: { target_type: 'UserRole', action: 'create' }.freeze,
|
||||
create_canonical_email_block: { target_type: 'CanonicalEmailBlock', action: 'create' }.freeze,
|
||||
demote_user: { target_type: 'User', action: 'demote' }.freeze,
|
||||
destroy_announcement: { target_type: 'Announcement', action: 'destroy' }.freeze,
|
||||
destroy_custom_emoji: { target_type: 'CustomEmoji', action: 'destroy' }.freeze,
|
||||
destroy_domain_allow: { target_type: 'DomainAllow', action: 'destroy' }.freeze,
|
||||
destroy_domain_block: { target_type: 'DomainBlock', action: 'destroy' }.freeze,
|
||||
destroy_ip_block: { target_type: 'IpBlock', action: 'destroy' }.freeze,
|
||||
destroy_email_domain_block: { target_type: 'EmailDomainBlock', action: 'destroy' }.freeze,
|
||||
destroy_instance: { target_type: 'Instance', action: 'destroy' }.freeze,
|
||||
destroy_unavailable_domain: { target_type: 'UnavailableDomain', action: 'destroy' }.freeze,
|
||||
destroy_status: { target_type: 'Status', action: 'destroy' }.freeze,
|
||||
destroy_user_role: { target_type: 'UserRole', action: 'destroy' }.freeze,
|
||||
destroy_canonical_email_block: { target_type: 'CanonicalEmailBlock', action: 'destroy' }.freeze,
|
||||
disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze,
|
||||
disable_custom_emoji: { target_type: 'CustomEmoji', action: 'disable' }.freeze,
|
||||
disable_user: { target_type: 'User', action: 'disable' }.freeze,
|
||||
|
|
@ -40,6 +47,7 @@ class Admin::ActionLogFilter
|
|||
promote_user: { target_type: 'User', action: 'promote' }.freeze,
|
||||
remove_avatar_user: { target_type: 'User', action: 'remove_avatar' }.freeze,
|
||||
reopen_report: { target_type: 'Report', action: 'reopen' }.freeze,
|
||||
resend_user: { target_type: 'User', action: 'resend' }.freeze,
|
||||
reset_password_user: { target_type: 'User', action: 'reset_password' }.freeze,
|
||||
resolve_report: { target_type: 'Report', action: 'resolve' }.freeze,
|
||||
sensitive_account: { target_type: 'Account', action: 'sensitive' }.freeze,
|
||||
|
|
@ -52,6 +60,8 @@ class Admin::ActionLogFilter
|
|||
update_announcement: { target_type: 'Announcement', action: 'update' }.freeze,
|
||||
update_custom_emoji: { target_type: 'CustomEmoji', action: 'update' }.freeze,
|
||||
update_status: { target_type: 'Status', action: 'update' }.freeze,
|
||||
update_user_role: { target_type: 'UserRole', action: 'update' }.freeze,
|
||||
update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze,
|
||||
unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
|
||||
}.freeze
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,11 @@ class Admin::StatusBatchAction
|
|||
end
|
||||
|
||||
def handle_delete!
|
||||
statuses.each { |status| authorize(status, :destroy?) }
|
||||
statuses.each { |status| authorize([:admin, status], :destroy?) }
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
statuses.each do |status|
|
||||
status.discard
|
||||
status.discard_with_reblogs
|
||||
log_action(:destroy, status)
|
||||
end
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ class Admin::StatusBatchAction
|
|||
statuses.includes(:media_attachments, :preview_cards).find_each do |status|
|
||||
next unless status.with_media? || status.with_preview_card?
|
||||
|
||||
authorize(status, :update?)
|
||||
authorize([:admin, status], :update?)
|
||||
|
||||
if target_account.local?
|
||||
UpdateStatusService.new.call(status, representative_account.id, sensitive: true)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
class Admin::StatusFilter
|
||||
KEYS = %i(
|
||||
media
|
||||
id
|
||||
report_id
|
||||
).freeze
|
||||
|
||||
|
|
@ -28,12 +27,10 @@ class Admin::StatusFilter
|
|||
|
||||
private
|
||||
|
||||
def scope_for(key, value)
|
||||
def scope_for(key, _value)
|
||||
case key.to_s
|
||||
when 'media'
|
||||
Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id).reorder('statuses.id desc')
|
||||
when 'id'
|
||||
Status.where(id: value)
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,9 +31,12 @@ class Announcement < ApplicationRecord
|
|||
validates :starts_at, presence: true, if: -> { ends_at.present? }
|
||||
validates :ends_at, presence: true, if: -> { starts_at.present? }
|
||||
|
||||
before_validation :set_all_day
|
||||
before_validation :set_published, on: :create
|
||||
|
||||
def to_log_human_identifier
|
||||
text
|
||||
end
|
||||
|
||||
def publish!
|
||||
update!(published: true, published_at: Time.now.utc, scheduled_at: nil)
|
||||
end
|
||||
|
|
@ -85,10 +88,6 @@ class Announcement < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def set_all_day
|
||||
self.all_day = false if starts_at.blank? || ends_at.blank?
|
||||
end
|
||||
|
||||
def set_published
|
||||
return unless scheduled_at.blank? || scheduled_at.past?
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ class Appeal < ApplicationRecord
|
|||
update!(rejected_at: Time.now.utc, rejected_by_account: current_account)
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
account.acct
|
||||
end
|
||||
|
||||
def to_log_route_param
|
||||
account_warning_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_time_frame
|
||||
|
|
|
|||
|
|
@ -5,27 +5,30 @@
|
|||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# canonical_email_hash :string default(""), not null
|
||||
# reference_account_id :bigint(8) not null
|
||||
# reference_account_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class CanonicalEmailBlock < ApplicationRecord
|
||||
include EmailHelper
|
||||
include Paginable
|
||||
|
||||
belongs_to :reference_account, class_name: 'Account'
|
||||
belongs_to :reference_account, class_name: 'Account', optional: true
|
||||
|
||||
validates :canonical_email_hash, presence: true, uniqueness: true
|
||||
|
||||
scope :matching_email, ->(email) { where(canonical_email_hash: email_to_canonical_email_hash(email)) }
|
||||
|
||||
def to_log_human_identifier
|
||||
canonical_email_hash
|
||||
end
|
||||
|
||||
def email=(email)
|
||||
self.canonical_email_hash = email_to_canonical_email_hash(email)
|
||||
end
|
||||
|
||||
def self.block?(email)
|
||||
where(canonical_email_hash: email_to_canonical_email_hash(email)).exists?
|
||||
end
|
||||
|
||||
def self.find_blocks(email)
|
||||
where(canonical_email_hash: email_to_canonical_email_hash(email))
|
||||
matching_email(email).exists?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
module AccountAvatar
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 2.megabytes
|
||||
|
||||
class_methods do
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
module AccountHeader
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 2.megabytes
|
||||
MAX_PIXELS = 750_000 # 1500x500px
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ module AccountInteractions
|
|||
mapping[follow.target_account_id] = {
|
||||
reblogs: follow.show_reblogs?,
|
||||
notify: follow.notify?,
|
||||
languages: follow.languages,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -38,6 +39,7 @@ module AccountInteractions
|
|||
mapping[follow_request.target_account_id] = {
|
||||
reblogs: follow_request.show_reblogs?,
|
||||
notify: follow_request.notify?,
|
||||
languages: follow_request.languages,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -100,12 +102,13 @@ module AccountInteractions
|
|||
has_many :announcement_mutes, dependent: :destroy
|
||||
end
|
||||
|
||||
def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false, bypass_limit: false)
|
||||
rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
|
||||
def follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
|
||||
rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, languages: languages, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
|
||||
.find_or_create_by!(target_account: other_account)
|
||||
|
||||
rel.show_reblogs = reblogs unless reblogs.nil?
|
||||
rel.notify = notify unless notify.nil?
|
||||
rel.show_reblogs = reblogs unless reblogs.nil?
|
||||
rel.notify = notify unless notify.nil?
|
||||
rel.languages = languages unless languages.nil?
|
||||
|
||||
rel.save! if rel.changed?
|
||||
|
||||
|
|
@ -114,12 +117,13 @@ module AccountInteractions
|
|||
rel
|
||||
end
|
||||
|
||||
def request_follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false, bypass_limit: false)
|
||||
rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
|
||||
def request_follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
|
||||
rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, languages: languages, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
|
||||
.find_or_create_by!(target_account: other_account)
|
||||
|
||||
rel.show_reblogs = reblogs unless reblogs.nil?
|
||||
rel.notify = notify unless notify.nil?
|
||||
rel.show_reblogs = reblogs unless reblogs.nil?
|
||||
rel.notify = notify unless notify.nil?
|
||||
rel.languages = languages unless languages.nil?
|
||||
|
||||
rel.save! if rel.changed?
|
||||
|
||||
|
|
@ -247,6 +251,11 @@ module AccountInteractions
|
|||
account_pins.where(target_account: account).exists?
|
||||
end
|
||||
|
||||
def status_matches_filters(status)
|
||||
active_filters = CustomFilter.cached_filters_for(id)
|
||||
CustomFilter.apply_cached_filters(active_filters, status)
|
||||
end
|
||||
|
||||
def followers_for_local_distribution
|
||||
followers.local
|
||||
.joins(:user)
|
||||
|
|
@ -283,8 +292,7 @@ module AccountInteractions
|
|||
|
||||
private
|
||||
|
||||
def remove_potential_friendship(other_account, mutual = false)
|
||||
def remove_potential_friendship(other_account)
|
||||
PotentialFriendshipTracker.remove(id, other_account.id)
|
||||
PotentialFriendshipTracker.remove(other_account.id, id) if mutual
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module UserRoles
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :admins, -> { where(admin: true) }
|
||||
scope :moderators, -> { where(moderator: true) }
|
||||
scope :staff, -> { admins.or(moderators) }
|
||||
end
|
||||
|
||||
def staff?
|
||||
admin? || moderator?
|
||||
end
|
||||
|
||||
def role=(value)
|
||||
case value
|
||||
when 'admin'
|
||||
self.admin = true
|
||||
self.moderator = false
|
||||
when 'moderator'
|
||||
self.admin = false
|
||||
self.moderator = true
|
||||
else
|
||||
self.admin = false
|
||||
self.moderator = false
|
||||
end
|
||||
end
|
||||
|
||||
def role
|
||||
if admin?
|
||||
'admin'
|
||||
elsif moderator?
|
||||
'moderator'
|
||||
else
|
||||
'user'
|
||||
end
|
||||
end
|
||||
|
||||
def role?(role)
|
||||
case role
|
||||
when 'user'
|
||||
true
|
||||
when 'moderator'
|
||||
staff?
|
||||
when 'admin'
|
||||
admin?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def promote!
|
||||
if moderator?
|
||||
update!(moderator: false, admin: true)
|
||||
elsif !admin?
|
||||
update!(moderator: true)
|
||||
end
|
||||
end
|
||||
|
||||
def demote!
|
||||
if admin?
|
||||
update!(admin: false, moderator: true)
|
||||
elsif moderator?
|
||||
update!(moderator: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
25
app/models/content_retention_policy.rb
Normal file
25
app/models/content_retention_policy.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ContentRetentionPolicy
|
||||
def self.current
|
||||
new
|
||||
end
|
||||
|
||||
def media_cache_retention_period
|
||||
retention_period Setting.media_cache_retention_period
|
||||
end
|
||||
|
||||
def content_cache_retention_period
|
||||
retention_period Setting.content_cache_retention_period
|
||||
end
|
||||
|
||||
def backups_retention_period
|
||||
retention_period Setting.backups_retention_period
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def retention_period(value)
|
||||
value.days if value.is_a?(Integer) && value.positive?
|
||||
end
|
||||
end
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
class CustomEmoji < ApplicationRecord
|
||||
include Attachmentable
|
||||
|
||||
LIMIT = 50.kilobytes
|
||||
LIMIT = 256.kilobytes
|
||||
|
||||
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ class CustomEmoji < ApplicationRecord
|
|||
:(#{SHORTCODE_RE_FRAGMENT}):
|
||||
(?=[^[:alnum:]:]|$)/x
|
||||
|
||||
IMAGE_MIME_TYPES = %w(image/png image/gif).freeze
|
||||
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
|
||||
|
||||
belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
|
||||
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
|
||||
|
|
@ -46,7 +46,7 @@ class CustomEmoji < ApplicationRecord
|
|||
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 :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
|
||||
|
|
@ -67,6 +67,10 @@ class CustomEmoji < ApplicationRecord
|
|||
copy.tap(&:save!)
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
shortcode
|
||||
end
|
||||
|
||||
class << self
|
||||
def from_text(text, domain = nil)
|
||||
return [] if text.blank?
|
||||
|
|
|
|||
|
|
@ -3,18 +3,22 @@
|
|||
#
|
||||
# Table name: custom_filters
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# expires_at :datetime
|
||||
# phrase :text default(""), not null
|
||||
# context :string default([]), not null, is an Array
|
||||
# whole_word :boolean default(TRUE), not null
|
||||
# irreversible :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# expires_at :datetime
|
||||
# phrase :text default(""), not null
|
||||
# context :string default([]), not null, is an Array
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# action :integer default("warn"), not null
|
||||
#
|
||||
|
||||
class CustomFilter < ApplicationRecord
|
||||
self.ignored_columns = %w(whole_word irreversible)
|
||||
|
||||
alias_attribute :title, :phrase
|
||||
alias_attribute :filter_action, :action
|
||||
|
||||
VALID_CONTEXTS = %w(
|
||||
home
|
||||
notifications
|
||||
|
|
@ -26,16 +30,21 @@ class CustomFilter < ApplicationRecord
|
|||
include Expireable
|
||||
include Redisable
|
||||
|
||||
enum action: [:warn, :hide], _suffix: :action
|
||||
|
||||
belongs_to :account
|
||||
has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
|
||||
has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
|
||||
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
|
||||
|
||||
validates :phrase, :context, presence: true
|
||||
validates :title, :context, presence: true
|
||||
validate :context_must_be_valid
|
||||
validate :irreversible_must_be_within_context
|
||||
|
||||
scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) }
|
||||
|
||||
before_validation :clean_up_contexts
|
||||
after_commit :remove_cache
|
||||
|
||||
before_save :prepare_cache_invalidation!
|
||||
before_destroy :prepare_cache_invalidation!
|
||||
after_commit :invalidate_cache!
|
||||
|
||||
def expires_in
|
||||
return @expires_in if defined?(@expires_in)
|
||||
|
|
@ -44,22 +53,78 @@ class CustomFilter < ApplicationRecord
|
|||
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
|
||||
end
|
||||
|
||||
def irreversible=(value)
|
||||
self.action = value ? :hide : :warn
|
||||
end
|
||||
|
||||
def irreversible?
|
||||
hide_action?
|
||||
end
|
||||
|
||||
def self.cached_filters_for(account_id)
|
||||
active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
|
||||
filters_hash = {}
|
||||
|
||||
scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
|
||||
scope.to_a.group_by(&:custom_filter).each do |filter, keywords|
|
||||
keywords.map! do |keyword|
|
||||
if keyword.whole_word
|
||||
sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
|
||||
eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : ''
|
||||
|
||||
/(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/
|
||||
else
|
||||
/#{Regexp.escape(keyword.keyword)}/i
|
||||
end
|
||||
end
|
||||
|
||||
filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter }
|
||||
end.to_h
|
||||
|
||||
scope = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
|
||||
scope.to_a.group_by(&:custom_filter).each do |filter, statuses|
|
||||
filters_hash[filter.id] ||= { filter: filter }
|
||||
filters_hash[filter.id].merge!(status_ids: statuses.map(&:status_id))
|
||||
end
|
||||
|
||||
filters_hash.values.map { |cache| [cache.delete(:filter), cache] }
|
||||
end.to_a
|
||||
|
||||
active_filters.select { |custom_filter, _| !custom_filter.expired? }
|
||||
end
|
||||
|
||||
def self.apply_cached_filters(cached_filters, status)
|
||||
cached_filters.filter_map do |filter, rules|
|
||||
match = rules[:keywords].match(status.proper.searchable_text) if rules[:keywords].present?
|
||||
keyword_matches = [match.to_s] unless match.nil?
|
||||
|
||||
status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present?
|
||||
|
||||
next if keyword_matches.blank? && status_matches.blank?
|
||||
FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches)
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_cache_invalidation!
|
||||
@should_invalidate_cache = true
|
||||
end
|
||||
|
||||
def invalidate_cache!
|
||||
return unless @should_invalidate_cache
|
||||
@should_invalidate_cache = false
|
||||
|
||||
Rails.cache.delete("filters:v3:#{account_id}")
|
||||
redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
|
||||
redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clean_up_contexts
|
||||
self.context = Array(context).map(&:strip).filter_map(&:presence)
|
||||
end
|
||||
|
||||
def remove_cache
|
||||
Rails.cache.delete("filters:#{account_id}")
|
||||
redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
|
||||
end
|
||||
|
||||
def context_must_be_valid
|
||||
errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) }
|
||||
end
|
||||
|
||||
def irreversible_must_be_within_context
|
||||
errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
34
app/models/custom_filter_keyword.rb
Normal file
34
app/models/custom_filter_keyword.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_filter_keywords
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# custom_filter_id :bigint(8) not null
|
||||
# keyword :text default(""), not null
|
||||
# whole_word :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class CustomFilterKeyword < ApplicationRecord
|
||||
belongs_to :custom_filter
|
||||
|
||||
validates :keyword, presence: true
|
||||
|
||||
alias_attribute :phrase, :keyword
|
||||
|
||||
before_save :prepare_cache_invalidation!
|
||||
before_destroy :prepare_cache_invalidation!
|
||||
after_commit :invalidate_cache!
|
||||
|
||||
private
|
||||
|
||||
def prepare_cache_invalidation!
|
||||
custom_filter.prepare_cache_invalidation!
|
||||
end
|
||||
|
||||
def invalidate_cache!
|
||||
custom_filter.invalidate_cache!
|
||||
end
|
||||
end
|
||||
37
app/models/custom_filter_status.rb
Normal file
37
app/models/custom_filter_status.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_filter_statuses
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# custom_filter_id :bigint(8) not null
|
||||
# status_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class CustomFilterStatus < ApplicationRecord
|
||||
belongs_to :custom_filter
|
||||
belongs_to :status
|
||||
|
||||
validates :status, uniqueness: { scope: :custom_filter }
|
||||
validate :validate_status_access
|
||||
|
||||
before_save :prepare_cache_invalidation!
|
||||
before_destroy :prepare_cache_invalidation!
|
||||
after_commit :invalidate_cache!
|
||||
|
||||
private
|
||||
|
||||
def validate_status_access
|
||||
errors.add(:status_id, :invalid) unless StatusPolicy.new(custom_filter.account, status).show?
|
||||
end
|
||||
|
||||
def prepare_cache_invalidation!
|
||||
custom_filter.prepare_cache_invalidation!
|
||||
end
|
||||
|
||||
def invalidate_cache!
|
||||
custom_filter.invalidate_cache!
|
||||
end
|
||||
end
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
#
|
||||
|
||||
class DomainAllow < ApplicationRecord
|
||||
include Paginable
|
||||
include DomainNormalizable
|
||||
include DomainMaterializable
|
||||
|
||||
|
|
@ -18,6 +19,10 @@ class DomainAllow < ApplicationRecord
|
|||
|
||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
|
||||
def to_log_human_identifier
|
||||
domain
|
||||
end
|
||||
|
||||
class << self
|
||||
def allowed?(domain)
|
||||
!rule_for(domain).nil?
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#
|
||||
|
||||
class DomainBlock < ApplicationRecord
|
||||
include Paginable
|
||||
include DomainNormalizable
|
||||
include DomainMaterializable
|
||||
|
||||
|
|
@ -27,8 +28,12 @@ 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')) }
|
||||
scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]) }
|
||||
scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), domain')) }
|
||||
|
||||
def to_log_human_identifier
|
||||
domain
|
||||
end
|
||||
|
||||
def policies
|
||||
if suspend?
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class EmailDomainBlock < ApplicationRecord
|
|||
)
|
||||
|
||||
include DomainNormalizable
|
||||
include Paginable
|
||||
|
||||
belongs_to :parent, class_name: 'EmailDomainBlock', optional: true
|
||||
has_many :children, class_name: 'EmailDomainBlock', foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy
|
||||
|
|
@ -26,36 +27,64 @@ class EmailDomainBlock < ApplicationRecord
|
|||
# Used for adding multiple blocks at once
|
||||
attr_accessor :other_domains
|
||||
|
||||
def to_log_human_identifier
|
||||
domain
|
||||
end
|
||||
|
||||
def history
|
||||
@history ||= Trends::History.new('email_domain_blocks', id)
|
||||
end
|
||||
|
||||
def self.block?(domain_or_domains, attempt_ip: nil)
|
||||
domains = Array(domain_or_domains).map do |str|
|
||||
domain = begin
|
||||
if str.include?('@')
|
||||
str.split('@', 2).last
|
||||
else
|
||||
str
|
||||
end
|
||||
class Matcher
|
||||
def initialize(domain_or_domains, attempt_ip: nil)
|
||||
@uris = extract_uris(domain_or_domains)
|
||||
@attempt_ip = attempt_ip
|
||||
end
|
||||
|
||||
def match?
|
||||
blocking? || invalid_uri?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invalid_uri?
|
||||
@uris.any?(&:nil?)
|
||||
end
|
||||
|
||||
def blocking?
|
||||
blocks = EmailDomainBlock.where(domain: domains_with_variants).order(Arel.sql('char_length(domain) desc'))
|
||||
blocks.each { |block| block.history.add(@attempt_ip) } if @attempt_ip.present?
|
||||
blocks.any?
|
||||
end
|
||||
|
||||
def domains_with_variants
|
||||
@uris.flat_map do |uri|
|
||||
next if uri.nil?
|
||||
|
||||
segments = uri.normalized_host.split('.')
|
||||
|
||||
segments.map.with_index { |_, i| segments[i..-1].join('.') }
|
||||
end
|
||||
|
||||
TagManager.instance.normalize_domain(domain) if domain.present?
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
# If some of the inputs passed in are invalid, we definitely want to
|
||||
# block the attempt, but we also want to register hits against any
|
||||
# other valid matches
|
||||
def extract_uris(domain_or_domains)
|
||||
Array(domain_or_domains).map do |str|
|
||||
domain = begin
|
||||
if str.include?('@')
|
||||
str.split('@', 2).last
|
||||
else
|
||||
str
|
||||
end
|
||||
end
|
||||
|
||||
blocked = domains.any?(&:nil?)
|
||||
|
||||
where(domain: domains).find_each do |block|
|
||||
blocked = true
|
||||
block.history.add(attempt_ip) if attempt_ip.present?
|
||||
Addressable::URI.new.tap { |u| u.host = domain.strip } if domain.present?
|
||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
blocked
|
||||
def self.block?(domain_or_domains, attempt_ip: nil)
|
||||
Matcher.new(domain_or_domains, attempt_ip: attempt_ip).match?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ class Export
|
|||
end
|
||||
|
||||
def to_following_accounts_csv
|
||||
CSV.generate(headers: ['Account address', 'Show boosts'], write_headers: true) do |csv|
|
||||
CSV.generate(headers: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'], write_headers: true) do |csv|
|
||||
account.active_relationships.includes(:target_account).reorder(id: :desc).each do |follow|
|
||||
csv << [acct(follow.target_account), follow.show_reblogs]
|
||||
csv << [acct(follow.target_account), follow.show_reblogs, follow.notify, follow.languages&.join(', ')]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
15
app/models/extended_description.rb
Normal file
15
app/models/extended_description.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExtendedDescription < ActiveModelSerializers::Model
|
||||
attributes :updated_at, :text
|
||||
|
||||
def self.current
|
||||
custom = Setting.find_by(var: 'site_extended_description')
|
||||
|
||||
if custom&.value.present?
|
||||
new(text: custom.value, updated_at: custom.updated_at)
|
||||
else
|
||||
new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -10,20 +10,33 @@
|
|||
# last_status_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# name :string
|
||||
#
|
||||
|
||||
class FeaturedTag < ApplicationRecord
|
||||
belongs_to :account, inverse_of: :featured_tags, required: true
|
||||
belongs_to :tag, inverse_of: :featured_tags, required: true
|
||||
belongs_to :account, inverse_of: :featured_tags
|
||||
belongs_to :tag, inverse_of: :featured_tags, optional: true # Set after validation
|
||||
|
||||
delegate :name, to: :tag, allow_nil: true
|
||||
validates :name, presence: true, format: { with: /\A(#{Tag::HASHTAG_NAME_RE})\z/i }, on: :create
|
||||
|
||||
validates_associated :tag, on: :create
|
||||
validates :name, presence: true, on: :create
|
||||
validate :validate_tag_uniqueness, on: :create
|
||||
validate :validate_featured_tags_limit, on: :create
|
||||
|
||||
def name=(str)
|
||||
self.tag = Tag.find_or_create_by_names(str.strip)&.first
|
||||
before_validation :strip_name
|
||||
|
||||
before_create :set_tag
|
||||
before_create :reset_data
|
||||
|
||||
scope :by_name, ->(name) { joins(:tag).where(tag: { name: HashtagNormalizer.new.normalize(name) }) }
|
||||
|
||||
LIMIT = 10
|
||||
|
||||
def sign?
|
||||
true
|
||||
end
|
||||
|
||||
def display_name
|
||||
attributes['name'] || tag.display_name
|
||||
end
|
||||
|
||||
def increment(timestamp)
|
||||
|
|
@ -34,14 +47,26 @@ class FeaturedTag < ApplicationRecord
|
|||
update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strip_name
|
||||
self.name = name&.strip&.gsub(/\A#/, '')
|
||||
end
|
||||
|
||||
def set_tag
|
||||
self.tag = Tag.find_or_create_by_names(name)&.first
|
||||
end
|
||||
|
||||
def reset_data
|
||||
self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count
|
||||
self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_featured_tags_limit
|
||||
errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= 10
|
||||
errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= LIMIT
|
||||
end
|
||||
|
||||
def validate_tag_uniqueness
|
||||
errors.add(:name, :taken) if FeaturedTag.by_name(name).where(account_id: account_id).exists?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
# show_reblogs :boolean default(TRUE), not null
|
||||
# uri :string
|
||||
# notify :boolean default(FALSE), not null
|
||||
# languages :string is an Array
|
||||
#
|
||||
|
||||
class Follow < ApplicationRecord
|
||||
|
|
@ -27,6 +28,7 @@ class Follow < ApplicationRecord
|
|||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
validates :languages, language: true
|
||||
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
|
||||
|
|
@ -35,7 +37,7 @@ class Follow < ApplicationRecord
|
|||
end
|
||||
|
||||
def revoke_request!
|
||||
FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, notify: notify, uri: uri)
|
||||
FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, notify: notify, languages: languages, uri: uri)
|
||||
destroy!
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
# show_reblogs :boolean default(TRUE), not null
|
||||
# uri :string
|
||||
# notify :boolean default(FALSE), not null
|
||||
# languages :string is an Array
|
||||
#
|
||||
|
||||
class FollowRequest < ApplicationRecord
|
||||
|
|
@ -27,9 +28,10 @@ class FollowRequest < ApplicationRecord
|
|||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
validates :languages, language: true
|
||||
|
||||
def authorize!
|
||||
account.follow!(target_account, reblogs: show_reblogs, notify: notify, uri: uri, bypass_limit: true)
|
||||
account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
|
||||
MergeWorker.perform_async(target_account.id, account.id) if account.local?
|
||||
destroy!
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ class Form::AccountBatch
|
|||
include AccountableConcern
|
||||
include Payloadable
|
||||
|
||||
attr_accessor :account_ids, :action, :current_account
|
||||
attr_accessor :account_ids, :action, :current_account,
|
||||
:select_all_matching, :query
|
||||
|
||||
def save
|
||||
case action
|
||||
|
|
@ -60,7 +61,11 @@ class Form::AccountBatch
|
|||
end
|
||||
|
||||
def accounts
|
||||
Account.where(id: account_ids)
|
||||
if select_all_matching?
|
||||
query
|
||||
else
|
||||
Account.where(id: account_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def approve!
|
||||
|
|
@ -101,7 +106,7 @@ class Form::AccountBatch
|
|||
|
||||
def reject_account(account)
|
||||
authorize(account.user, :reject?)
|
||||
log_action(:reject, account.user, username: account.username)
|
||||
log_action(:reject, account.user)
|
||||
account.suspend!(origin: :local)
|
||||
AccountDeletionWorker.perform_async(account.id, { 'reserve_username' => false })
|
||||
end
|
||||
|
|
@ -118,4 +123,8 @@ class Form::AccountBatch
|
|||
log_action(:approve, account.user)
|
||||
account.user.approve!
|
||||
end
|
||||
|
||||
def select_all_matching?
|
||||
select_all_matching == '1'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,25 +8,19 @@ class Form::AdminSettings
|
|||
site_contact_email
|
||||
site_title
|
||||
site_short_description
|
||||
site_description
|
||||
site_extended_description
|
||||
site_terms
|
||||
registrations_mode
|
||||
closed_registrations_message
|
||||
open_deletion
|
||||
timeline_preview
|
||||
show_staff_badge
|
||||
bootstrap_timeline_accounts
|
||||
theme
|
||||
min_invite_role
|
||||
activity_api_enabled
|
||||
peers_api_enabled
|
||||
show_known_fediverse_at_about_page
|
||||
preview_sensitive_media
|
||||
custom_css
|
||||
profile_directory
|
||||
thumbnail
|
||||
hero
|
||||
mascot
|
||||
trends
|
||||
trendable_by_default
|
||||
|
|
@ -34,15 +28,21 @@ class Form::AdminSettings
|
|||
show_domain_blocks_rationale
|
||||
noindex
|
||||
require_invite_text
|
||||
media_cache_retention_period
|
||||
content_cache_retention_period
|
||||
backups_retention_period
|
||||
).freeze
|
||||
|
||||
INTEGER_KEYS = %i(
|
||||
media_cache_retention_period
|
||||
content_cache_retention_period
|
||||
backups_retention_period
|
||||
).freeze
|
||||
|
||||
BOOLEAN_KEYS = %i(
|
||||
open_deletion
|
||||
timeline_preview
|
||||
show_staff_badge
|
||||
activity_api_enabled
|
||||
peers_api_enabled
|
||||
show_known_fediverse_at_about_page
|
||||
preview_sensitive_media
|
||||
profile_directory
|
||||
trends
|
||||
|
|
@ -53,54 +53,65 @@ class Form::AdminSettings
|
|||
|
||||
UPLOAD_KEYS = %i(
|
||||
thumbnail
|
||||
hero
|
||||
mascot
|
||||
).freeze
|
||||
|
||||
attr_accessor(*KEYS)
|
||||
|
||||
validates :site_short_description, :site_description, html: { wrap_with: :p }
|
||||
validates :site_extended_description, :site_terms, :closed_registrations_message, html: true
|
||||
validates :registrations_mode, inclusion: { in: %w(open approved none) }
|
||||
validates :min_invite_role, inclusion: { in: %w(disabled user moderator admin) }
|
||||
validates :site_contact_email, :site_contact_username, presence: true
|
||||
validates :site_contact_username, existing_username: true
|
||||
validates :bootstrap_timeline_accounts, existing_username: { multiple: true }
|
||||
validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }
|
||||
validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }
|
||||
validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) }
|
||||
validates :site_contact_email, :site_contact_username, presence: true, if: -> { defined?(@site_contact_username) || defined?(@site_contact_email) }
|
||||
validates :site_contact_username, existing_username: true, if: -> { defined?(@site_contact_username) }
|
||||
validates :bootstrap_timeline_accounts, existing_username: { multiple: true }, if: -> { defined?(@bootstrap_timeline_accounts) }
|
||||
validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks) }
|
||||
validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) }
|
||||
validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
|
||||
validates :site_short_description, length: { maximum: 200 }, if: -> { defined?(@site_short_description) }
|
||||
|
||||
def initialize(_attributes = {})
|
||||
super
|
||||
initialize_attributes
|
||||
KEYS.each do |key|
|
||||
define_method(key) do
|
||||
return instance_variable_get("@#{key}") if instance_variable_defined?("@#{key}")
|
||||
|
||||
stored_value = begin
|
||||
if UPLOAD_KEYS.include?(key)
|
||||
SiteUpload.where(var: key).first_or_initialize(var: key)
|
||||
else
|
||||
Setting.public_send(key)
|
||||
end
|
||||
end
|
||||
|
||||
instance_variable_set("@#{key}", stored_value)
|
||||
end
|
||||
end
|
||||
|
||||
UPLOAD_KEYS.each do |key|
|
||||
define_method("#{key}=") do |file|
|
||||
value = public_send(key)
|
||||
value.file = file
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
return false unless valid?
|
||||
|
||||
KEYS.each do |key|
|
||||
value = instance_variable_get("@#{key}")
|
||||
next unless instance_variable_defined?("@#{key}")
|
||||
|
||||
if UPLOAD_KEYS.include?(key) && !value.nil?
|
||||
upload = SiteUpload.where(var: key).first_or_initialize(var: key)
|
||||
upload.update(file: value)
|
||||
if UPLOAD_KEYS.include?(key)
|
||||
public_send(key).save
|
||||
else
|
||||
setting = Setting.where(var: key).first_or_initialize(var: key)
|
||||
setting.update(value: typecast_value(key, value))
|
||||
setting.update(value: typecast_value(key, instance_variable_get("@#{key}")))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_attributes
|
||||
KEYS.each do |key|
|
||||
instance_variable_set("@#{key}", Setting.public_send(key)) if instance_variable_get("@#{key}").nil?
|
||||
end
|
||||
end
|
||||
|
||||
def typecast_value(key, value)
|
||||
if BOOLEAN_KEYS.include?(key)
|
||||
value == '1'
|
||||
elsif INTEGER_KEYS.include?(key)
|
||||
value.blank? ? value : Integer(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class Form::Redirect
|
|||
private
|
||||
|
||||
def set_target_account
|
||||
@target_account = ResolveAccountService.new.call(acct)
|
||||
@target_account = ResolveAccountService.new.call(acct, skip_cache: true)
|
||||
rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
|
||||
# Validation will take care of it
|
||||
end
|
||||
|
|
|
|||
34
app/models/form/status_filter_batch_action.rb
Normal file
34
app/models/form/status_filter_batch_action.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::StatusFilterBatchAction
|
||||
include ActiveModel::Model
|
||||
include AccountableConcern
|
||||
include Authorization
|
||||
|
||||
attr_accessor :current_account, :type,
|
||||
:status_filter_ids, :filter_id
|
||||
|
||||
def save!
|
||||
process_action!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_filters
|
||||
filter = current_account.custom_filters.find(filter_id)
|
||||
filter.statuses.where(id: status_filter_ids)
|
||||
end
|
||||
|
||||
def process_action!
|
||||
return if status_filter_ids.empty?
|
||||
|
||||
case type
|
||||
when 'remove'
|
||||
handle_remove!
|
||||
end
|
||||
end
|
||||
|
||||
def handle_remove!
|
||||
status_filters.destroy_all
|
||||
end
|
||||
end
|
||||
|
|
@ -48,6 +48,8 @@ class Instance < ApplicationRecord
|
|||
domain
|
||||
end
|
||||
|
||||
alias to_log_human_identifier to_param
|
||||
|
||||
delegate :exhausted_deliveries_days, to: :delivery_failure_tracker
|
||||
|
||||
def availability_over_days(num_days, end_date = Time.now.utc.to_date)
|
||||
|
|
|
|||
|
|
@ -16,16 +16,23 @@ class IpBlock < ApplicationRecord
|
|||
CACHE_KEY = 'blocked_ips'
|
||||
|
||||
include Expireable
|
||||
include Paginable
|
||||
|
||||
enum severity: {
|
||||
sign_up_requires_approval: 5000,
|
||||
sign_up_block: 5500,
|
||||
no_access: 9999,
|
||||
}
|
||||
|
||||
validates :ip, :severity, presence: true
|
||||
validates :ip, uniqueness: true
|
||||
|
||||
after_commit :reset_cache
|
||||
|
||||
def to_log_human_identifier
|
||||
"#{ip}/#{ip.prefix}"
|
||||
end
|
||||
|
||||
class << self
|
||||
def blocked?(remote_ip)
|
||||
blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) }
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class MediaAttachment < ApplicationRecord
|
|||
MAX_VIDEO_MATRIX_LIMIT = 2_304_000 # 1920x1200px
|
||||
MAX_VIDEO_FRAME_RATE = 60
|
||||
|
||||
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
|
||||
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).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
|
||||
|
||||
|
|
@ -55,10 +55,11 @@ class MediaAttachment < ApplicationRecord
|
|||
small
|
||||
).freeze
|
||||
|
||||
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
|
||||
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif image/heic image/heif image/webp image/avif).freeze
|
||||
IMAGE_CONVERTIBLE_MIME_TYPES = %w(image/heic image/heif).freeze
|
||||
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/vorbis audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
|
||||
AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/vnd.wave audio/ogg audio/vorbis audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
|
||||
|
||||
BLURHASH_OPTIONS = {
|
||||
x_comp: 4,
|
||||
|
|
@ -72,12 +73,22 @@ class MediaAttachment < ApplicationRecord
|
|||
}.freeze,
|
||||
|
||||
small: {
|
||||
pixels: 160_000, # 400x400px
|
||||
pixels: 230_400, # 640x360px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
IMAGE_CONVERTED_STYLES = {
|
||||
original: {
|
||||
format: 'jpeg',
|
||||
}.merge(IMAGE_STYLES[:original]).freeze,
|
||||
|
||||
small: {
|
||||
format: 'jpeg',
|
||||
}.merge(IMAGE_STYLES[:small]).freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_FORMAT = {
|
||||
format: 'mp4',
|
||||
content_type: 'video/mp4',
|
||||
|
|
@ -248,11 +259,11 @@ class MediaAttachment < ApplicationRecord
|
|||
attr_writer :delay_processing
|
||||
|
||||
def delay_processing?
|
||||
@delay_processing
|
||||
@delay_processing && larger_media_format?
|
||||
end
|
||||
|
||||
def delay_processing_for_attachment?(attachment_name)
|
||||
@delay_processing && attachment_name == :file
|
||||
delay_processing? && attachment_name == :file
|
||||
end
|
||||
|
||||
after_commit :enqueue_processing, on: :create
|
||||
|
|
@ -277,6 +288,8 @@ class MediaAttachment < ApplicationRecord
|
|||
def file_styles(attachment)
|
||||
if attachment.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
VIDEO_CONVERTED_STYLES
|
||||
elsif IMAGE_CONVERTIBLE_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
IMAGE_CONVERTED_STYLES
|
||||
elsif IMAGE_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
IMAGE_STYLES
|
||||
elsif VIDEO_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class Notification < ApplicationRecord
|
|||
poll
|
||||
update
|
||||
admin.sign_up
|
||||
admin.report
|
||||
).freeze
|
||||
|
||||
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
||||
|
|
@ -46,6 +47,7 @@ class Notification < ApplicationRecord
|
|||
favourite: [favourite: :status],
|
||||
poll: [poll: :status],
|
||||
update: :status,
|
||||
'admin.report': [report: :target_account],
|
||||
}.freeze
|
||||
|
||||
belongs_to :account, optional: true
|
||||
|
|
@ -58,6 +60,7 @@ class Notification < ApplicationRecord
|
|||
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :report, foreign_key: 'activity_id', optional: true
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
|
|
@ -146,7 +149,7 @@ class Notification < ApplicationRecord
|
|||
return unless new_record?
|
||||
|
||||
case activity_type
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
|
||||
self.from_account_id = activity&.account_id
|
||||
when 'Mention'
|
||||
self.from_account_id = activity&.status&.account_id
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
class PreviewCard < ApplicationRecord
|
||||
include Attachmentable
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 1.megabytes
|
||||
|
||||
BLURHASH_OPTIONS = {
|
||||
|
|
@ -48,6 +48,7 @@ class PreviewCard < ApplicationRecord
|
|||
enum link_type: [:unknown, :article]
|
||||
|
||||
has_and_belongs_to_many :statuses
|
||||
has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
|
||||
|
||||
has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }, validate_media_type: false
|
||||
|
||||
|
|
|
|||
17
app/models/preview_card_trend.rb
Normal file
17
app/models/preview_card_trend.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: preview_card_trends
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# preview_card_id :bigint(8) not null
|
||||
# score :float default(0.0), not null
|
||||
# rank :integer default(0), not null
|
||||
# allowed :boolean default(FALSE), not null
|
||||
# language :string
|
||||
#
|
||||
class PreviewCardTrend < ApplicationRecord
|
||||
belongs_to :preview_card
|
||||
scope :allowed, -> { where(allowed: true) }
|
||||
end
|
||||
77
app/models/privacy_policy.rb
Normal file
77
app/models/privacy_policy.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PrivacyPolicy < ActiveModelSerializers::Model
|
||||
DEFAULT_PRIVACY_POLICY = <<~TXT
|
||||
This privacy policy describes how %{domain} ("%{domain}", "we", "us") collects, protects and uses the personally identifiable information you may provide through the %{domain} website or its API. The policy also describes the choices available to you regarding our use of your personal information and how you can access and update this information. This policy does not apply to the practices of companies that %{domain} does not own or control, or to individuals that %{domain} does not employ or manage.
|
||||
|
||||
# What information do we collect?
|
||||
|
||||
- **Basic account information**: If you register on this server, you may be asked to enter a username, an e-mail address and a password. You may also enter additional profile information such as a display name and biography, and upload a profile picture and header image. The username, display name, biography, profile picture and header image are always listed publicly.
|
||||
- **Posts, following and other public information**: The list of people you follow is listed publicly, the same is true for your followers. When you submit a message, the date and time is stored as well as the application you submitted the message from. Messages may contain media attachments, such as pictures and videos. Public and unlisted posts are available publicly. When you feature a post on your profile, that is also publicly available information. Your posts are delivered to your followers, in some cases it means they are delivered to different servers and copies are stored there. When you delete posts, this is likewise delivered to your followers. The action of reblogging or favouriting another post is always public.
|
||||
- **Direct and followers-only posts**: All posts are stored and processed on the server. Followers-only posts are delivered to your followers and users who are mentioned in them, and direct posts are delivered only to users mentioned in them. In some cases it means they are delivered to different servers and copies are stored there. We make a good faith effort to limit the access to those posts only to authorized persons, but other servers may fail to do so. Therefore it's important to review servers your followers belong to. You may toggle an option to approve and reject new followers manually in the settings. **Please keep in mind that the operators of the server and any receiving server may view such messages**, and that recipients may screenshot, copy or otherwise re-share them. **Do not share any sensitive information over Mastodon.**
|
||||
- **IPs and other metadata**: When you log in, we record the IP address you log in from, as well as the name of your browser application. All the logged in sessions are available for your review and revocation in the settings. The latest IP address used is stored for up to 12 months. We also may retain server logs which include the IP address of every request to our server.
|
||||
|
||||
# What do we use your information for?
|
||||
|
||||
Any of the information we collect from you may be used in the following ways:
|
||||
|
||||
- To provide the core functionality of Mastodon. You can only interact with other people's content and post your own content when you are logged in. For example, you may follow other people to view their combined posts in your own personalized home timeline.
|
||||
- To aid moderation of the community, for example comparing your IP address with other known ones to determine ban evasion or other violations.
|
||||
- The email address you provide may be used to send you information, notifications about other people interacting with your content or sending you messages, and to respond to inquiries, and/or other requests or questions.
|
||||
|
||||
# How do we protect your information?
|
||||
|
||||
We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information. Among other things, your browser session, as well as the traffic between your applications and the API, are secured with SSL, and your password is hashed using a strong one-way algorithm. You may enable two-factor authentication to further secure access to your account.
|
||||
|
||||
# What is our data retention policy?
|
||||
|
||||
We will make a good faith effort to:
|
||||
|
||||
- Retain server logs containing the IP address of all requests to this server, in so far as such logs are kept, no more than 90 days.
|
||||
- Retain the IP addresses associated with registered users no more than 12 months.
|
||||
|
||||
You can request and download an archive of your content, including your posts, media attachments, profile picture, and header image.
|
||||
|
||||
You may irreversibly delete your account at any time.
|
||||
|
||||
# Do we use cookies?
|
||||
|
||||
Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account.
|
||||
|
||||
We use cookies to understand and save your preferences for future visits.
|
||||
|
||||
# Do we disclose any information to outside parties?
|
||||
|
||||
We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety.
|
||||
|
||||
Your public content may be downloaded by other servers in the network. Your public and followers-only posts are delivered to the servers where your followers reside, and direct messages are delivered to the servers of the recipients, in so far as those followers or recipients reside on a different server than this.
|
||||
|
||||
When you authorize an application to use your account, depending on the scope of permissions you approve, it may access your public profile information, your following list, your followers, your lists, all your posts, and your favourites. Applications can never access your e-mail address or password.
|
||||
|
||||
# Site usage by children
|
||||
|
||||
If this server is in the EU or the EEA: Our site, products and services are all directed to people who are at least 16 years old. If you are under the age of 16, per the requirements of the GDPR (General Data Protection Regulation) do not use this site.
|
||||
|
||||
If this server is in the USA: Our site, products and services are all directed to people who are at least 13 years old. If you are under the age of 13, per the requirements of COPPA (Children's Online Privacy Protection Act) do not use this site.
|
||||
|
||||
Law requirements can be different if this server is in another jurisdiction.
|
||||
|
||||
___
|
||||
|
||||
This document is CC-BY-SA. Originally adapted from the [Discourse privacy policy](https://github.com/discourse/discourse).
|
||||
TXT
|
||||
|
||||
DEFAULT_UPDATED_AT = DateTime.new(2022, 10, 7).freeze
|
||||
|
||||
attributes :updated_at, :text
|
||||
|
||||
def self.current
|
||||
custom = Setting.find_by(var: 'site_terms')
|
||||
|
||||
if custom&.value.present?
|
||||
new(text: custom.value, updated_at: custom.updated_at)
|
||||
else
|
||||
new(text: DEFAULT_PRIVACY_POLICY, updated_at: DEFAULT_UPDATED_AT)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -8,6 +8,7 @@ class PublicFeed
|
|||
# @option [Boolean] :local
|
||||
# @option [Boolean] :remote
|
||||
# @option [Boolean] :only_media
|
||||
# @option [String] :locale
|
||||
def initialize(account, options = {})
|
||||
@account = account
|
||||
@options = options
|
||||
|
|
@ -27,6 +28,7 @@ class PublicFeed
|
|||
scope.merge!(remote_only_scope) if remote_only?
|
||||
scope.merge!(account_filters_scope) if account?
|
||||
scope.merge!(media_only_scope) if media_only?
|
||||
scope.merge!(language_scope)
|
||||
|
||||
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
|
||||
end
|
||||
|
|
@ -83,10 +85,19 @@ class PublicFeed
|
|||
Status.joins(:media_attachments).group(:id)
|
||||
end
|
||||
|
||||
def language_scope
|
||||
if account&.chosen_languages.present?
|
||||
Status.where(language: account.chosen_languages)
|
||||
elsif @options[:locale].present?
|
||||
Status.where(language: @options[:locale])
|
||||
else
|
||||
Status.all
|
||||
end
|
||||
end
|
||||
|
||||
def account_filters_scope
|
||||
Status.not_excluded_by_account(account).tap do |scope|
|
||||
scope.merge!(Status.not_domain_blocked_by_account(account)) unless local_only?
|
||||
scope.merge!(Status.in_chosen_languages(account)) if account.chosen_languages.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class Report < ApplicationRecord
|
|||
belongs_to :assigned_account, class_name: 'Account', optional: true
|
||||
|
||||
has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy
|
||||
has_many :notifications, as: :activity, dependent: :destroy
|
||||
|
||||
scope :unresolved, -> { where(action_taken_at: nil) }
|
||||
scope :resolved, -> { where.not(action_taken_at: nil) }
|
||||
|
|
@ -55,6 +56,8 @@ class Report < ApplicationRecord
|
|||
|
||||
before_validation :set_uri, only: :create
|
||||
|
||||
after_create_commit :trigger_webhooks
|
||||
|
||||
def object_type
|
||||
:flag
|
||||
end
|
||||
|
|
@ -113,6 +116,10 @@ class Report < ApplicationRecord
|
|||
Report.where.not(id: id).where(target_account_id: target_account_id).unresolved.exists?
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
id
|
||||
end
|
||||
|
||||
def history
|
||||
subquery = [
|
||||
Admin::ActionLog.where(
|
||||
|
|
@ -134,6 +141,8 @@ class Report < ApplicationRecord
|
|||
Admin::ActionLog.from(Arel::Nodes::As.new(subquery, Admin::ActionLog.arel_table))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_uri
|
||||
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil? && account.local?
|
||||
end
|
||||
|
|
@ -143,4 +152,8 @@ class Report < ApplicationRecord
|
|||
|
||||
errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids&.size
|
||||
end
|
||||
|
||||
def trigger_webhooks
|
||||
TriggerWebhookWorker.perform_async('report.created', 'Report', id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,10 +12,35 @@
|
|||
# meta :json
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# blurhash :string
|
||||
#
|
||||
|
||||
class SiteUpload < ApplicationRecord
|
||||
has_attached_file :file
|
||||
include Attachmentable
|
||||
|
||||
STYLES = {
|
||||
thumbnail: {
|
||||
'@1x': {
|
||||
format: 'png',
|
||||
geometry: '1200x630#',
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: {
|
||||
x_comp: 4,
|
||||
y_comp: 4,
|
||||
}.freeze,
|
||||
},
|
||||
|
||||
'@2x': {
|
||||
format: 'png',
|
||||
geometry: '2400x1260#',
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
|
||||
mascot: {}.freeze,
|
||||
}.freeze
|
||||
|
||||
has_attached_file :file, styles: ->(file) { STYLES[file.instance.var.to_sym] }, convert_options: { all: '-coalesce -strip' }, processors: [:lazy_thumbnail, :blurhash_transcoder, :type_corrector]
|
||||
|
||||
validates_attachment_content_type :file, content_type: /\Aimage\/.*\z/
|
||||
validates :file, presence: true
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class Status < ApplicationRecord
|
|||
has_one :notification, as: :activity, dependent: :destroy
|
||||
has_one :status_stat, inverse_of: :status
|
||||
has_one :poll, inverse_of: :status, dependent: :destroy
|
||||
has_one :trend, class_name: 'StatusTrend', inverse_of: :status
|
||||
|
||||
validates :uri, uniqueness: true, presence: true, unless: :local?
|
||||
validates :text, presence: true, unless: -> { with_media? || reblog? }
|
||||
|
|
@ -95,7 +96,6 @@ class Status < ApplicationRecord
|
|||
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
|
||||
scope :with_public_visibility, -> { where(visibility: :public) }
|
||||
scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
|
||||
scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) }
|
||||
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
|
||||
scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
|
||||
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
|
||||
|
|
@ -166,6 +166,14 @@ class Status < ApplicationRecord
|
|||
].compact.join("\n\n")
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
account.acct
|
||||
end
|
||||
|
||||
def to_log_permalink
|
||||
ActivityPub::TagManager.instance.uri_for(self)
|
||||
end
|
||||
|
||||
def reply?
|
||||
!in_reply_to_id.nil? || attributes['reply']
|
||||
end
|
||||
|
|
@ -432,6 +440,12 @@ class Status < ApplicationRecord
|
|||
im
|
||||
end
|
||||
|
||||
def discard_with_reblogs
|
||||
discard_time = Time.current
|
||||
Status.unscoped.where(reblog_of_id: id, deleted_at: [nil, deleted_at]).in_batches.update_all(deleted_at: discard_time) unless reblog?
|
||||
update_attribute(:deleted_at, discard_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_status_stat!(attrs)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class StatusEdit < ApplicationRecord
|
|||
:preview_remote_url, :text_url, :meta, :blurhash,
|
||||
:not_processed?, :needs_redownload?, :local?,
|
||||
:file, :thumbnail, :thumbnail_remote_url,
|
||||
:shortcode, to: :media_attachment
|
||||
:shortcode, :video?, :audio?, to: :media_attachment
|
||||
end
|
||||
|
||||
rate_limit by: :account, family: :statuses
|
||||
|
|
@ -40,7 +40,8 @@ class StatusEdit < ApplicationRecord
|
|||
|
||||
default_scope { order(id: :asc) }
|
||||
|
||||
delegate :local?, to: :status
|
||||
delegate :local?, :application, :edited?, :edited_at,
|
||||
:discarded?, :visibility, to: :status
|
||||
|
||||
def emojis
|
||||
return @emojis if defined?(@emojis)
|
||||
|
|
@ -59,4 +60,12 @@ class StatusEdit < ApplicationRecord
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def proper
|
||||
self
|
||||
end
|
||||
|
||||
def reblog?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
|
|||
21
app/models/status_trend.rb
Normal file
21
app/models/status_trend.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: status_trends
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_id :bigint(8) not null
|
||||
# account_id :bigint(8) not null
|
||||
# score :float default(0.0), not null
|
||||
# rank :integer default(0), not null
|
||||
# allowed :boolean default(FALSE), not null
|
||||
# language :string
|
||||
#
|
||||
|
||||
class StatusTrend < ApplicationRecord
|
||||
belongs_to :status
|
||||
belongs_to :account
|
||||
|
||||
scope :allowed, -> { joins('INNER JOIN (SELECT account_id, MAX(score) AS max_score FROM status_trends GROUP BY account_id) AS grouped_status_trends ON status_trends.account_id = grouped_status_trends.account_id AND status_trends.score = grouped_status_trends.max_score').where(allowed: true) }
|
||||
end
|
||||
|
|
@ -15,20 +15,25 @@
|
|||
# last_status_at :datetime
|
||||
# max_score :float
|
||||
# max_score_at :datetime
|
||||
# display_name :string
|
||||
#
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
has_and_belongs_to_many :statuses
|
||||
has_and_belongs_to_many :accounts
|
||||
|
||||
has_many :passive_relationships, class_name: 'TagFollow', inverse_of: :tag, dependent: :destroy
|
||||
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
|
||||
has_many :followers, through: :passive_relationships, source: :account
|
||||
|
||||
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, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
|
||||
validates :display_name, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
|
||||
validate :validate_name_change, if: -> { !new_record? && name_changed? }
|
||||
validate :validate_display_name_change, if: -> { !new_record? && display_name_changed? }
|
||||
|
||||
scope :reviewed, -> { where.not(reviewed_at: nil) }
|
||||
scope :unreviewed, -> { where(reviewed_at: nil) }
|
||||
|
|
@ -46,6 +51,10 @@ class Tag < ApplicationRecord
|
|||
name
|
||||
end
|
||||
|
||||
def display_name
|
||||
attributes['display_name'] || name
|
||||
end
|
||||
|
||||
def usable
|
||||
boolean_with_default('usable', true)
|
||||
end
|
||||
|
|
@ -90,8 +99,10 @@ class Tag < ApplicationRecord
|
|||
|
||||
class << self
|
||||
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)
|
||||
names = Array(name_or_names).map { |str| [normalize(str), str] }.uniq(&:first)
|
||||
|
||||
names.map do |(normalized_name, display_name)|
|
||||
tag = matching_name(normalized_name).first || create(name: normalized_name, display_name: display_name.gsub(/[^[:alnum:]#{HASHTAG_SEPARATORS}]/, ''))
|
||||
|
||||
yield tag if block_given?
|
||||
|
||||
|
|
@ -129,7 +140,7 @@ class Tag < ApplicationRecord
|
|||
end
|
||||
|
||||
def normalize(str)
|
||||
str.gsub(/\A#/, '')
|
||||
HashtagNormalizer.new.normalize(str)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -138,4 +149,8 @@ class Tag < ApplicationRecord
|
|||
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
|
||||
|
||||
def validate_display_name_change
|
||||
errors.add(:display_name, I18n.t('tags.does_not_match_previous_name')) unless HashtagNormalizer.new.normalize(display_name).casecmp(name.mb_chars).zero?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class TagFeed < PublicFeed
|
|||
# @option [Boolean] :local
|
||||
# @option [Boolean] :remote
|
||||
# @option [Boolean] :only_media
|
||||
# @option [String] :locale
|
||||
def initialize(tag, account, options = {})
|
||||
@tag = tag
|
||||
super(account, options)
|
||||
|
|
|
|||
24
app/models/tag_follow.rb
Normal file
24
app/models/tag_follow.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tag_follows
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# tag_id :bigint(8) not null
|
||||
# account_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class TagFollow < ApplicationRecord
|
||||
include RateLimitable
|
||||
include Paginable
|
||||
|
||||
belongs_to :tag
|
||||
belongs_to :account
|
||||
|
||||
accepts_nested_attributes_for :tag
|
||||
|
||||
rate_limit by: :account, family: :follows
|
||||
end
|
||||
|
|
@ -26,7 +26,7 @@ module Trends
|
|||
end
|
||||
|
||||
def self.request_review!
|
||||
return unless enabled?
|
||||
return if skip_review? || !enabled?
|
||||
|
||||
links_requiring_review = links.request_review
|
||||
tags_requiring_review = tags.request_review
|
||||
|
|
@ -34,7 +34,7 @@ module Trends
|
|||
|
||||
return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
|
||||
|
||||
User.staff.includes(:account).find_each do |user|
|
||||
User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
|
||||
AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
|
||||
end
|
||||
end
|
||||
|
|
@ -43,6 +43,10 @@ module Trends
|
|||
Setting.trends
|
||||
end
|
||||
|
||||
def self.skip_review?
|
||||
Setting.trendable_by_default
|
||||
end
|
||||
|
||||
def self.available_locales
|
||||
@available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq
|
||||
end
|
||||
|
|
|
|||
|
|
@ -98,4 +98,8 @@ class Trends::Base
|
|||
pipeline.rename(from_key, to_key)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_review?
|
||||
Setting.trendable_by_default
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,40 @@ class Trends::Links < Trends::Base
|
|||
decay_threshold: 1,
|
||||
}
|
||||
|
||||
class Query < Trends::Query
|
||||
def filtered_for!(account)
|
||||
@account = account
|
||||
self
|
||||
end
|
||||
|
||||
def filtered_for(account)
|
||||
clone.filtered_for!(account)
|
||||
end
|
||||
|
||||
def to_arel
|
||||
scope = PreviewCard.joins(:trend).reorder(score: :desc)
|
||||
scope = scope.reorder(language_order_clause.desc, score: :desc) if preferred_languages.present?
|
||||
scope = scope.merge(PreviewCardTrend.allowed) if @allowed
|
||||
scope = scope.offset(@offset) if @offset.present?
|
||||
scope = scope.limit(@limit) if @limit.present?
|
||||
scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def language_order_clause
|
||||
Arel::Nodes::Case.new.when(PreviewCardTrend.arel_table[:language].in(preferred_languages)).then(1).else(0)
|
||||
end
|
||||
|
||||
def preferred_languages
|
||||
if @account&.chosen_languages.present?
|
||||
@account.chosen_languages
|
||||
else
|
||||
@locale
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def register(status, at_time = Time.now.utc)
|
||||
original_status = status.proper
|
||||
|
||||
|
|
@ -28,24 +62,33 @@ class Trends::Links < Trends::Base
|
|||
record_used_id(preview_card.id, at_time)
|
||||
end
|
||||
|
||||
def query
|
||||
Query.new(key_prefix, klass)
|
||||
end
|
||||
|
||||
def refresh(at_time = Time.now.utc)
|
||||
preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
|
||||
preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + PreviewCardTrend.pluck(:preview_card_id)).uniq)
|
||||
calculate_scores(preview_cards, at_time)
|
||||
end
|
||||
|
||||
def request_review
|
||||
preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
|
||||
PreviewCardTrend.pluck('distinct language').flat_map do |language|
|
||||
score_at_threshold = PreviewCardTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0
|
||||
preview_card_trends = PreviewCardTrend.where(language: language, allowed: false).joins(:preview_card)
|
||||
|
||||
preview_cards.filter_map do |preview_card|
|
||||
next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
|
||||
preview_card_trends.filter_map do |trend|
|
||||
preview_card = trend.preview_card
|
||||
|
||||
if preview_card.provider.nil?
|
||||
preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc)
|
||||
else
|
||||
preview_card.provider.touch(:requested_review_at)
|
||||
next unless trend.score > score_at_threshold && !preview_card.trendable? && preview_card.requires_review_notification?
|
||||
|
||||
if preview_card.provider.nil?
|
||||
preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc)
|
||||
else
|
||||
preview_card.provider.touch(:requested_review_at)
|
||||
end
|
||||
|
||||
preview_card
|
||||
end
|
||||
|
||||
preview_card
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -62,10 +105,7 @@ class Trends::Links < Trends::Base
|
|||
private
|
||||
|
||||
def calculate_scores(preview_cards, at_time)
|
||||
global_items = []
|
||||
locale_items = Hash.new { |h, key| h[key] = [] }
|
||||
|
||||
preview_cards.each do |preview_card|
|
||||
items = preview_cards.map do |preview_card|
|
||||
expected = preview_card.history.get(at_time - 1.day).accounts.to_f
|
||||
expected = 1.0 if expected.zero?
|
||||
observed = preview_card.history.get(at_time).accounts.to_f
|
||||
|
|
@ -89,26 +129,24 @@ class Trends::Links < Trends::Base
|
|||
preview_card.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) / options[:max_score_halflife].to_f))
|
||||
decaying_score = begin
|
||||
if max_score.zero? || !valid_locale?(preview_card.language)
|
||||
0
|
||||
else
|
||||
max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
||||
end
|
||||
end
|
||||
|
||||
next unless decaying_score >= options[:decay_threshold]
|
||||
|
||||
global_items << { score: decaying_score, item: preview_card }
|
||||
locale_items[preview_card.language] << { score: decaying_score, item: preview_card } if valid_locale?(preview_card.language)
|
||||
[decaying_score, preview_card]
|
||||
end
|
||||
|
||||
replace_items('', global_items)
|
||||
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
|
||||
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
|
||||
|
||||
Trends.available_locales.each do |locale|
|
||||
replace_items(":#{locale}", locale_items[locale])
|
||||
PreviewCardTrend.transaction do
|
||||
PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any?
|
||||
PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any?
|
||||
PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id')
|
||||
end
|
||||
end
|
||||
|
||||
def filter_for_allowed_items(items)
|
||||
items.select { |item| item[:item].trendable? }
|
||||
end
|
||||
|
||||
def would_be_trending?(id)
|
||||
score(id) > score_at_rank(options[:review_threshold] - 1)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ class Trends::PreviewCardFilter
|
|||
end
|
||||
|
||||
def results
|
||||
scope = PreviewCard.unscoped
|
||||
scope = initial_scope
|
||||
|
||||
params.each do |key, value|
|
||||
next if %w(page locale).include?(key.to_s)
|
||||
next if %w(page).include?(key.to_s)
|
||||
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||
end
|
||||
|
|
@ -26,21 +26,30 @@ class Trends::PreviewCardFilter
|
|||
|
||||
private
|
||||
|
||||
def initial_scope
|
||||
PreviewCard.select(PreviewCard.arel_table[Arel.star])
|
||||
.joins(:trend)
|
||||
.eager_load(:trend)
|
||||
.reorder(score: :desc)
|
||||
end
|
||||
|
||||
def scope_for(key, value)
|
||||
case key.to_s
|
||||
when 'trending'
|
||||
trending_scope(value)
|
||||
when 'locale'
|
||||
PreviewCardTrend.where(language: value)
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
def trending_scope(value)
|
||||
scope = Trends.links.query
|
||||
|
||||
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
||||
scope = scope.allowed if value == 'allowed'
|
||||
|
||||
scope.to_arel
|
||||
case value
|
||||
when 'allowed'
|
||||
PreviewCardTrend.allowed
|
||||
else
|
||||
PreviewCardTrend.all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class Trends::StatusBatch
|
|||
end
|
||||
|
||||
def approve!
|
||||
statuses.each { |status| authorize(status, :review?) }
|
||||
statuses.each { |status| authorize([:admin, status], :review?) }
|
||||
statuses.update_all(trendable: true)
|
||||
end
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ class Trends::StatusBatch
|
|||
end
|
||||
|
||||
def reject!
|
||||
statuses.each { |status| authorize(status, :review?) }
|
||||
statuses.each { |status| authorize([:admin, status], :review?) }
|
||||
statuses.update_all(trendable: false)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ class Trends::StatusFilter
|
|||
end
|
||||
|
||||
def results
|
||||
scope = Status.unscoped.kept
|
||||
scope = initial_scope
|
||||
|
||||
params.each do |key, value|
|
||||
next if %w(page locale).include?(key.to_s)
|
||||
next if %w(page).include?(key.to_s)
|
||||
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||
end
|
||||
|
|
@ -26,21 +26,30 @@ class Trends::StatusFilter
|
|||
|
||||
private
|
||||
|
||||
def initial_scope
|
||||
Status.select(Status.arel_table[Arel.star])
|
||||
.joins(:trend)
|
||||
.eager_load(:trend)
|
||||
.reorder(score: :desc)
|
||||
end
|
||||
|
||||
def scope_for(key, value)
|
||||
case key.to_s
|
||||
when 'trending'
|
||||
trending_scope(value)
|
||||
when 'locale'
|
||||
StatusTrend.where(language: value)
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
def trending_scope(value)
|
||||
scope = Trends.statuses.query
|
||||
|
||||
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
||||
scope = scope.allowed if value == 'allowed'
|
||||
|
||||
scope.to_arel
|
||||
case value
|
||||
when 'allowed'
|
||||
StatusTrend.allowed
|
||||
else
|
||||
StatusTrend.all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,13 +20,27 @@ class Trends::Statuses < Trends::Base
|
|||
clone.filtered_for!(account)
|
||||
end
|
||||
|
||||
def to_arel
|
||||
scope = Status.joins(:trend).reorder(score: :desc)
|
||||
scope = scope.reorder(language_order_clause.desc, score: :desc) if preferred_languages.present?
|
||||
scope = scope.merge(StatusTrend.allowed) if @allowed
|
||||
scope = scope.not_excluded_by_account(@account).not_domain_blocked_by_account(@account) if @account.present?
|
||||
scope = scope.offset(@offset) if @offset.present?
|
||||
scope = scope.limit(@limit) if @limit.present?
|
||||
scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_scopes(scope)
|
||||
if @account.nil?
|
||||
scope
|
||||
def language_order_clause
|
||||
Arel::Nodes::Case.new.when(StatusTrend.arel_table[:language].in(preferred_languages)).then(1).else(0)
|
||||
end
|
||||
|
||||
def preferred_languages
|
||||
if @account&.chosen_languages.present?
|
||||
@account.chosen_languages
|
||||
else
|
||||
scope.not_excluded_by_account(@account).not_domain_blocked_by_account(@account)
|
||||
@locale
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -36,9 +50,6 @@ class Trends::Statuses < Trends::Base
|
|||
end
|
||||
|
||||
def add(status, _account_id, at_time = Time.now.utc)
|
||||
# We rely on the total reblogs and favourites count, so we
|
||||
# don't record which account did the what and when here
|
||||
|
||||
record_used_id(status.id, at_time)
|
||||
end
|
||||
|
||||
|
|
@ -47,18 +58,23 @@ class Trends::Statuses < Trends::Base
|
|||
end
|
||||
|
||||
def refresh(at_time = Time.now.utc)
|
||||
statuses = Status.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq).includes(:account, :media_attachments)
|
||||
statuses = Status.where(id: (recently_used_ids(at_time) + StatusTrend.pluck(:status_id)).uniq).includes(:status_stat, :account)
|
||||
calculate_scores(statuses, at_time)
|
||||
end
|
||||
|
||||
def request_review
|
||||
statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account)
|
||||
StatusTrend.pluck('distinct language').flat_map do |language|
|
||||
score_at_threshold = StatusTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0
|
||||
status_trends = StatusTrend.where(language: language, allowed: false).joins(:status).includes(status: :account)
|
||||
|
||||
statuses.filter_map do |status|
|
||||
next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification?
|
||||
status_trends.filter_map do |trend|
|
||||
status = trend.status
|
||||
|
||||
status.account.touch(:requested_review_at)
|
||||
status
|
||||
if trend.score > score_at_threshold && !status.trendable? && status.requires_review_notification?
|
||||
status.account.touch(:requested_review_at)
|
||||
status
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -75,14 +91,11 @@ class Trends::Statuses < Trends::Base
|
|||
private
|
||||
|
||||
def eligible?(status)
|
||||
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply?
|
||||
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
|
||||
end
|
||||
|
||||
def calculate_scores(statuses, at_time)
|
||||
global_items = []
|
||||
locale_items = Hash.new { |h, key| h[key] = [] }
|
||||
|
||||
statuses.each do |status|
|
||||
items = statuses.map do |status|
|
||||
expected = 1.0
|
||||
observed = (status.reblogs_count + status.favourites_count).to_f
|
||||
|
||||
|
|
@ -94,29 +107,24 @@ class Trends::Statuses < Trends::Base
|
|||
end
|
||||
end
|
||||
|
||||
decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
|
||||
decaying_score = begin
|
||||
if score.zero? || !eligible?(status)
|
||||
0
|
||||
else
|
||||
score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
|
||||
end
|
||||
end
|
||||
|
||||
next unless decaying_score >= options[:decay_threshold]
|
||||
|
||||
global_items << { score: decaying_score, item: status }
|
||||
locale_items[status.language] << { account_id: status.account_id, score: decaying_score, item: status } if valid_locale?(status.language)
|
||||
[decaying_score, status]
|
||||
end
|
||||
|
||||
replace_items('', global_items)
|
||||
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
|
||||
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
|
||||
|
||||
Trends.available_locales.each do |locale|
|
||||
replace_items(":#{locale}", locale_items[locale])
|
||||
StatusTrend.transaction do
|
||||
StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any?
|
||||
StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any?
|
||||
StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id')
|
||||
end
|
||||
end
|
||||
|
||||
def filter_for_allowed_items(items)
|
||||
# Show only one status per account, pick the one with the highest score
|
||||
# that's also eligible to trend
|
||||
|
||||
items.group_by { |item| item[:account_id] }.values.filter_map { |account_items| account_items.select { |item| item[:item].trendable? && item[:item].account.discoverable? }.max_by { |item| item[:score] } }
|
||||
end
|
||||
|
||||
def would_be_trending?(id)
|
||||
score(id) > score_at_rank(options[:review_threshold] - 1)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ class UnavailableDomain < ApplicationRecord
|
|||
|
||||
after_commit :reset_cache!
|
||||
|
||||
def to_log_human_identifier
|
||||
domain
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reset_cache!
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
# otp_required_for_login :boolean default(FALSE), not null
|
||||
# last_emailed_at :datetime
|
||||
# otp_backup_codes :string is an Array
|
||||
# filtered_languages :string default([]), not null, is an Array
|
||||
# account_id :bigint(8) not null
|
||||
# disabled :boolean default(FALSE), not null
|
||||
# moderator :boolean default(FALSE), not null
|
||||
|
|
@ -38,7 +37,7 @@
|
|||
# sign_in_token_sent_at :datetime
|
||||
# webauthn_id :string
|
||||
# sign_up_ip :inet
|
||||
# skip_sign_in_token :boolean
|
||||
# role_id :bigint(8)
|
||||
#
|
||||
|
||||
class User < ApplicationRecord
|
||||
|
|
@ -48,10 +47,10 @@ class User < ApplicationRecord
|
|||
current_sign_in_ip
|
||||
last_sign_in_ip
|
||||
skip_sign_in_token
|
||||
filtered_languages
|
||||
)
|
||||
|
||||
include Settings::Extend
|
||||
include UserRoles
|
||||
include Redisable
|
||||
include LanguagesHelper
|
||||
|
||||
|
|
@ -80,6 +79,7 @@ class User < ApplicationRecord
|
|||
belongs_to :account, inverse_of: :user
|
||||
belongs_to :invite, counter_cache: :uses, optional: true
|
||||
belongs_to :created_by_application, class_name: 'Doorkeeper::Application', optional: true
|
||||
belongs_to :role, class_name: 'UserRole', optional: true
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
|
||||
|
|
@ -94,7 +94,7 @@ class User < ApplicationRecord
|
|||
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
|
||||
|
||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
||||
validates_with BlacklistedEmailValidator, if: -> { !confirmed? }
|
||||
validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
|
||||
validates_with EmailMxValidator, if: :validate_email_dns?
|
||||
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
||||
|
||||
|
|
@ -104,6 +104,7 @@ class User < ApplicationRecord
|
|||
validates_with RegistrationFormTimeValidator, on: :create
|
||||
validates :website, absence: true, on: :create
|
||||
validates :confirm_password, absence: true, on: :create
|
||||
validate :validate_role_elevation
|
||||
|
||||
scope :recent, -> { order(id: :desc) }
|
||||
scope :pending, -> { where(approved: false) }
|
||||
|
|
@ -118,8 +119,10 @@ class User < ApplicationRecord
|
|||
scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
|
||||
|
||||
before_validation :sanitize_languages
|
||||
before_validation :sanitize_role
|
||||
before_create :set_approved
|
||||
after_commit :send_pending_devise_notifications
|
||||
after_create_commit :trigger_webhooks
|
||||
|
||||
# This avoids a deprecation warning from Rails 5.1
|
||||
# It seems possible that a future release of devise-two-factor will
|
||||
|
|
@ -135,8 +138,28 @@ class User < ApplicationRecord
|
|||
:disable_swiping, :always_send_emails,
|
||||
to: :settings, prefix: :setting, allow_nil: false
|
||||
|
||||
delegate :can?, to: :role
|
||||
|
||||
attr_reader :invite_code
|
||||
attr_writer :external, :bypass_invite_request_check
|
||||
attr_writer :external, :bypass_invite_request_check, :current_account
|
||||
|
||||
def self.those_who_can(*any_of_privileges)
|
||||
matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id)
|
||||
|
||||
if matching_role_ids.empty?
|
||||
none
|
||||
else
|
||||
where(role_id: matching_role_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def role
|
||||
if role_id.nil?
|
||||
UserRole.everyone
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def confirmed?
|
||||
confirmed_at.present?
|
||||
|
|
@ -158,6 +181,14 @@ class User < ApplicationRecord
|
|||
update!(disabled: false)
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
account.acct
|
||||
end
|
||||
|
||||
def to_log_route_param
|
||||
account_id
|
||||
end
|
||||
|
||||
def confirm
|
||||
new_user = !confirmed?
|
||||
self.approved = true if open_registrations? && !sign_up_from_ip_requires_approval?
|
||||
|
|
@ -182,7 +213,9 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def update_sign_in!(new_sign_in: false)
|
||||
old_current, new_current = current_sign_in_at, Time.now.utc
|
||||
old_current = current_sign_in_at
|
||||
new_current = Time.now.utc
|
||||
|
||||
self.last_sign_in_at = old_current || new_current
|
||||
self.current_sign_in_at = new_current
|
||||
|
||||
|
|
@ -248,18 +281,18 @@ class User < ApplicationRecord
|
|||
save!
|
||||
end
|
||||
|
||||
def prefers_noindex?
|
||||
setting_noindex
|
||||
end
|
||||
|
||||
def preferred_posting_language
|
||||
valid_locale_cascade(settings.default_language, locale)
|
||||
valid_locale_cascade(settings.default_language, locale, I18n.locale)
|
||||
end
|
||||
|
||||
def setting_default_privacy
|
||||
settings.default_privacy || (account.locked? ? 'private' : 'public')
|
||||
end
|
||||
|
||||
def allows_digest_emails?
|
||||
settings.notification_emails['digest']
|
||||
end
|
||||
|
||||
def allows_report_emails?
|
||||
settings.notification_emails['report']
|
||||
end
|
||||
|
|
@ -439,6 +472,11 @@ class User < ApplicationRecord
|
|||
self.chosen_languages = nil if chosen_languages.empty?
|
||||
end
|
||||
|
||||
def sanitize_role
|
||||
return if role.nil?
|
||||
self.role = nil if role.everyone?
|
||||
end
|
||||
|
||||
def prepare_new_user!
|
||||
BootstrapTimelineWorker.perform_async(account_id)
|
||||
ActivityTracker.increment('activity:accounts:local')
|
||||
|
|
@ -451,7 +489,7 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def notify_staff_about_pending_account!
|
||||
User.staff.includes(:account).find_each do |u|
|
||||
User.those_who_can(:manage_users).includes(:account).find_each do |u|
|
||||
next unless u.allows_pending_account_emails?
|
||||
AdminMailer.new_pending_account(u.account, self).deliver_later
|
||||
end
|
||||
|
|
@ -469,7 +507,15 @@ class User < ApplicationRecord
|
|||
email_changed? && !external? && !(Rails.env.test? || Rails.env.development?)
|
||||
end
|
||||
|
||||
def validate_role_elevation
|
||||
errors.add(:role_id, :elevated) if defined?(@current_account) && role&.overrides?(@current_account&.user_role)
|
||||
end
|
||||
|
||||
def invite_text_required?
|
||||
Setting.require_invite_text && !invited? && !external? && !bypass_invite_request_check?
|
||||
end
|
||||
|
||||
def trigger_webhooks
|
||||
TriggerWebhookWorker.perform_async('account.created', 'Account', account_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
190
app/models/user_role.rb
Normal file
190
app/models/user_role.rb
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: user_roles
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# name :string default(""), not null
|
||||
# color :string default(""), not null
|
||||
# position :integer default(0), not null
|
||||
# permissions :bigint(8) default(0), not null
|
||||
# highlighted :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class UserRole < ApplicationRecord
|
||||
FLAGS = {
|
||||
administrator: (1 << 0),
|
||||
view_devops: (1 << 1),
|
||||
view_audit_log: (1 << 2),
|
||||
view_dashboard: (1 << 3),
|
||||
manage_reports: (1 << 4),
|
||||
manage_federation: (1 << 5),
|
||||
manage_settings: (1 << 6),
|
||||
manage_blocks: (1 << 7),
|
||||
manage_taxonomies: (1 << 8),
|
||||
manage_appeals: (1 << 9),
|
||||
manage_users: (1 << 10),
|
||||
manage_invites: (1 << 11),
|
||||
manage_rules: (1 << 12),
|
||||
manage_announcements: (1 << 13),
|
||||
manage_custom_emojis: (1 << 14),
|
||||
manage_webhooks: (1 << 15),
|
||||
invite_users: (1 << 16),
|
||||
manage_roles: (1 << 17),
|
||||
manage_user_access: (1 << 18),
|
||||
delete_user_data: (1 << 19),
|
||||
}.freeze
|
||||
|
||||
module Flags
|
||||
NONE = 0
|
||||
ALL = FLAGS.values.reduce(&:|)
|
||||
|
||||
DEFAULT = FLAGS[:invite_users]
|
||||
|
||||
CATEGORIES = {
|
||||
invites: %i(
|
||||
invite_users
|
||||
).freeze,
|
||||
|
||||
moderation: %w(
|
||||
view_dashboard
|
||||
view_audit_log
|
||||
manage_users
|
||||
manage_user_access
|
||||
delete_user_data
|
||||
manage_reports
|
||||
manage_appeals
|
||||
manage_federation
|
||||
manage_blocks
|
||||
manage_taxonomies
|
||||
manage_invites
|
||||
).freeze,
|
||||
|
||||
administration: %w(
|
||||
manage_settings
|
||||
manage_rules
|
||||
manage_roles
|
||||
manage_webhooks
|
||||
manage_custom_emojis
|
||||
manage_announcements
|
||||
).freeze,
|
||||
|
||||
devops: %w(
|
||||
view_devops
|
||||
).freeze,
|
||||
|
||||
special: %i(
|
||||
administrator
|
||||
).freeze,
|
||||
}.freeze
|
||||
end
|
||||
|
||||
attr_writer :current_account
|
||||
|
||||
validates :name, presence: true, unless: :everyone?
|
||||
validates :color, format: { with: /\A#?(?:[A-F0-9]{3}){1,2}\z/i }, unless: -> { color.blank? }
|
||||
|
||||
validate :validate_permissions_elevation
|
||||
validate :validate_position_elevation
|
||||
validate :validate_dangerous_permissions
|
||||
validate :validate_own_role_edition
|
||||
|
||||
before_validation :set_position
|
||||
|
||||
scope :assignable, -> { where.not(id: -99).order(position: :asc) }
|
||||
|
||||
has_many :users, inverse_of: :role, foreign_key: 'role_id', dependent: :nullify
|
||||
|
||||
def self.nobody
|
||||
@nobody ||= UserRole.new(permissions: Flags::NONE, position: -1)
|
||||
end
|
||||
|
||||
def self.everyone
|
||||
UserRole.find(-99)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
UserRole.create!(id: -99, permissions: Flags::DEFAULT)
|
||||
end
|
||||
|
||||
def self.that_can(*any_of_privileges)
|
||||
all.select { |role| role.can?(*any_of_privileges) }
|
||||
end
|
||||
|
||||
def everyone?
|
||||
id == -99
|
||||
end
|
||||
|
||||
def nobody?
|
||||
id.nil?
|
||||
end
|
||||
|
||||
def permissions_as_keys
|
||||
FLAGS.keys.select { |privilege| permissions & FLAGS[privilege] == FLAGS[privilege] }.map(&:to_s)
|
||||
end
|
||||
|
||||
def permissions_as_keys=(value)
|
||||
self.permissions = value.map(&:presence).compact.reduce(Flags::NONE) { |bitmask, privilege| FLAGS.key?(privilege.to_sym) ? (bitmask | FLAGS[privilege.to_sym]) : bitmask }
|
||||
end
|
||||
|
||||
def can?(*any_of_privileges)
|
||||
any_of_privileges.any? { |privilege| in_permissions?(privilege) }
|
||||
end
|
||||
|
||||
def overrides?(other_role)
|
||||
other_role.nil? || position > other_role.position
|
||||
end
|
||||
|
||||
def computed_permissions
|
||||
# If called on the everyone role, no further computation needed
|
||||
return permissions if everyone?
|
||||
|
||||
# If called on the nobody role, no permissions are there to be given
|
||||
return Flags::NONE if nobody?
|
||||
|
||||
# Otherwise, compute permissions based on special conditions
|
||||
@computed_permissions ||= begin
|
||||
permissions = self.class.everyone.permissions | self.permissions
|
||||
|
||||
if permissions & FLAGS[:administrator] == FLAGS[:administrator]
|
||||
Flags::ALL
|
||||
else
|
||||
permissions
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def in_permissions?(privilege)
|
||||
raise ArgumentError, "Unknown privilege: #{privilege}" unless FLAGS.key?(privilege)
|
||||
computed_permissions & FLAGS[privilege] == FLAGS[privilege]
|
||||
end
|
||||
|
||||
def set_position
|
||||
self.position = -1 if everyone?
|
||||
end
|
||||
|
||||
def validate_own_role_edition
|
||||
return unless defined?(@current_account) && @current_account.user_role.id == id
|
||||
errors.add(:permissions_as_keys, :own_role) if permissions_changed?
|
||||
errors.add(:position, :own_role) if position_changed?
|
||||
end
|
||||
|
||||
def validate_permissions_elevation
|
||||
errors.add(:permissions_as_keys, :elevated) if defined?(@current_account) && @current_account.user_role.computed_permissions & permissions != permissions
|
||||
end
|
||||
|
||||
def validate_position_elevation
|
||||
errors.add(:position, :elevated) if defined?(@current_account) && @current_account.user_role.position < position
|
||||
end
|
||||
|
||||
def validate_dangerous_permissions
|
||||
errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::DEFAULT & permissions != permissions
|
||||
end
|
||||
end
|
||||
58
app/models/webhook.rb
Normal file
58
app/models/webhook.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: webhooks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# url :string not null
|
||||
# events :string default([]), not null, is an Array
|
||||
# secret :string default(""), not null
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Webhook < ApplicationRecord
|
||||
EVENTS = %w(
|
||||
account.created
|
||||
report.created
|
||||
).freeze
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
validates :url, presence: true, url: true
|
||||
validates :secret, presence: true, length: { minimum: 12 }
|
||||
validates :events, presence: true
|
||||
|
||||
validate :validate_events
|
||||
|
||||
before_validation :strip_events
|
||||
before_validation :generate_secret
|
||||
|
||||
def rotate_secret!
|
||||
update!(secret: SecureRandom.hex(20))
|
||||
end
|
||||
|
||||
def enable!
|
||||
update!(enabled: true)
|
||||
end
|
||||
|
||||
def disable!
|
||||
update!(enabled: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_events
|
||||
errors.add(:events, :invalid) if events.any? { |e| !EVENTS.include?(e) }
|
||||
end
|
||||
|
||||
def strip_events
|
||||
self.events = events.map { |str| str.strip.presence }.compact if events.present?
|
||||
end
|
||||
|
||||
def generate_secret
|
||||
self.secret = SecureRandom.hex(20) if secret.blank?
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue