Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
d564483d30
1796 changed files with 48111 additions and 29322 deletions
|
@ -90,6 +90,8 @@ class Account < ApplicationRecord
|
|||
include Account::Interactions
|
||||
include Account::Merging
|
||||
include Account::Search
|
||||
include Account::Sensitizes
|
||||
include Account::Silences
|
||||
include Account::StatusesSearch
|
||||
include Account::Suspensions
|
||||
include Account::AttributionDomains
|
||||
|
@ -105,33 +107,33 @@ class Account < ApplicationRecord
|
|||
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
||||
|
||||
# Remote user validations, also applies to internal actors
|
||||
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
|
||||
validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (remote? || actor_type_application?) && will_save_change_to_username? }
|
||||
|
||||
# Remote user validations
|
||||
validates :uri, presence: true, unless: :local?, on: :create
|
||||
|
||||
# Local user validations
|
||||
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: USERNAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
|
||||
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
|
||||
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: USERNAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_username? && !actor_type_application? }
|
||||
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && !actor_type_application? }
|
||||
validates :display_name, length: { maximum: DISPLAY_NAME_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_display_name? }
|
||||
validates :note, note_length: { maximum: NOTE_LENGTH_LIMIT }, if: -> { local? && will_save_change_to_note? }
|
||||
validates :fields, length: { maximum: DEFAULT_FIELDS_SIZE }, if: -> { local? && will_save_change_to_fields? }
|
||||
with_options on: :create do
|
||||
validates :uri, absence: true, if: :local?
|
||||
validates :inbox_url, absence: true, if: :local?
|
||||
validates :shared_inbox_url, absence: true, if: :local?
|
||||
validates :followers_url, absence: true, if: :local?
|
||||
validates_with EmptyProfileFieldNamesValidator, if: -> { local? && will_save_change_to_fields? }
|
||||
with_options on: :create, if: :local? do
|
||||
validates :followers_url, absence: true
|
||||
validates :inbox_url, absence: true
|
||||
validates :shared_inbox_url, absence: true
|
||||
validates :uri, absence: true
|
||||
end
|
||||
|
||||
validates :domain, exclusion: { in: [''] }
|
||||
|
||||
normalizes :username, with: ->(username) { username.squish }
|
||||
|
||||
scope :without_internal, -> { where(id: 1...) }
|
||||
scope :remote, -> { where.not(domain: nil) }
|
||||
scope :local, -> { where(domain: nil) }
|
||||
scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
|
||||
scope :silenced, -> { where.not(silenced_at: nil) }
|
||||
scope :sensitized, -> { where.not(sensitized_at: nil) }
|
||||
scope :without_silenced, -> { where(silenced_at: nil) }
|
||||
scope :without_instance_actor, -> { where.not(id: INSTANCE_ACTOR_ID) }
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :bots, -> { where(actor_type: AUTOMATED_ACTOR_TYPES) }
|
||||
|
@ -186,6 +188,10 @@ class Account < ApplicationRecord
|
|||
domain.nil?
|
||||
end
|
||||
|
||||
def remote?
|
||||
!domain.nil?
|
||||
end
|
||||
|
||||
def moved?
|
||||
moved_to_account_id.present?
|
||||
end
|
||||
|
@ -204,6 +210,10 @@ class Account < ApplicationRecord
|
|||
self.actor_type = ActiveModel::Type::Boolean.new.cast(val) ? 'Service' : 'Person'
|
||||
end
|
||||
|
||||
def actor_type_application?
|
||||
actor_type == 'Application'
|
||||
end
|
||||
|
||||
def group?
|
||||
actor_type == 'Group'
|
||||
end
|
||||
|
@ -244,30 +254,6 @@ class Account < ApplicationRecord
|
|||
ResolveAccountService.new.call(acct) unless local?
|
||||
end
|
||||
|
||||
def silenced?
|
||||
silenced_at.present?
|
||||
end
|
||||
|
||||
def silence!(date = Time.now.utc)
|
||||
update!(silenced_at: date)
|
||||
end
|
||||
|
||||
def unsilence!
|
||||
update!(silenced_at: nil)
|
||||
end
|
||||
|
||||
def sensitized?
|
||||
sensitized_at.present?
|
||||
end
|
||||
|
||||
def sensitize!(date = Time.now.utc)
|
||||
update!(sensitized_at: date)
|
||||
end
|
||||
|
||||
def unsensitize!
|
||||
update!(sensitized_at: nil)
|
||||
end
|
||||
|
||||
def memorialize!
|
||||
update!(memorial: true)
|
||||
end
|
||||
|
@ -325,7 +311,7 @@ class Account < ApplicationRecord
|
|||
|
||||
if attributes.is_a?(Hash)
|
||||
attributes.each_value do |attr|
|
||||
next if attr[:name].blank?
|
||||
next if attr[:name].blank? && attr[:value].blank?
|
||||
|
||||
previous = old_fields.find { |item| item['value'] == attr[:value] }
|
||||
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
# Table name: account_aliases
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# acct :string default(""), not null
|
||||
# uri :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class AccountAlias < ApplicationRecord
|
||||
|
|
|
@ -5,13 +5,13 @@
|
|||
# Table name: account_conversations
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# conversation_id :bigint(8)
|
||||
# lock_version :integer default(0), not null
|
||||
# participant_account_ids :bigint(8) default([]), not null, is an Array
|
||||
# status_ids :bigint(8) default([]), not null, is an Array
|
||||
# last_status_id :bigint(8)
|
||||
# lock_version :integer default(0), not null
|
||||
# unread :boolean default(FALSE), not null
|
||||
# account_id :bigint(8) not null
|
||||
# conversation_id :bigint(8) not null
|
||||
# last_status_id :bigint(8)
|
||||
#
|
||||
|
||||
class AccountConversation < ApplicationRecord
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
# Table name: account_deletion_requests
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
class AccountDeletionRequest < ApplicationRecord
|
||||
DELAY_TO_DELETION = 30.days.freeze
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
# Table name: account_domain_blocks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# domain :string
|
||||
# domain :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8)
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class AccountDomainBlock < ApplicationRecord
|
||||
|
|
|
@ -61,7 +61,7 @@ class AccountFilter
|
|||
when 'email'
|
||||
accounts_with_users.merge(User.matches_email(value.to_s.strip))
|
||||
when 'ip'
|
||||
valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group('users.id, accounts.id')) : Account.none
|
||||
valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group(users: [:id], accounts: [:id])) : Account.none
|
||||
when 'invited_by'
|
||||
invited_by_scope(value)
|
||||
when 'order'
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
# Table name: account_notes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# target_account_id :bigint(8)
|
||||
# comment :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# target_account_id :bigint(8) not null
|
||||
#
|
||||
class AccountNote < ApplicationRecord
|
||||
include RelationshipCacheable
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
# Table name: account_pins
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# target_account_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# target_account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class AccountPin < ApplicationRecord
|
||||
|
@ -23,6 +23,6 @@ class AccountPin < ApplicationRecord
|
|||
private
|
||||
|
||||
def validate_follow_relationship
|
||||
errors.add(:base, I18n.t('accounts.pin_errors.following')) unless account.following?(target_account)
|
||||
errors.add(:base, I18n.t('accounts.pin_errors.following')) unless account&.following?(target_account)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -130,7 +130,7 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
|
|||
end
|
||||
|
||||
def without_direct_scope
|
||||
Status.where.not(visibility: :direct)
|
||||
Status.not_direct_visibility
|
||||
end
|
||||
|
||||
def old_enough_scope(max_id = nil)
|
||||
|
|
|
@ -17,6 +17,6 @@ class AccountSummary < ApplicationRecord
|
|||
has_many :follow_recommendation_suppressions, primary_key: :account_id, foreign_key: :account_id, inverse_of: false, dependent: nil
|
||||
|
||||
scope :safe, -> { where(sensitive: false) }
|
||||
scope :localized, ->(locale) { order(Arel::Nodes::Case.new.when(arel_table[:language].eq(locale)).then(1).else(0).desc) }
|
||||
scope :localized, ->(locale) { in_order_of(:language, [locale], filter: false) }
|
||||
scope :filtered, -> { where.missing(:follow_recommendation_suppressions) }
|
||||
end
|
||||
|
|
|
@ -27,6 +27,7 @@ class AccountWarning < ApplicationRecord
|
|||
suspend: 4_000,
|
||||
}, suffix: :action
|
||||
|
||||
APPEAL_WINDOW = 20.days
|
||||
RECENT_PERIOD = 3.months.freeze
|
||||
|
||||
normalizes :text, with: ->(text) { text.to_s }, apply_to_nil: true
|
||||
|
@ -49,6 +50,10 @@ class AccountWarning < ApplicationRecord
|
|||
overruled_at.present?
|
||||
end
|
||||
|
||||
def appeal_eligible?
|
||||
created_at >= APPEAL_WINDOW.ago
|
||||
end
|
||||
|
||||
def to_log_human_identifier
|
||||
target_account.acct
|
||||
end
|
||||
|
|
|
@ -5,15 +5,15 @@
|
|||
# Table name: admin_action_logs
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# action :string default(""), not null
|
||||
# human_identifier :string
|
||||
# permalink :string
|
||||
# route_param :string
|
||||
# target_type :string
|
||||
# target_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# human_identifier :string
|
||||
# route_param :string
|
||||
# permalink :string
|
||||
# account_id :bigint(8) not null
|
||||
# target_id :bigint(8)
|
||||
#
|
||||
|
||||
class Admin::ActionLog < ApplicationRecord
|
||||
|
|
|
@ -31,6 +31,7 @@ class Admin::ActionLogFilter
|
|||
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_relay: { target_type: 'Relay', 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,
|
||||
|
@ -40,6 +41,7 @@ class Admin::ActionLogFilter
|
|||
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_relay: { target_type: 'Relay', 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,
|
||||
|
@ -49,10 +51,13 @@ class Admin::ActionLogFilter
|
|||
disable_2fa_user: { target_type: 'User', action: 'disable_2fa' }.freeze,
|
||||
disable_custom_emoji: { target_type: 'CustomEmoji', action: 'disable' }.freeze,
|
||||
disable_user: { target_type: 'User', action: 'disable' }.freeze,
|
||||
disable_relay: { target_type: 'Relay', action: 'disable' }.freeze,
|
||||
enable_custom_emoji: { target_type: 'CustomEmoji', action: 'enable' }.freeze,
|
||||
enable_user: { target_type: 'User', action: 'enable' }.freeze,
|
||||
enable_relay: { target_type: 'Relay', action: 'enable' }.freeze,
|
||||
memorialize_account: { target_type: 'Account', action: 'memorialize' }.freeze,
|
||||
promote_user: { target_type: 'User', action: 'promote' }.freeze,
|
||||
publish_terms_of_service: { target_type: 'TermsOfService', action: 'publish' }.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,
|
||||
|
|
|
@ -32,7 +32,7 @@ class Admin::StatusFilter
|
|||
def scope_for(key, _value)
|
||||
case key.to_s
|
||||
when 'media'
|
||||
Status.joins(:media_attachments).merge(@account.media_attachments).group(:id).reorder('statuses.id desc')
|
||||
Status.joins(:media_attachments).merge(@account.media_attachments).group(:id).recent
|
||||
else
|
||||
raise Mastodon::InvalidParameterError, "Unknown filter: #{key}"
|
||||
end
|
||||
|
|
|
@ -4,17 +4,18 @@
|
|||
#
|
||||
# Table name: announcements
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# text :text default(""), not null
|
||||
# published :boolean default(FALSE), not null
|
||||
# all_day :boolean default(FALSE), not null
|
||||
# scheduled_at :datetime
|
||||
# starts_at :datetime
|
||||
# ends_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# published_at :datetime
|
||||
# status_ids :bigint(8) is an Array
|
||||
# id :bigint(8) not null, primary key
|
||||
# all_day :boolean default(FALSE), not null
|
||||
# ends_at :datetime
|
||||
# notification_sent_at :datetime
|
||||
# published :boolean default(FALSE), not null
|
||||
# published_at :datetime
|
||||
# scheduled_at :datetime
|
||||
# starts_at :datetime
|
||||
# status_ids :bigint(8) is an Array
|
||||
# text :text default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class Announcement < ApplicationRecord
|
||||
|
@ -54,6 +55,10 @@ class Announcement < ApplicationRecord
|
|||
update!(published: false, scheduled_at: nil)
|
||||
end
|
||||
|
||||
def notification_sent?
|
||||
notification_sent_at.present?
|
||||
end
|
||||
|
||||
def mentions
|
||||
@mentions ||= Account.from_text(text)
|
||||
end
|
||||
|
@ -86,6 +91,10 @@ class Announcement < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def scope_for_notification
|
||||
User.confirmed.joins(:account).merge(Account.without_suspended)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def grouped_ordered_announcement_reactions
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
# Table name: announcement_mutes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# announcement_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# announcement_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class AnnouncementMute < ApplicationRecord
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
# Table name: announcement_reactions
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# announcement_id :bigint(8)
|
||||
# name :string default(""), not null
|
||||
# custom_emoji_id :bigint(8)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# announcement_id :bigint(8) not null
|
||||
# custom_emoji_id :bigint(8)
|
||||
#
|
||||
|
||||
class AnnouncementReaction < ApplicationRecord
|
||||
|
|
15
app/models/annual_report/statuses_per_account_count.rb
Normal file
15
app/models/annual_report/statuses_per_account_count.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: annual_report_statuses_per_account_counts
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# year :integer not null
|
||||
# account_id :bigint(8) not null
|
||||
# statuses_count :bigint(8) not null
|
||||
#
|
||||
|
||||
class AnnualReport::StatusesPerAccountCount < ApplicationRecord
|
||||
# This table facilitates percentile calculations
|
||||
end
|
|
@ -16,8 +16,6 @@
|
|||
# updated_at :datetime not null
|
||||
#
|
||||
class Appeal < ApplicationRecord
|
||||
MAX_STRIKE_AGE = 20.days
|
||||
|
||||
TEXT_LENGTH_LIMIT = 2_000
|
||||
|
||||
belongs_to :account
|
||||
|
@ -68,6 +66,6 @@ class Appeal < ApplicationRecord
|
|||
private
|
||||
|
||||
def validate_time_frame
|
||||
errors.add(:base, I18n.t('strikes.errors.too_late')) if strike.created_at < MAX_STRIKE_AGE.ago
|
||||
errors.add(:base, I18n.t('strikes.errors.too_late')) unless strike.appeal_eligible?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
primary_abstract_class
|
||||
|
||||
include Remotable
|
||||
|
||||
|
|
|
@ -21,6 +21,9 @@
|
|||
class BulkImport < ApplicationRecord
|
||||
self.inheritance_column = false
|
||||
|
||||
ARCHIVE_PERIOD = 1.week
|
||||
CONFIRM_PERIOD = 10.minutes
|
||||
|
||||
belongs_to :account
|
||||
has_many :rows, class_name: 'BulkImportRow', inverse_of: :bulk_import, dependent: :delete_all
|
||||
|
||||
|
@ -42,6 +45,9 @@ class BulkImport < ApplicationRecord
|
|||
|
||||
validates :type, presence: true
|
||||
|
||||
scope :archival_completed, -> { where(created_at: ..ARCHIVE_PERIOD.ago) }
|
||||
scope :confirmation_missed, -> { state_unconfirmed.where(created_at: ..CONFIRM_PERIOD.ago) }
|
||||
|
||||
def self.progress!(bulk_import_id, imported: false)
|
||||
# Use `increment_counter` so that the incrementation is done atomically in the database
|
||||
BulkImport.increment_counter(:processed_items, bulk_import_id)
|
||||
|
|
|
@ -4,75 +4,68 @@ module Account::Associations
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Local users
|
||||
has_one :user, inverse_of: :account, dependent: :destroy
|
||||
# Core associations
|
||||
with_options dependent: :destroy do
|
||||
# Association where account owns record
|
||||
with_options inverse_of: :account do
|
||||
has_many :account_moderation_notes
|
||||
has_many :account_pins
|
||||
has_many :account_warnings
|
||||
has_many :aliases, class_name: 'AccountAlias'
|
||||
has_many :bookmarks
|
||||
has_many :conversations, class_name: 'AccountConversation'
|
||||
has_many :custom_filters
|
||||
has_many :favourites
|
||||
has_many :featured_tags, -> { includes(:tag) }
|
||||
has_many :list_accounts
|
||||
has_many :media_attachments
|
||||
has_many :mentions
|
||||
has_many :migrations, class_name: 'AccountMigration'
|
||||
has_many :notification_permissions
|
||||
has_many :notification_requests
|
||||
has_many :notifications
|
||||
has_many :owned_lists, class_name: 'List'
|
||||
has_many :polls
|
||||
has_many :report_notes
|
||||
has_many :reports
|
||||
has_many :scheduled_statuses
|
||||
has_many :status_pins
|
||||
has_many :statuses
|
||||
|
||||
# Timelines
|
||||
has_many :statuses, inverse_of: :account, dependent: :destroy
|
||||
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||
has_many :bookmarks, inverse_of: :account, dependent: :destroy
|
||||
has_many :mentions, inverse_of: :account, dependent: :destroy
|
||||
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account
|
||||
has_many :scheduled_statuses, inverse_of: :account, dependent: :destroy
|
||||
has_one :deletion_request, class_name: 'AccountDeletionRequest'
|
||||
has_one :follow_recommendation_suppression
|
||||
has_one :notification_policy
|
||||
has_one :statuses_cleanup_policy, class_name: 'AccountStatusesCleanupPolicy'
|
||||
has_one :user
|
||||
end
|
||||
|
||||
# Notifications
|
||||
has_many :notifications, inverse_of: :account, dependent: :destroy
|
||||
has_one :notification_policy, inverse_of: :account, dependent: :destroy
|
||||
has_many :notification_permissions, inverse_of: :account, dependent: :destroy
|
||||
has_many :notification_requests, inverse_of: :account, dependent: :destroy
|
||||
# Association where account is targeted by record
|
||||
with_options foreign_key: :target_account_id, inverse_of: :target_account do
|
||||
has_many :strikes, class_name: 'AccountWarning'
|
||||
has_many :targeted_moderation_notes, class_name: 'AccountModerationNote'
|
||||
has_many :targeted_reports, class_name: 'Report'
|
||||
end
|
||||
end
|
||||
|
||||
# Pinned statuses
|
||||
has_many :status_pins, inverse_of: :account, dependent: :destroy
|
||||
has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
|
||||
# Status records pinned by the account
|
||||
has_many :pinned_statuses, -> { reorder(status_pins: { created_at: :desc }) }, through: :status_pins, class_name: 'Status', source: :status
|
||||
|
||||
# Endorsements
|
||||
has_many :account_pins, inverse_of: :account, dependent: :destroy
|
||||
# Account records endorsed (pinned) by the account
|
||||
has_many :endorsed_accounts, through: :account_pins, class_name: 'Account', source: :target_account
|
||||
|
||||
# Media
|
||||
has_many :media_attachments, dependent: :destroy
|
||||
has_many :polls, dependent: :destroy
|
||||
|
||||
# Report relationships
|
||||
has_many :reports, dependent: :destroy, inverse_of: :account
|
||||
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
|
||||
|
||||
has_many :report_notes, dependent: :destroy
|
||||
has_many :custom_filters, inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Moderation notes
|
||||
has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
|
||||
has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
|
||||
has_many :account_warnings, dependent: :destroy, inverse_of: :account
|
||||
has_many :strikes, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
|
||||
|
||||
# Lists (that the account is on, not owned by the account)
|
||||
has_many :list_accounts, inverse_of: :account, dependent: :destroy
|
||||
# List records the account has been added to (not owned by the account)
|
||||
has_many :lists, through: :list_accounts
|
||||
|
||||
# Lists (owned by the account)
|
||||
has_many :owned_lists, class_name: 'List', dependent: :destroy, inverse_of: :account
|
||||
|
||||
# Account migrations
|
||||
# Account record where account has been migrated
|
||||
belongs_to :moved_to_account, class_name: 'Account', optional: true
|
||||
has_many :migrations, class_name: 'AccountMigration', dependent: :destroy, inverse_of: :account
|
||||
has_many :aliases, class_name: 'AccountAlias', dependent: :destroy, inverse_of: :account
|
||||
|
||||
# Hashtags
|
||||
# Tag records applied to account
|
||||
has_and_belongs_to_many :tags # rubocop:disable Rails/HasAndBelongsToMany
|
||||
has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
|
||||
|
||||
# Account deletion requests
|
||||
has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Follow recommendations
|
||||
# FollowRecommendation for account (surfaced via view)
|
||||
has_one :follow_recommendation, inverse_of: :account, dependent: nil
|
||||
has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Account statuses cleanup policy
|
||||
has_one :statuses_cleanup_policy, class_name: 'AccountStatusesCleanupPolicy', inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Imports
|
||||
# BulkImport records owned by account
|
||||
has_many :bulk_imports, inverse_of: :account, dependent: :delete_all
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,21 +4,9 @@ module Account::AttributionDomains
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
validates :attribution_domains_as_text, domain: { multiline: true }, lines: { maximum: 100 }, if: -> { local? && will_save_change_to_attribution_domains? }
|
||||
end
|
||||
normalizes :attribution_domains, with: ->(arr) { arr.filter_map { |str| str.to_s.strip.delete_prefix('http://').delete_prefix('https://').delete_prefix('*.').presence }.uniq }
|
||||
|
||||
def attribution_domains_as_text
|
||||
self[:attribution_domains].join("\n")
|
||||
end
|
||||
|
||||
def attribution_domains_as_text=(str)
|
||||
self[:attribution_domains] = str.split.filter_map do |line|
|
||||
line
|
||||
.strip
|
||||
.delete_prefix('http://')
|
||||
.delete_prefix('https://')
|
||||
.delete_prefix('*.')
|
||||
end
|
||||
validates :attribution_domains, domain: true, length: { maximum: 100 }, if: -> { local? && will_save_change_to_attribution_domains? }
|
||||
end
|
||||
|
||||
def can_be_attributed_from?(domain)
|
||||
|
|
|
@ -3,11 +3,9 @@
|
|||
module Account::Avatar
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 200.megabytes
|
||||
MAX_PIXELS = 5_000_000 # 1500x500px
|
||||
|
||||
AVATAR_DIMENSIONS = [400, 400].freeze
|
||||
AVATAR_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
AVATAR_LIMIT = Rails.configuration.x.use_vips ? 400.megabytes : 200.megabytes
|
||||
AVATAR_DIMENSIONS = [4096, 4096].freeze
|
||||
AVATAR_GEOMETRY = [AVATAR_DIMENSIONS.first, AVATAR_DIMENSIONS.last].join('x')
|
||||
|
||||
class_methods do
|
||||
|
@ -23,9 +21,9 @@ module Account::Avatar
|
|||
included do
|
||||
# Avatar upload
|
||||
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :avatar, less_than: LIMIT
|
||||
remotable_attachment :avatar, LIMIT, suppress_errors: false
|
||||
validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES
|
||||
validates_attachment_size :avatar, less_than: AVATAR_LIMIT
|
||||
remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false
|
||||
end
|
||||
|
||||
def avatar_original_url
|
||||
|
|
|
@ -3,16 +3,15 @@
|
|||
module Account::Header
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
LIMIT = 200.megabytes
|
||||
|
||||
HEADER_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
|
||||
HEADER_LIMIT = Rails.configuration.x.use_vips ? 400.megabytes : 200.megabytes
|
||||
HEADER_DIMENSIONS = [6000, 2000].freeze
|
||||
HEADER_GEOMETRY = [HEADER_DIMENSIONS.first, HEADER_DIMENSIONS.last].join('x')
|
||||
MAX_PIXELS = HEADER_DIMENSIONS.first * HEADER_DIMENSIONS.last
|
||||
HEADER_MAX_PIXELS = HEADER_DIMENSIONS.first * HEADER_DIMENSIONS.last
|
||||
|
||||
class_methods do
|
||||
def header_styles(file)
|
||||
styles = { original: { pixels: MAX_PIXELS, file_geometry_parser: FastGeometryParser } }
|
||||
styles = { original: { pixels: HEADER_MAX_PIXELS, file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
end
|
||||
|
@ -23,9 +22,9 @@ module Account::Header
|
|||
included do
|
||||
# Header upload
|
||||
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail]
|
||||
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :header, less_than: LIMIT
|
||||
remotable_attachment :header, LIMIT, suppress_errors: false
|
||||
validates_attachment_content_type :header, content_type: HEADER_IMAGE_MIME_TYPES
|
||||
validates_attachment_size :header, less_than: HEADER_LIMIT
|
||||
remotable_attachment :header, HEADER_LIMIT, suppress_errors: false
|
||||
end
|
||||
|
||||
def header_original_url
|
||||
|
|
|
@ -80,14 +80,17 @@ module Account::Interactions
|
|||
has_many :passive_relationships, foreign_key: 'target_account_id', inverse_of: :target_account
|
||||
end
|
||||
|
||||
has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
|
||||
has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
|
||||
has_many :following, -> { order(follows: { id: :desc }) }, through: :active_relationships, source: :target_account
|
||||
has_many :followers, -> { order(follows: { id: :desc }) }, through: :passive_relationships, source: :account
|
||||
|
||||
with_options class_name: 'SeveredRelationship', dependent: :destroy do
|
||||
has_many :severed_relationships, foreign_key: 'local_account_id', inverse_of: :local_account
|
||||
has_many :remote_severed_relationships, foreign_key: 'remote_account_id', inverse_of: :remote_account
|
||||
end
|
||||
|
||||
# Hashtag follows
|
||||
has_many :tag_follows, inverse_of: :account, dependent: :destroy
|
||||
|
||||
# Account notes
|
||||
has_many :account_notes, dependent: :destroy
|
||||
|
||||
|
@ -96,23 +99,23 @@ module Account::Interactions
|
|||
has_many :block_relationships, foreign_key: 'account_id', inverse_of: :account
|
||||
has_many :blocked_by_relationships, foreign_key: :target_account_id, inverse_of: :target_account
|
||||
end
|
||||
has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
|
||||
has_many :blocked_by, -> { order('blocks.id desc') }, through: :blocked_by_relationships, source: :account
|
||||
has_many :blocking, -> { order(blocks: { id: :desc }) }, through: :block_relationships, source: :target_account
|
||||
has_many :blocked_by, -> { order(blocks: { id: :desc }) }, through: :blocked_by_relationships, source: :account
|
||||
|
||||
# Mute relationships
|
||||
with_options class_name: 'Mute', dependent: :destroy do
|
||||
has_many :mute_relationships, foreign_key: 'account_id', inverse_of: :account
|
||||
has_many :muted_by_relationships, foreign_key: :target_account_id, inverse_of: :target_account
|
||||
end
|
||||
has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
|
||||
has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
|
||||
has_many :muting, -> { order(mutes: { id: :desc }) }, through: :mute_relationships, source: :target_account
|
||||
has_many :muted_by, -> { order(mutes: { id: :desc }) }, through: :muted_by_relationships, source: :account
|
||||
has_many :conversation_mutes, dependent: :destroy
|
||||
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
|
||||
has_many :announcement_mutes, dependent: :destroy
|
||||
end
|
||||
|
||||
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)
|
||||
rel = active_relationships.create_with(show_reblogs: reblogs.nil? || 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?
|
||||
|
@ -125,7 +128,7 @@ module Account::Interactions
|
|||
end
|
||||
|
||||
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)
|
||||
rel = follow_requests.create_with(show_reblogs: reblogs.nil? || 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?
|
||||
|
|
|
@ -16,7 +16,7 @@ module Account::Merging
|
|||
Follow, FollowRequest, Block, Mute,
|
||||
AccountModerationNote, AccountPin, AccountStat, ListAccount,
|
||||
PollVote, Mention, AccountDeletionRequest, AccountNote, FollowRecommendationSuppression,
|
||||
Appeal
|
||||
Appeal, TagFollow
|
||||
]
|
||||
|
||||
owned_classes.each do |klass|
|
||||
|
|
21
app/models/concerns/account/sensitizes.rb
Normal file
21
app/models/concerns/account/sensitizes.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Account::Sensitizes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :sensitized, -> { where.not(sensitized_at: nil) }
|
||||
end
|
||||
|
||||
def sensitized?
|
||||
sensitized_at.present?
|
||||
end
|
||||
|
||||
def sensitize!(date = Time.now.utc)
|
||||
update!(sensitized_at: date)
|
||||
end
|
||||
|
||||
def unsensitize!
|
||||
update!(sensitized_at: nil)
|
||||
end
|
||||
end
|
22
app/models/concerns/account/silences.rb
Normal file
22
app/models/concerns/account/silences.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Account::Silences
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :silenced, -> { where.not(silenced_at: nil) }
|
||||
scope :without_silenced, -> { where(silenced_at: nil) }
|
||||
end
|
||||
|
||||
def silenced?
|
||||
silenced_at.present?
|
||||
end
|
||||
|
||||
def silence!(date = Time.now.utc)
|
||||
update!(silenced_at: date)
|
||||
end
|
||||
|
||||
def unsilence!
|
||||
update!(silenced_at: nil)
|
||||
end
|
||||
end
|
10
app/models/concerns/inet_container.rb
Normal file
10
app/models/concerns/inet_container.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module InetContainer
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :containing, ->(value) { where('ip >>= ?', value) }
|
||||
scope :contained_by, ->(value) { where('ip <<= ?', value) }
|
||||
end
|
||||
end
|
127
app/models/concerns/notification/groups.rb
Normal file
127
app/models/concerns/notification/groups.rb
Normal file
|
@ -0,0 +1,127 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Notification::Groups
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# `set_group_key!` needs to be updated if this list changes
|
||||
GROUPABLE_NOTIFICATION_TYPES = %i(favourite reblog follow).freeze
|
||||
MAXIMUM_GROUP_SPAN_HOURS = 12
|
||||
|
||||
included do
|
||||
scope :by_group_key, ->(group_key) { group_key&.start_with?('ungrouped-') ? where(id: group_key.delete_prefix('ungrouped-')) : where(group_key: group_key) }
|
||||
end
|
||||
|
||||
def set_group_key!
|
||||
return if filtered? || GROUPABLE_NOTIFICATION_TYPES.exclude?(type)
|
||||
|
||||
type_prefix = case type
|
||||
when :favourite, :reblog
|
||||
[type, target_status&.id].join('-')
|
||||
when :follow
|
||||
type
|
||||
else
|
||||
raise NotImplementedError
|
||||
end
|
||||
redis_key = "notif-group/#{account.id}/#{type_prefix}"
|
||||
hour_bucket = activity.created_at.utc.to_i / 1.hour.to_i
|
||||
|
||||
# Reuse previous group if it does not span too large an amount of time
|
||||
previous_bucket = redis.get(redis_key).to_i
|
||||
hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
|
||||
|
||||
# We do not concern ourselves with race conditions since we use hour buckets
|
||||
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS.hours.to_i)
|
||||
|
||||
self.group_key = "#{type_prefix}-#{hour_bucket}"
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def paginate_groups(limit, pagination_order, grouped_types: nil)
|
||||
raise ArgumentError unless %i(asc desc).include?(pagination_order)
|
||||
|
||||
query = reorder(id: pagination_order)
|
||||
|
||||
# Ideally `:types` would be a bind rather than part of the SQL itself, but that does not
|
||||
# seem to be possible to do with Rails, considering that the expression would occur in
|
||||
# multiple places, including in a `select`
|
||||
group_key_sql = begin
|
||||
if grouped_types.present?
|
||||
# Normalize `grouped_types` so the number of different SQL query shapes remains small, and
|
||||
# the queries can be analyzed in monitoring/telemetry tools
|
||||
grouped_types = (grouped_types.map(&:to_sym) & GROUPABLE_NOTIFICATION_TYPES).sort
|
||||
|
||||
sanitize_sql_array([<<~SQL.squish, { types: grouped_types }])
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN notifications.type IN (:types) THEN notifications.group_key
|
||||
ELSE NULL
|
||||
END,
|
||||
'ungrouped-' || notifications.id
|
||||
)
|
||||
SQL
|
||||
else
|
||||
"COALESCE(notifications.group_key, 'ungrouped-' || notifications.id)"
|
||||
end
|
||||
end
|
||||
|
||||
unscoped
|
||||
.with_recursive(
|
||||
grouped_notifications: [
|
||||
# Base case: fetching one notification and annotating it with visited groups
|
||||
query
|
||||
.select('notifications.*', "ARRAY[#{group_key_sql}] AS groups")
|
||||
.limit(1),
|
||||
# Recursive case, always yielding at most one annotated notification
|
||||
unscoped
|
||||
.from(
|
||||
[
|
||||
# Expose the working table as `wt`, but quit early if we've reached the limit
|
||||
unscoped
|
||||
.select('id', 'groups')
|
||||
.from('grouped_notifications')
|
||||
.where('array_length(grouped_notifications.groups, 1) < :limit', limit: limit)
|
||||
.arel.as('wt'),
|
||||
# Recursive query, using `LATERAL` so we can refer to `wt`
|
||||
query
|
||||
.where(pagination_order == :desc ? 'notifications.id < wt.id' : 'notifications.id > wt.id')
|
||||
.where.not("#{group_key_sql} = ANY(wt.groups)")
|
||||
.limit(1)
|
||||
.arel.lateral('notifications'),
|
||||
]
|
||||
)
|
||||
.select('notifications.*', "array_append(wt.groups, #{group_key_sql}) AS groups"),
|
||||
]
|
||||
)
|
||||
.from('grouped_notifications AS notifications')
|
||||
.order(id: pagination_order)
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
# This returns notifications from the request page, but with at most one notification per group.
|
||||
# Notifications that have no `group_key` each count as a separate group.
|
||||
def paginate_groups_by_max_id(limit, max_id: nil, since_id: nil, grouped_types: nil)
|
||||
query = reorder(id: :desc)
|
||||
query = query.where(id: ...(max_id.to_i)) if max_id.present?
|
||||
query = query.where(id: (since_id.to_i + 1)...) if since_id.present?
|
||||
query.paginate_groups(limit, :desc, grouped_types: grouped_types)
|
||||
end
|
||||
|
||||
# Differs from :paginate_groups_by_max_id in that it gives the results immediately following min_id,
|
||||
# whereas since_id gives the items with largest id, but with since_id as a cutoff.
|
||||
# Results will be in ascending order by id.
|
||||
def paginate_groups_by_min_id(limit, max_id: nil, min_id: nil, grouped_types: nil)
|
||||
query = reorder(id: :asc)
|
||||
query = query.where(id: (min_id.to_i + 1)...) if min_id.present?
|
||||
query = query.where(id: ...(max_id.to_i)) if max_id.present?
|
||||
query.paginate_groups(limit, :asc, grouped_types: grouped_types)
|
||||
end
|
||||
|
||||
def to_a_grouped_paginated_by_id(limit, options = {})
|
||||
if options[:min_id].present?
|
||||
paginate_groups_by_min_id(limit, min_id: options[:min_id], max_id: options[:max_id], grouped_types: options[:grouped_types]).reverse
|
||||
else
|
||||
paginate_groups_by_max_id(limit, max_id: options[:max_id], since_id: options[:since_id], grouped_types: options[:grouped_types]).to_a
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -9,6 +9,10 @@ module RankedTrend
|
|||
end
|
||||
|
||||
class_methods do
|
||||
def locales
|
||||
distinct.pluck(:language)
|
||||
end
|
||||
|
||||
def recalculate_ordered_rank
|
||||
connection
|
||||
.exec_update(<<~SQL.squish)
|
||||
|
|
43
app/models/concerns/status/fetch_replies_concern.rb
Normal file
43
app/models/concerns/status/fetch_replies_concern.rb
Normal file
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Status::FetchRepliesConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# enable/disable fetching all replies
|
||||
FETCH_REPLIES_ENABLED = ENV['FETCH_REPLIES_ENABLED'] == 'true'
|
||||
|
||||
# debounce fetching all replies to minimize DoS
|
||||
FETCH_REPLIES_COOLDOWN_MINUTES = (ENV['FETCH_REPLIES_COOLDOWN_MINUTES'] || 15).to_i.minutes
|
||||
FETCH_REPLIES_INITIAL_WAIT_MINUTES = (ENV['FETCH_REPLIES_INITIAL_WAIT_MINUTES'] || 5).to_i.minutes
|
||||
|
||||
included do
|
||||
scope :created_recently, -> { where(created_at: FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago..) }
|
||||
scope :not_created_recently, -> { where(created_at: ..FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago) }
|
||||
scope :fetched_recently, -> { where(fetched_replies_at: FETCH_REPLIES_COOLDOWN_MINUTES.ago..) }
|
||||
scope :not_fetched_recently, -> { where(fetched_replies_at: [nil, ..FETCH_REPLIES_COOLDOWN_MINUTES.ago]) }
|
||||
|
||||
scope :should_not_fetch_replies, -> { local.or(created_recently.or(fetched_recently)) }
|
||||
scope :should_fetch_replies, -> { remote.not_created_recently.not_fetched_recently }
|
||||
|
||||
# statuses for which we won't receive update or deletion actions,
|
||||
# and should update when fetching replies
|
||||
# Status from an account which either
|
||||
# a) has only remote followers
|
||||
# b) has local follows that were created after the last update time, or
|
||||
# c) has no known followers
|
||||
scope :unsubscribed, lambda {
|
||||
remote.merge(
|
||||
Status.left_outer_joins(account: :followers).where.not(followers_accounts: { domain: nil })
|
||||
.or(where.not('follows.created_at < statuses.updated_at'))
|
||||
.or(where(follows: { id: nil }))
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def should_fetch_replies?
|
||||
# we aren't brand new, and we haven't fetched replies since the debounce window
|
||||
FETCH_REPLIES_ENABLED && !local? && created_at <= FETCH_REPLIES_INITIAL_WAIT_MINUTES.ago && (
|
||||
fetched_replies_at.nil? || fetched_replies_at <= FETCH_REPLIES_COOLDOWN_MINUTES.ago
|
||||
)
|
||||
end
|
||||
end
|
|
@ -15,7 +15,9 @@ module Status::SafeReblogInsert
|
|||
#
|
||||
# The code is kept similar to ActiveRecord::Persistence code and calls it
|
||||
# directly when we are not handling a reblog.
|
||||
def _insert_record(values, returning)
|
||||
#
|
||||
# https://github.com/rails/rails/blob/v7.2.1.1/activerecord/lib/active_record/persistence.rb#L238-L263
|
||||
def _insert_record(connection, values, returning)
|
||||
return super unless values.is_a?(Hash) && values['reblog_of_id']&.value.present?
|
||||
|
||||
primary_key = self.primary_key
|
||||
|
@ -30,14 +32,19 @@ module Status::SafeReblogInsert
|
|||
|
||||
# The following line departs from stock ActiveRecord
|
||||
# Original code was:
|
||||
# im.insert(values.transform_keys { |name| arel_table[name] })
|
||||
# im = Arel::InsertManager.new(arel_table)
|
||||
# Instead, we use a custom builder when a reblog is happening:
|
||||
im = _compile_reblog_insert(values)
|
||||
|
||||
connection.insert(im, "#{self} Create", primary_key || false, primary_key_value, returning: returning).tap do |result|
|
||||
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
|
||||
# For our purposes, it's equivalent to a foreign key constraint violation
|
||||
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id'].value}) is not present in table \"statuses\"" if result.nil?
|
||||
with_connection do |_c|
|
||||
connection.insert(
|
||||
im, "#{self} Create", primary_key || false, primary_key_value,
|
||||
returning: returning
|
||||
).tap do |result|
|
||||
# Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
|
||||
# For our purposes, it's equivalent to a foreign key constraint violation
|
||||
raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id'].value}) is not present in table \"statuses\"" if result.nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ module Status::SnapshotConcern
|
|||
)
|
||||
end
|
||||
|
||||
def snapshot!(**options)
|
||||
build_snapshot(**options).save!
|
||||
def snapshot!(**)
|
||||
build_snapshot(**).save!
|
||||
end
|
||||
end
|
||||
|
|
47
app/models/concerns/status/visibility.rb
Normal file
47
app/models/concerns/status/visibility.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Status::Visibility
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
enum :visibility,
|
||||
{ public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 },
|
||||
suffix: :visibility,
|
||||
validate: true
|
||||
|
||||
scope :distributable_visibility, -> { where(visibility: %i(public unlisted)) }
|
||||
scope :list_eligible_visibility, -> { where(visibility: %i(public unlisted private)) }
|
||||
scope :not_direct_visibility, -> { where.not(visibility: :direct) }
|
||||
|
||||
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
|
||||
|
||||
before_validation :set_visibility, unless: :visibility?
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def selectable_visibilities
|
||||
visibilities.keys - %w(direct limited)
|
||||
end
|
||||
end
|
||||
|
||||
def hidden?
|
||||
!distributable?
|
||||
end
|
||||
|
||||
def distributable?
|
||||
public_visibility? || unlisted_visibility?
|
||||
end
|
||||
|
||||
alias sign? distributable?
|
||||
|
||||
private
|
||||
|
||||
def set_visibility
|
||||
self.visibility ||= reblog.visibility if reblog?
|
||||
self.visibility ||= visibility_from_account
|
||||
end
|
||||
|
||||
def visibility_from_account
|
||||
account.locked? ? :private : :public
|
||||
end
|
||||
end
|
|
@ -43,6 +43,10 @@ module User::HasSettings
|
|||
settings['web.use_system_font']
|
||||
end
|
||||
|
||||
def setting_system_scrollbars_ui
|
||||
settings['web.use_system_scrollbars']
|
||||
end
|
||||
|
||||
def setting_noindex
|
||||
settings['noindex']
|
||||
end
|
||||
|
|
|
@ -14,4 +14,6 @@ class CustomEmojiCategory < ApplicationRecord
|
|||
has_many :emojis, class_name: 'CustomEmoji', foreign_key: 'category_id', inverse_of: :category, dependent: nil
|
||||
|
||||
validates :name, presence: true, uniqueness: true
|
||||
|
||||
scope :alphabetic, -> { order(name: :asc) }
|
||||
end
|
||||
|
|
|
@ -5,13 +5,13 @@
|
|||
# Table name: custom_filters
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# action :integer default("warn"), not null
|
||||
# context :string default([]), not null, is an Array
|
||||
# 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
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class CustomFilter < ApplicationRecord
|
||||
|
|
|
@ -21,7 +21,7 @@ class DomainBlock < ApplicationRecord
|
|||
include DomainNormalizable
|
||||
include DomainMaterializable
|
||||
|
||||
enum :severity, { silence: 0, suspend: 1, noop: 2 }
|
||||
enum :severity, { silence: 0, suspend: 1, noop: 2 }, validate: true
|
||||
|
||||
validates :domain, presence: true, uniqueness: true, domain: true
|
||||
|
||||
|
|
|
@ -28,6 +28,8 @@ class EmailDomainBlock < ApplicationRecord
|
|||
|
||||
validates :domain, presence: true, uniqueness: true, domain: true
|
||||
|
||||
scope :parents, -> { where(parent_id: nil) }
|
||||
|
||||
# Used for adding multiple blocks at once
|
||||
attr_accessor :other_domains
|
||||
|
||||
|
|
|
@ -44,8 +44,16 @@ class FeaturedTag < ApplicationRecord
|
|||
update(statuses_count: statuses_count + 1, last_status_at: timestamp)
|
||||
end
|
||||
|
||||
def decrement(deleted_status_id)
|
||||
update(statuses_count: [0, statuses_count - 1].max, last_status_at: visible_tagged_account_statuses.where.not(id: deleted_status_id).pick(:created_at))
|
||||
def decrement(deleted_status)
|
||||
if statuses_count <= 1
|
||||
update(statuses_count: 0, last_status_at: nil)
|
||||
elsif last_status_at.present? && last_status_at > deleted_status.created_at
|
||||
update(statuses_count: statuses_count - 1)
|
||||
else
|
||||
# Fetching the latest status creation time can be expensive, so only perform it
|
||||
# if we know we are deleting the latest status using this tag
|
||||
update(statuses_count: statuses_count - 1, last_status_at: visible_tagged_account_statuses.where(id: ...deleted_status.id).pick(:created_at))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -33,8 +33,15 @@ class FollowRequest < ApplicationRecord
|
|||
|
||||
def authorize!
|
||||
follow = account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
|
||||
ListAccount.where(follow_request: self).update_all(follow_request_id: nil, follow_id: follow.id)
|
||||
MergeWorker.perform_async(target_account.id, account.id) if account.local?
|
||||
|
||||
if account.local?
|
||||
ListAccount.where(follow_request: self).update_all(follow_request_id: nil, follow_id: follow.id)
|
||||
MergeWorker.perform_async(target_account.id, account.id, 'home')
|
||||
MergeWorker.push_bulk(List.where(account: account).joins(:list_accounts).where(list_accounts: { account_id: target_account.id }).pluck(:id)) do |list_id|
|
||||
[target_account.id, list_id, 'list']
|
||||
end
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
|
||||
|
|
|
@ -69,6 +69,10 @@ class Form::AdminSettings
|
|||
favicon
|
||||
).freeze
|
||||
|
||||
DIGEST_KEYS = %i(
|
||||
custom_css
|
||||
).freeze
|
||||
|
||||
OVERRIDEN_SETTINGS = {
|
||||
authorized_fetch: :authorized_fetch_mode?,
|
||||
}.freeze
|
||||
|
@ -122,6 +126,8 @@ class Form::AdminSettings
|
|||
KEYS.each do |key|
|
||||
next unless instance_variable_defined?(:"@#{key}")
|
||||
|
||||
cache_digest_value(key) if DIGEST_KEYS.include?(key)
|
||||
|
||||
if UPLOAD_KEYS.include?(key)
|
||||
public_send(key).save
|
||||
else
|
||||
|
@ -133,6 +139,18 @@ class Form::AdminSettings
|
|||
|
||||
private
|
||||
|
||||
def cache_digest_value(key)
|
||||
Rails.cache.delete(:"setting_digest_#{key}")
|
||||
|
||||
key_value = instance_variable_get(:"@#{key}")
|
||||
if key_value.present?
|
||||
Rails.cache.write(
|
||||
:"setting_digest_#{key}",
|
||||
Digest::SHA256.hexdigest(key_value)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def typecast_value(key, value)
|
||||
if BOOLEAN_KEYS.include?(key)
|
||||
value == '1'
|
||||
|
|
|
@ -28,9 +28,9 @@ class InstanceFilter
|
|||
def scope_for(key, value)
|
||||
case key.to_s
|
||||
when 'limited'
|
||||
Instance.joins(:domain_block).reorder(Arel.sql('domain_blocks.id desc'))
|
||||
Instance.joins(:domain_block).reorder(domain_blocks: { id: :desc })
|
||||
when 'allowed'
|
||||
Instance.joins(:domain_allow).reorder(Arel.sql('domain_allows.id desc'))
|
||||
Instance.joins(:domain_allow).reorder(domain_allows: { id: :desc })
|
||||
when 'by_domain'
|
||||
Instance.matches_domain(value)
|
||||
when 'availability'
|
||||
|
|
|
@ -20,6 +20,9 @@ class Invite < ApplicationRecord
|
|||
include Expireable
|
||||
|
||||
COMMENT_SIZE_LIMIT = 420
|
||||
ELIGIBLE_CODE_CHARACTERS = [*('a'..'z'), *('A'..'Z'), *('0'..'9')].freeze
|
||||
HOMOGLYPHS = %w(0 1 I l O).freeze
|
||||
VALID_CODE_CHARACTERS = ELIGIBLE_CODE_CHARACTERS - HOMOGLYPHS
|
||||
|
||||
belongs_to :user, inverse_of: :invites
|
||||
has_many :users, inverse_of: :invite, dependent: nil
|
||||
|
@ -28,7 +31,7 @@ class Invite < ApplicationRecord
|
|||
|
||||
validates :comment, length: { maximum: COMMENT_SIZE_LIMIT }
|
||||
|
||||
before_validation :set_code
|
||||
before_validation :set_code, on: :create
|
||||
|
||||
def valid_for_use?
|
||||
(max_uses.nil? || uses < max_uses) && !expired? && user&.functional?
|
||||
|
@ -38,7 +41,7 @@ class Invite < ApplicationRecord
|
|||
|
||||
def set_code
|
||||
loop do
|
||||
self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join
|
||||
self.code = VALID_CODE_CHARACTERS.sample(8).join
|
||||
break if Invite.find_by(code: code).nil?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,13 +17,14 @@ class IpBlock < ApplicationRecord
|
|||
CACHE_KEY = 'blocked_ips'
|
||||
|
||||
include Expireable
|
||||
include InetContainer
|
||||
include Paginable
|
||||
|
||||
enum :severity, {
|
||||
sign_up_requires_approval: 5000,
|
||||
sign_up_block: 5500,
|
||||
no_access: 9999,
|
||||
}, prefix: true
|
||||
}, prefix: true, validate: true
|
||||
|
||||
validates :ip, :severity, presence: true
|
||||
validates :ip, uniqueness: true
|
||||
|
|
|
@ -24,6 +24,7 @@ class List < ApplicationRecord
|
|||
|
||||
has_many :list_accounts, inverse_of: :list, dependent: :destroy
|
||||
has_many :accounts, through: :list_accounts
|
||||
has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account
|
||||
|
||||
validates :title, presence: true
|
||||
|
||||
|
@ -34,7 +35,7 @@ class List < ApplicationRecord
|
|||
private
|
||||
|
||||
def validate_account_lists_limit
|
||||
errors.add(:base, I18n.t('lists.errors.limit')) if account.lists.count >= PER_ACCOUNT_LIMIT
|
||||
errors.add(:base, I18n.t('lists.errors.limit')) if account.owned_lists.count >= PER_ACCOUNT_LIMIT
|
||||
end
|
||||
|
||||
def clean_feed_manager
|
||||
|
|
|
@ -20,22 +20,23 @@ class ListAccount < ApplicationRecord
|
|||
validates :account_id, uniqueness: { scope: :list_id }
|
||||
validate :validate_relationship
|
||||
|
||||
scope :active, -> { where.not(follow_id: nil) }
|
||||
|
||||
before_validation :set_follow, unless: :list_owner_account_is_account?
|
||||
|
||||
private
|
||||
|
||||
def set_follow
|
||||
self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
self.follow_request = FollowRequest.find_by!(account_id: list.account_id, target_account_id: account.id)
|
||||
self.follow = Follow.find_by(account_id: list.account_id, target_account_id: account.id)
|
||||
self.follow_request = FollowRequest.find_by(account_id: list.account_id, target_account_id: account.id) if follow.nil?
|
||||
end
|
||||
|
||||
def validate_relationship
|
||||
return if list.account_id == account_id
|
||||
return if list_owner_account_is_account?
|
||||
|
||||
errors.add(:account_id, 'follow relationship missing') if follow_id.nil? && follow_request_id.nil?
|
||||
errors.add(:follow, 'mismatched accounts') if follow_id.present? && follow.target_account_id != account_id
|
||||
errors.add(:follow_request, 'mismatched accounts') if follow_request_id.present? && follow_request.target_account_id != account_id
|
||||
errors.add(:account_id, :must_be_following) if follow_id.nil? && follow_request_id.nil?
|
||||
errors.add(:follow, :invalid) if follow_id.present? && follow.target_account_id != account_id
|
||||
errors.add(:follow_request, :invalid) if follow_request_id.present? && follow_request.target_account_id != account_id
|
||||
end
|
||||
|
||||
def list_owner_account_is_account?
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
# Table name: markers
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# user_id :bigint(8)
|
||||
# timeline :string default(""), not null
|
||||
# last_read_id :bigint(8) default(0), not null
|
||||
# lock_version :integer default(0), not null
|
||||
# timeline :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# last_read_id :bigint(8) default(0), not null
|
||||
# user_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class Marker < ApplicationRecord
|
||||
|
|
|
@ -115,7 +115,7 @@ class MediaAttachment < ApplicationRecord
|
|||
VIDEO_PASSTHROUGH_OPTIONS = {
|
||||
video_codecs: ['h264'].freeze,
|
||||
audio_codecs: ['aac', nil].freeze,
|
||||
colorspaces: ['yuv420p'].freeze,
|
||||
colorspaces: ['yuv420p', 'yuvj420p'].freeze,
|
||||
options: {
|
||||
format: 'mp4',
|
||||
convert_options: {
|
||||
|
|
|
@ -18,7 +18,7 @@ class Mention < ApplicationRecord
|
|||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :account, uniqueness: { scope: :status }
|
||||
validates :account_id, uniqueness: { scope: :status_id }
|
||||
|
||||
scope :active, -> { where(silent: false) }
|
||||
scope :silent, -> { where(silent: true) }
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
class Notification < ApplicationRecord
|
||||
self.inheritance_column = nil
|
||||
|
||||
include Notification::Groups
|
||||
include Paginable
|
||||
include Redisable
|
||||
|
||||
|
@ -31,10 +32,6 @@ class Notification < ApplicationRecord
|
|||
'Poll' => :poll,
|
||||
}.freeze
|
||||
|
||||
# `set_group_key!` needs to be updated if this list changes
|
||||
GROUPABLE_NOTIFICATION_TYPES = %i(favourite reblog follow).freeze
|
||||
MAXIMUM_GROUP_SPAN_HOURS = 12
|
||||
|
||||
# Please update app/javascript/api_types/notification.ts if you change this
|
||||
PROPERTIES = {
|
||||
mention: {
|
||||
|
@ -67,6 +64,9 @@ class Notification < ApplicationRecord
|
|||
moderation_warning: {
|
||||
filterable: false,
|
||||
}.freeze,
|
||||
annual_report: {
|
||||
filterable: false,
|
||||
}.freeze,
|
||||
'admin.sign_up': {
|
||||
filterable: false,
|
||||
}.freeze,
|
||||
|
@ -101,6 +101,7 @@ class Notification < ApplicationRecord
|
|||
belongs_to :report, inverse_of: false
|
||||
belongs_to :account_relationship_severance_event, inverse_of: false
|
||||
belongs_to :account_warning, inverse_of: false
|
||||
belongs_to :generated_annual_report, inverse_of: false
|
||||
end
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
@ -126,30 +127,6 @@ class Notification < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def set_group_key!
|
||||
return if filtered? || Notification::GROUPABLE_NOTIFICATION_TYPES.exclude?(type)
|
||||
|
||||
type_prefix = case type
|
||||
when :favourite, :reblog
|
||||
[type, target_status&.id].join('-')
|
||||
when :follow
|
||||
type
|
||||
else
|
||||
raise NotImplementedError
|
||||
end
|
||||
redis_key = "notif-group/#{account.id}/#{type_prefix}"
|
||||
hour_bucket = activity.created_at.utc.to_i / 1.hour.to_i
|
||||
|
||||
# Reuse previous group if it does not span too large an amount of time
|
||||
previous_bucket = redis.get(redis_key).to_i
|
||||
hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
|
||||
|
||||
# We do not concern ourselves with race conditions since we use hour buckets
|
||||
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS.hours.to_i)
|
||||
|
||||
self.group_key = "#{type_prefix}-#{hour_bucket}"
|
||||
end
|
||||
|
||||
class << self
|
||||
def browserable(types: [], exclude_types: [], from_account_id: nil, include_filtered: false)
|
||||
requested_types = if types.empty?
|
||||
|
@ -167,94 +144,6 @@ class Notification < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def paginate_groups(limit, pagination_order, grouped_types: nil)
|
||||
raise ArgumentError unless %i(asc desc).include?(pagination_order)
|
||||
|
||||
query = reorder(id: pagination_order)
|
||||
|
||||
# Ideally `:types` would be a bind rather than part of the SQL itself, but that does not
|
||||
# seem to be possible to do with Rails, considering that the expression would occur in
|
||||
# multiple places, including in a `select`
|
||||
group_key_sql = begin
|
||||
if grouped_types.present?
|
||||
# Normalize `grouped_types` so the number of different SQL query shapes remains small, and
|
||||
# the queries can be analyzed in monitoring/telemetry tools
|
||||
grouped_types = (grouped_types.map(&:to_sym) & GROUPABLE_NOTIFICATION_TYPES).sort
|
||||
|
||||
sanitize_sql_array([<<~SQL.squish, { types: grouped_types }])
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN notifications.type IN (:types) THEN notifications.group_key
|
||||
ELSE NULL
|
||||
END,
|
||||
'ungrouped-' || notifications.id
|
||||
)
|
||||
SQL
|
||||
else
|
||||
"COALESCE(notifications.group_key, 'ungrouped-' || notifications.id)"
|
||||
end
|
||||
end
|
||||
|
||||
unscoped
|
||||
.with_recursive(
|
||||
grouped_notifications: [
|
||||
# Base case: fetching one notification and annotating it with visited groups
|
||||
query
|
||||
.select('notifications.*', "ARRAY[#{group_key_sql}] AS groups")
|
||||
.limit(1),
|
||||
# Recursive case, always yielding at most one annotated notification
|
||||
unscoped
|
||||
.from(
|
||||
[
|
||||
# Expose the working table as `wt`, but quit early if we've reached the limit
|
||||
unscoped
|
||||
.select('id', 'groups')
|
||||
.from('grouped_notifications')
|
||||
.where('array_length(grouped_notifications.groups, 1) < :limit', limit: limit)
|
||||
.arel.as('wt'),
|
||||
# Recursive query, using `LATERAL` so we can refer to `wt`
|
||||
query
|
||||
.where(pagination_order == :desc ? 'notifications.id < wt.id' : 'notifications.id > wt.id')
|
||||
.where.not("#{group_key_sql} = ANY(wt.groups)")
|
||||
.limit(1)
|
||||
.arel.lateral('notifications'),
|
||||
]
|
||||
)
|
||||
.select('notifications.*', "array_append(wt.groups, #{group_key_sql}) AS groups"),
|
||||
]
|
||||
)
|
||||
.from('grouped_notifications AS notifications')
|
||||
.order(id: pagination_order)
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
# This returns notifications from the request page, but with at most one notification per group.
|
||||
# Notifications that have no `group_key` each count as a separate group.
|
||||
def paginate_groups_by_max_id(limit, max_id: nil, since_id: nil, grouped_types: nil)
|
||||
query = reorder(id: :desc)
|
||||
query = query.where(id: ...(max_id.to_i)) if max_id.present?
|
||||
query = query.where(id: (since_id.to_i + 1)...) if since_id.present?
|
||||
query.paginate_groups(limit, :desc, grouped_types: grouped_types)
|
||||
end
|
||||
|
||||
# Differs from :paginate_groups_by_max_id in that it gives the results immediately following min_id,
|
||||
# whereas since_id gives the items with largest id, but with since_id as a cutoff.
|
||||
# Results will be in ascending order by id.
|
||||
def paginate_groups_by_min_id(limit, max_id: nil, min_id: nil, grouped_types: nil)
|
||||
query = reorder(id: :asc)
|
||||
query = query.where(id: (min_id.to_i + 1)...) if min_id.present?
|
||||
query = query.where(id: ...(max_id.to_i)) if max_id.present?
|
||||
query.paginate_groups(limit, :asc, grouped_types: grouped_types)
|
||||
end
|
||||
|
||||
def to_a_grouped_paginated_by_id(limit, options = {})
|
||||
if options[:min_id].present?
|
||||
paginate_groups_by_min_id(limit, min_id: options[:min_id], max_id: options[:max_id], grouped_types: options[:grouped_types]).reverse
|
||||
else
|
||||
paginate_groups_by_max_id(limit, max_id: options[:max_id], since_id: options[:since_id], grouped_types: options[:grouped_types]).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def preload_cache_collection_target_statuses(notifications, &_block)
|
||||
notifications.group_by(&:type).each do |type, grouped_notifications|
|
||||
associations = TARGET_STATUS_INCLUDES_BY_TYPE[type]
|
||||
|
@ -309,7 +198,7 @@ class Notification < ApplicationRecord
|
|||
self.from_account_id = activity&.status&.account_id
|
||||
when 'Account'
|
||||
self.from_account_id = activity&.id
|
||||
when 'AccountRelationshipSeveranceEvent', 'AccountWarning'
|
||||
when 'AccountRelationshipSeveranceEvent', 'AccountWarning', 'GeneratedAnnualReport'
|
||||
# These do not really have an originating account, but this is mandatory
|
||||
# in the data model, and the recipient's account will by definition
|
||||
# always exist
|
||||
|
|
|
@ -51,6 +51,7 @@ class NotificationGroup < ActiveModelSerializers::Model
|
|||
:report,
|
||||
:account_relationship_severance_event,
|
||||
:account_warning,
|
||||
:generated_annual_report,
|
||||
to: :notification, prefix: false
|
||||
|
||||
class << self
|
||||
|
@ -63,21 +64,31 @@ class NotificationGroup < ActiveModelSerializers::Model
|
|||
binds = [
|
||||
account_id,
|
||||
SAMPLE_ACCOUNTS_SIZE,
|
||||
pagination_range.begin,
|
||||
pagination_range.end,
|
||||
ActiveRecord::Relation::QueryAttribute.new('group_keys', group_keys, ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(ActiveModel::Type::String.new)),
|
||||
pagination_range.begin || 0,
|
||||
]
|
||||
binds << pagination_range.end unless pagination_range.end.nil?
|
||||
|
||||
upper_bound_cond = begin
|
||||
if pagination_range.end.nil?
|
||||
''
|
||||
elsif pagination_range.exclude_end?
|
||||
'AND id < $5'
|
||||
else
|
||||
'AND id <= $5'
|
||||
end
|
||||
end
|
||||
|
||||
ActiveRecord::Base.connection.select_all(<<~SQL.squish, 'grouped_notifications', binds).cast_values.to_h { |k, *values| [k, values] }
|
||||
SELECT
|
||||
groups.group_key,
|
||||
(SELECT id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4 ORDER BY id DESC LIMIT 1),
|
||||
array(SELECT from_account_id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4 ORDER BY id DESC LIMIT $2),
|
||||
(SELECT count(*) FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4) AS notifications_count,
|
||||
(SELECT id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id >= $3 ORDER BY id ASC LIMIT 1) AS min_id,
|
||||
(SELECT created_at FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4 ORDER BY id DESC LIMIT 1)
|
||||
(SELECT id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key #{upper_bound_cond} ORDER BY id DESC LIMIT 1),
|
||||
array(SELECT from_account_id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key #{upper_bound_cond} ORDER BY id DESC LIMIT $2),
|
||||
(SELECT count(*) FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key #{upper_bound_cond}) AS notifications_count,
|
||||
(SELECT id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id >= $4 ORDER BY id ASC LIMIT 1) AS min_id,
|
||||
(SELECT created_at FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key #{upper_bound_cond} ORDER BY id DESC LIMIT 1)
|
||||
FROM
|
||||
unnest($5::text[]) AS groups(group_key);
|
||||
unnest($3::text[]) AS groups(group_key);
|
||||
SQL
|
||||
else
|
||||
binds = [
|
||||
|
|
|
@ -5,19 +5,19 @@
|
|||
# Table name: polls
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# status_id :bigint(8)
|
||||
# expires_at :datetime
|
||||
# options :string default([]), not null, is an Array
|
||||
# cached_tallies :bigint(8) default([]), not null, is an Array
|
||||
# multiple :boolean default(FALSE), not null
|
||||
# expires_at :datetime
|
||||
# hide_totals :boolean default(FALSE), not null
|
||||
# votes_count :bigint(8) default(0), not null
|
||||
# last_fetched_at :datetime
|
||||
# lock_version :integer default(0), not null
|
||||
# multiple :boolean default(FALSE), not null
|
||||
# options :string default([]), not null, is an Array
|
||||
# voters_count :bigint(8)
|
||||
# votes_count :bigint(8) default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# lock_version :integer default(0), not null
|
||||
# voters_count :bigint(8)
|
||||
# account_id :bigint(8) not null
|
||||
# status_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class Poll < ApplicationRecord
|
||||
|
@ -29,18 +29,16 @@ class Poll < ApplicationRecord
|
|||
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all
|
||||
|
||||
with_options class_name: 'Account', source: :account, through: :votes do
|
||||
has_many :voters, -> { group('accounts.id') }
|
||||
has_many :local_voters, -> { group('accounts.id').merge(Account.local) }
|
||||
has_many :voters, -> { group(accounts: [:id]) }
|
||||
has_many :local_voters, -> { group(accounts: [:id]).merge(Account.local) }
|
||||
end
|
||||
|
||||
has_many :notifications, as: :activity, dependent: :destroy
|
||||
|
||||
validates :options, presence: true
|
||||
validates :expires_at, presence: true, if: :local?
|
||||
validates_with PollValidator, on: :create, if: :local?
|
||||
|
||||
scope :attached, -> { where.not(status_id: nil) }
|
||||
scope :unattached, -> { where(status_id: nil) }
|
||||
validates_with PollOptionsValidator, if: :local?
|
||||
validates_with PollExpirationValidator, if: -> { local? && expires_at_changed? }
|
||||
|
||||
before_validation :prepare_options, if: :local?
|
||||
before_validation :prepare_votes_count
|
||||
|
@ -64,11 +62,7 @@ class Poll < ApplicationRecord
|
|||
votes.where(account: account).pluck(:choice)
|
||||
end
|
||||
|
||||
delegate :local?, to: :account
|
||||
|
||||
def remote?
|
||||
!local?
|
||||
end
|
||||
delegate :local?, :remote?, to: :account
|
||||
|
||||
def emojis
|
||||
@emojis ||= CustomEmoji.from_text(options.join(' '), account.domain)
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
# Table name: poll_votes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# poll_id :bigint(8)
|
||||
# choice :integer default(0), not null
|
||||
# uri :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# uri :string
|
||||
# account_id :bigint(8) not null
|
||||
# poll_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class PollVote < ApplicationRecord
|
||||
|
|
|
@ -134,7 +134,7 @@ class PreviewCard < ApplicationRecord
|
|||
end
|
||||
|
||||
def authors
|
||||
@authors ||= [PreviewCard::Author.new(self)]
|
||||
@authors ||= Array(serialized_authors)
|
||||
end
|
||||
|
||||
class Author < ActiveModelSerializers::Model
|
||||
|
@ -169,6 +169,13 @@ class PreviewCard < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def serialized_authors
|
||||
if author_name? || author_url? || author_account_id?
|
||||
PreviewCard::Author
|
||||
.new(self)
|
||||
end
|
||||
end
|
||||
|
||||
def extract_dimensions
|
||||
file = image.queued_for_write[:original]
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
#
|
||||
|
||||
class Relay < ApplicationRecord
|
||||
validates :inbox_url, presence: true, uniqueness: true, url: true, if: :will_save_change_to_inbox_url?
|
||||
validates :inbox_url, presence: true, uniqueness: true, url: true # rubocop:disable Rails/UniqueValidationWithoutIndex
|
||||
|
||||
enum :state, { idle: 0, pending: 1, accepted: 2, rejected: 3 }
|
||||
|
||||
|
@ -25,6 +25,10 @@ class Relay < ApplicationRecord
|
|||
|
||||
alias enabled? accepted?
|
||||
|
||||
def to_log_human_identifier
|
||||
inbox_url
|
||||
end
|
||||
|
||||
def enable!
|
||||
activity_id = ActivityPub::TagManager.instance.generate_uri_for(nil)
|
||||
payload = Oj.dump(follow_activity(activity_id))
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
# Table name: scheduled_statuses
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# scheduled_at :datetime
|
||||
# params :jsonb
|
||||
# scheduled_at :datetime
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class ScheduledStatus < ApplicationRecord
|
||||
|
@ -15,6 +15,7 @@ class ScheduledStatus < ApplicationRecord
|
|||
|
||||
TOTAL_LIMIT = 300
|
||||
DAILY_LIMIT = 25
|
||||
MINIMUM_OFFSET = 5.minutes.freeze
|
||||
|
||||
belongs_to :account, inverse_of: :scheduled_statuses
|
||||
has_many :media_attachments, inverse_of: :scheduled_status, dependent: :nullify
|
||||
|
@ -26,7 +27,7 @@ class ScheduledStatus < ApplicationRecord
|
|||
private
|
||||
|
||||
def validate_future_date
|
||||
errors.add(:scheduled_at, I18n.t('scheduled_statuses.too_soon')) if scheduled_at.present? && scheduled_at <= Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET
|
||||
errors.add(:scheduled_at, I18n.t('scheduled_statuses.too_soon')) if scheduled_at.present? && scheduled_at <= Time.now.utc + MINIMUM_OFFSET
|
||||
end
|
||||
|
||||
def validate_total_limit
|
||||
|
|
|
@ -30,13 +30,15 @@ class SessionActivation < ApplicationRecord
|
|||
|
||||
DEFAULT_SCOPES = %w(read write follow).freeze
|
||||
|
||||
scope :latest, -> { order(id: :desc) }
|
||||
|
||||
class << self
|
||||
def active?(id)
|
||||
id && exists?(session_id: id)
|
||||
end
|
||||
|
||||
def activate(**options)
|
||||
activation = create!(**options)
|
||||
def activate(**)
|
||||
activation = create!(**)
|
||||
purge_old
|
||||
activation
|
||||
end
|
||||
|
@ -48,7 +50,7 @@ class SessionActivation < ApplicationRecord
|
|||
end
|
||||
|
||||
def purge_old
|
||||
order('created_at desc').offset(Rails.configuration.x.max_session_activations).destroy_all
|
||||
latest.offset(Rails.configuration.x.max_session_activations).destroy_all
|
||||
end
|
||||
|
||||
def exclusive(id)
|
||||
|
|
|
@ -7,10 +7,8 @@
|
|||
# id :bigint(8) not null, primary key
|
||||
# var :string not null
|
||||
# value :text
|
||||
# thing_type :string
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# thing_id :bigint(8)
|
||||
#
|
||||
|
||||
# This file is derived from a fork of the `rails-settings-cached` gem available at
|
||||
|
@ -46,10 +44,10 @@ class Setting < ApplicationRecord
|
|||
after_commit :rewrite_cache, on: %i(create update)
|
||||
after_commit :expire_cache, on: %i(destroy)
|
||||
|
||||
# Settings are server-wide settings only, but they were previously
|
||||
# used for users too. This can be dropped later with a database
|
||||
# migration dropping any scoped setting.
|
||||
default_scope { where(thing_type: nil, thing_id: nil) }
|
||||
self.ignored_columns += %w(
|
||||
thing_id
|
||||
thing_type
|
||||
)
|
||||
|
||||
class << self
|
||||
# get or set a variable with the variable as the called method
|
||||
|
|
|
@ -18,23 +18,43 @@ class SoftwareUpdate < ApplicationRecord
|
|||
|
||||
enum :type, { patch: 0, minor: 1, major: 2 }, suffix: :type
|
||||
|
||||
scope :urgent, -> { where(urgent: true) }
|
||||
|
||||
def gem_version
|
||||
Gem::Version.new(version)
|
||||
end
|
||||
|
||||
def outdated?
|
||||
runtime_version >= gem_version
|
||||
end
|
||||
|
||||
def pending?
|
||||
gem_version > runtime_version
|
||||
end
|
||||
|
||||
class << self
|
||||
def check_enabled?
|
||||
ENV['UPDATE_CHECK_URL'] != ''
|
||||
Rails.configuration.x.mastodon.software_update_url.present?
|
||||
end
|
||||
|
||||
def by_version
|
||||
all.sort_by(&:gem_version)
|
||||
end
|
||||
|
||||
def pending_to_a
|
||||
return [] unless check_enabled?
|
||||
|
||||
all.to_a.filter { |update| update.gem_version > Mastodon::Version.gem_version }
|
||||
all.to_a.filter(&:pending?)
|
||||
end
|
||||
|
||||
def urgent_pending?
|
||||
pending_to_a.any?(&:urgent?)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def runtime_version
|
||||
Mastodon::Version.gem_version
|
||||
end
|
||||
end
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
# edited_at :datetime
|
||||
# trendable :boolean
|
||||
# ordered_media_attachment_ids :bigint(8) is an Array
|
||||
# fetched_replies_at :datetime
|
||||
#
|
||||
|
||||
class Status < ApplicationRecord
|
||||
|
@ -34,10 +35,12 @@ class Status < ApplicationRecord
|
|||
include Discard::Model
|
||||
include Paginable
|
||||
include RateLimitable
|
||||
include Status::FetchRepliesConcern
|
||||
include Status::SafeReblogInsert
|
||||
include Status::SearchConcern
|
||||
include Status::SnapshotConcern
|
||||
include Status::ThreadingConcern
|
||||
include Status::Visibility
|
||||
|
||||
MEDIA_ATTACHMENTS_LIMIT = 16
|
||||
|
||||
|
@ -52,8 +55,6 @@ class Status < ApplicationRecord
|
|||
update_index('statuses', :proper)
|
||||
update_index('public_statuses', :proper)
|
||||
|
||||
enum :visibility, { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 }, suffix: :visibility, validate: true
|
||||
|
||||
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
|
||||
|
||||
belongs_to :account, inverse_of: :statuses
|
||||
|
@ -98,7 +99,6 @@ class Status < ApplicationRecord
|
|||
validates_with StatusLengthValidator
|
||||
validates_with DisallowedHashtagsValidator
|
||||
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
|
||||
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
|
||||
|
||||
accepts_nested_attributes_for :poll
|
||||
|
||||
|
@ -125,8 +125,6 @@ class Status < ApplicationRecord
|
|||
scope :tagged_with_none, lambda { |tag_ids|
|
||||
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
|
||||
}
|
||||
scope :distributable_visibility, -> { where(visibility: %i(public unlisted)) }
|
||||
scope :list_eligible_visibility, -> { where(visibility: %i(public unlisted private)) }
|
||||
|
||||
after_create_commit :trigger_create_webhooks
|
||||
after_update_commit :trigger_update_webhooks
|
||||
|
@ -139,7 +137,6 @@ class Status < ApplicationRecord
|
|||
|
||||
before_validation :prepare_contents, if: :local?
|
||||
before_validation :set_reblog
|
||||
before_validation :set_visibility
|
||||
before_validation :set_conversation
|
||||
before_validation :set_local
|
||||
|
||||
|
@ -241,16 +238,6 @@ class Status < ApplicationRecord
|
|||
PreviewCardsStatus.where(status_id: id).delete_all
|
||||
end
|
||||
|
||||
def hidden?
|
||||
!distributable?
|
||||
end
|
||||
|
||||
def distributable?
|
||||
public_visibility? || unlisted_visibility?
|
||||
end
|
||||
|
||||
alias sign? distributable?
|
||||
|
||||
def with_media?
|
||||
ordered_media_attachments.any?
|
||||
end
|
||||
|
@ -350,28 +337,24 @@ class Status < ApplicationRecord
|
|||
end
|
||||
|
||||
class << self
|
||||
def selectable_visibilities
|
||||
visibilities.keys - %w(direct limited)
|
||||
end
|
||||
|
||||
def favourites_map(status_ids, account_id)
|
||||
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
|
||||
Favourite.select(:status_id).where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
|
||||
end
|
||||
|
||||
def bookmarks_map(status_ids, account_id)
|
||||
Bookmark.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
|
||||
Bookmark.select(:status_id).where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
|
||||
end
|
||||
|
||||
def reblogs_map(status_ids, account_id)
|
||||
unscoped.select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).each_with_object({}) { |s, h| h[s.reblog_of_id] = true }
|
||||
unscoped.select(:reblog_of_id).where(reblog_of_id: status_ids).where(account_id: account_id).each_with_object({}) { |s, h| h[s.reblog_of_id] = true }
|
||||
end
|
||||
|
||||
def mutes_map(conversation_ids, account_id)
|
||||
ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
|
||||
ConversationMute.select(:conversation_id).where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
|
||||
end
|
||||
|
||||
def pins_map(status_ids, account_id)
|
||||
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||
StatusPin.select(:status_id).where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||
end
|
||||
|
||||
def from_text(text)
|
||||
|
@ -435,11 +418,6 @@ class Status < ApplicationRecord
|
|||
update_column(:poll_id, poll.id) if association(:poll).loaded? && poll.present?
|
||||
end
|
||||
|
||||
def set_visibility
|
||||
self.visibility = reblog.visibility if reblog? && visibility.nil?
|
||||
self.visibility = (account.locked? ? :private : :public) if visibility.nil?
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
self.thread = thread.reblog if thread&.reblog?
|
||||
|
||||
|
|
|
@ -32,12 +32,14 @@ class Tag < ApplicationRecord
|
|||
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
|
||||
has_many :followers, through: :passive_relationships, source: :account
|
||||
|
||||
has_one :trend, class_name: 'TagTrend', inverse_of: :tag, dependent: :destroy
|
||||
|
||||
HASHTAG_SEPARATORS = "_\u00B7\u30FB\u200c"
|
||||
HASHTAG_FIRST_SEQUENCE_CHUNK_ONE = "[[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}]"
|
||||
HASHTAG_FIRST_SEQUENCE_CHUNK_TWO = "[[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_]"
|
||||
HASHTAG_FIRST_SEQUENCE = "(#{HASHTAG_FIRST_SEQUENCE_CHUNK_ONE}#{HASHTAG_FIRST_SEQUENCE_CHUNK_TWO})"
|
||||
HASHTAG_FIRST_SEQUENCE_CHUNK_ONE = "[[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}]".freeze
|
||||
HASHTAG_FIRST_SEQUENCE_CHUNK_TWO = "[[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_]".freeze
|
||||
HASHTAG_FIRST_SEQUENCE = "(#{HASHTAG_FIRST_SEQUENCE_CHUNK_ONE}#{HASHTAG_FIRST_SEQUENCE_CHUNK_TWO})".freeze
|
||||
HASHTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)'
|
||||
HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASHTAG_LAST_SEQUENCE}"
|
||||
HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASHTAG_LAST_SEQUENCE}".freeze
|
||||
|
||||
HASHTAG_RE = %r{(?<![=/)\p{Alnum}])#(#{HASHTAG_NAME_PAT})}
|
||||
HASHTAG_NAME_RE = /\A(#{HASHTAG_NAME_PAT})\z/i
|
||||
|
@ -61,7 +63,7 @@ class Tag < ApplicationRecord
|
|||
scope :recently_used, lambda { |account|
|
||||
joins(:statuses)
|
||||
.where(statuses: { id: account.statuses.select(:id).limit(RECENT_STATUS_LIMIT) })
|
||||
.group(:id).order(Arel.sql('count(*) desc'))
|
||||
.group(:id).order(Arel.star.count.desc)
|
||||
}
|
||||
scope :matches_name, ->(term) { where(arel_table[:name].lower.matches(arel_table.lower("#{sanitize_sql_like(Tag.normalize(term))}%"), nil, true)) } # Search with case-sensitive to use B-tree index
|
||||
|
||||
|
@ -127,7 +129,7 @@ class Tag < ApplicationRecord
|
|||
query = query.merge(Tag.listable) if options[:exclude_unlistable]
|
||||
query = query.merge(matching_name(stripped_term).or(reviewed)) if options[:exclude_unreviewed]
|
||||
|
||||
query.order(Arel.sql('length(name) ASC, name ASC'))
|
||||
query.order(Arel.sql('LENGTH(name)').asc, name: :asc)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
end
|
||||
|
@ -158,11 +160,11 @@ class Tag < ApplicationRecord
|
|||
private
|
||||
|
||||
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?
|
||||
errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.casecmp(name).zero?
|
||||
end
|
||||
|
||||
def validate_display_name_change
|
||||
unless HashtagNormalizer.new.normalize(display_name).casecmp(name.mb_chars).zero?
|
||||
unless HashtagNormalizer.new.normalize(display_name).casecmp(name).zero?
|
||||
errors.add(:display_name,
|
||||
I18n.t('tags.does_not_match_previous_name'))
|
||||
end
|
||||
|
|
|
@ -21,4 +21,6 @@ class TagFollow < ApplicationRecord
|
|||
accepts_nested_attributes_for :tag
|
||||
|
||||
rate_limit by: :account, family: :follows
|
||||
|
||||
scope :for_local_distribution, -> { joins(account: :user).merge(User.signed_in_recently) }
|
||||
end
|
||||
|
|
21
app/models/tag_trend.rb
Normal file
21
app/models/tag_trend.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: tag_trends
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# allowed :boolean default(FALSE), not null
|
||||
# language :string default(""), not null
|
||||
# rank :integer default(0), not null
|
||||
# score :float default(0.0), not null
|
||||
# tag_id :bigint(8) not null
|
||||
#
|
||||
class TagTrend < ApplicationRecord
|
||||
include RankedTrend
|
||||
|
||||
belongs_to :tag
|
||||
|
||||
scope :allowed, -> { where(allowed: true) }
|
||||
scope :not_allowed, -> { where(allowed: false) }
|
||||
end
|
55
app/models/terms_of_service.rb
Normal file
55
app/models/terms_of_service.rb
Normal file
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: terms_of_services
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# changelog :text default(""), not null
|
||||
# effective_date :date
|
||||
# notification_sent_at :datetime
|
||||
# published_at :datetime
|
||||
# text :text default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class TermsOfService < ApplicationRecord
|
||||
scope :published, -> { where.not(published_at: nil).order(Arel.sql('coalesce(effective_date, published_at) DESC')) }
|
||||
scope :live, -> { published.where('effective_date IS NULL OR effective_date < now()').limit(1) }
|
||||
scope :draft, -> { where(published_at: nil).order(id: :desc).limit(1) }
|
||||
|
||||
validates :text, presence: true
|
||||
validates :changelog, :effective_date, presence: true, if: -> { published? }
|
||||
|
||||
validate :effective_date_cannot_be_in_the_past
|
||||
|
||||
def published?
|
||||
published_at.present?
|
||||
end
|
||||
|
||||
def effective?
|
||||
published? && effective_date&.past?
|
||||
end
|
||||
|
||||
def succeeded_by
|
||||
TermsOfService.published.where(effective_date: (effective_date..)).where.not(id: id).first
|
||||
end
|
||||
|
||||
def notification_sent?
|
||||
notification_sent_at.present?
|
||||
end
|
||||
|
||||
def scope_for_notification
|
||||
User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def effective_date_cannot_be_in_the_past
|
||||
return if effective_date.blank?
|
||||
|
||||
min_date = TermsOfService.live.pick(:effective_date) || Time.zone.today
|
||||
|
||||
errors.add(:effective_date, :too_soon, date: min_date) if effective_date < min_date
|
||||
end
|
||||
end
|
27
app/models/terms_of_service/generator.rb
Normal file
27
app/models/terms_of_service/generator.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TermsOfService::Generator
|
||||
include ActiveModel::Model
|
||||
|
||||
TEMPLATE = Rails.root.join('config', 'templates', 'terms-of-service.md').read
|
||||
|
||||
VARIABLES = %i(
|
||||
admin_email
|
||||
arbitration_address
|
||||
arbitration_website
|
||||
choice_of_law
|
||||
dmca_address
|
||||
dmca_email
|
||||
domain
|
||||
jurisdiction
|
||||
min_age
|
||||
).freeze
|
||||
|
||||
attr_accessor(*VARIABLES)
|
||||
|
||||
validates(*VARIABLES, presence: true)
|
||||
|
||||
def render
|
||||
format(TEMPLATE, VARIABLES.index_with { |key| public_send(key) })
|
||||
end
|
||||
end
|
|
@ -5,13 +5,15 @@
|
|||
# Table name: tombstones
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# by_moderator :boolean
|
||||
# uri :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# by_moderator :boolean
|
||||
# account_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class Tombstone < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validates :uri, presence: true
|
||||
end
|
||||
|
|
|
@ -34,19 +34,7 @@ class Trends::Base
|
|||
end
|
||||
|
||||
def query
|
||||
Trends::Query.new(key_prefix, klass)
|
||||
end
|
||||
|
||||
def score(id, locale: nil)
|
||||
redis.zscore([key_prefix, 'all', locale].compact.join(':'), id) || 0
|
||||
end
|
||||
|
||||
def rank(id, locale: nil)
|
||||
redis.zrevrank([key_prefix, 'allowed', locale].compact.join(':'), id)
|
||||
end
|
||||
|
||||
def currently_trending_ids(allowed, limit)
|
||||
redis.zrevrange(allowed ? "#{key_prefix}:allowed" : "#{key_prefix}:all", 0, limit.positive? ? limit - 1 : limit).map(&:to_i)
|
||||
Trends::Query.new(klass)
|
||||
end
|
||||
|
||||
protected
|
||||
|
@ -64,42 +52,9 @@ class Trends::Base
|
|||
redis.expire(used_key(at_time), 1.day.seconds)
|
||||
end
|
||||
|
||||
def score_at_rank(rank)
|
||||
redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
|
||||
end
|
||||
|
||||
def replace_items(suffix, items)
|
||||
tmp_prefix = "#{key_prefix}:tmp:#{SecureRandom.alphanumeric(6)}#{suffix}"
|
||||
allowed_items = filter_for_allowed_items(items)
|
||||
|
||||
redis.pipelined do |pipeline|
|
||||
items.each { |item| pipeline.zadd("#{tmp_prefix}:all", item[:score], item[:item].id) }
|
||||
allowed_items.each { |item| pipeline.zadd("#{tmp_prefix}:allowed", item[:score], item[:item].id) }
|
||||
|
||||
rename_set(pipeline, "#{tmp_prefix}:all", "#{key_prefix}:all#{suffix}", items)
|
||||
rename_set(pipeline, "#{tmp_prefix}:allowed", "#{key_prefix}:allowed#{suffix}", allowed_items)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_for_allowed_items(items)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def used_key(at_time)
|
||||
"#{key_prefix}:used:#{at_time.beginning_of_day.to_i}"
|
||||
end
|
||||
|
||||
def rename_set(pipeline, from_key, to_key, set_items)
|
||||
if set_items.empty?
|
||||
pipeline.del(to_key)
|
||||
else
|
||||
pipeline.rename(from_key, to_key)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_review?
|
||||
Setting.trendable_by_default
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,18 +14,9 @@ class Trends::Links < Trends::Base
|
|||
}
|
||||
|
||||
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.reorder(language_order_clause, 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?
|
||||
|
@ -34,16 +25,8 @@ class Trends::Links < Trends::Base
|
|||
|
||||
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
|
||||
def trend_class
|
||||
PreviewCardTrend
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -85,7 +68,7 @@ class Trends::Links < Trends::Base
|
|||
end
|
||||
|
||||
def request_review
|
||||
PreviewCardTrend.pluck('distinct language').flat_map do |language|
|
||||
PreviewCardTrend.locales.flat_map do |language|
|
||||
score_at_threshold = PreviewCardTrend.where(language: language).allowed.by_rank.ranked_below(options[:review_threshold]).first&.score || 0
|
||||
preview_card_trends = PreviewCardTrend.where(language: language).not_allowed.joins(:preview_card)
|
||||
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trends::Query
|
||||
include Redisable
|
||||
include Enumerable
|
||||
|
||||
attr_reader :prefix, :klass, :loaded
|
||||
attr_reader :klass, :loaded
|
||||
|
||||
alias loaded? loaded
|
||||
|
||||
def initialize(prefix, klass)
|
||||
@prefix = prefix
|
||||
def initialize(_prefix, klass)
|
||||
@klass = klass
|
||||
@records = []
|
||||
@loaded = false
|
||||
@allowed = false
|
||||
@account = nil
|
||||
@limit = nil
|
||||
@offset = nil
|
||||
end
|
||||
|
@ -27,6 +26,15 @@ class Trends::Query
|
|||
clone.allowed!
|
||||
end
|
||||
|
||||
def filtered_for!(account)
|
||||
@account = account
|
||||
self
|
||||
end
|
||||
|
||||
def filtered_for(account)
|
||||
clone.filtered_for!(account)
|
||||
end
|
||||
|
||||
def in_locale!(value)
|
||||
@locale = value
|
||||
self
|
||||
|
@ -68,22 +76,11 @@ class Trends::Query
|
|||
alias to_a to_ary
|
||||
|
||||
def to_arel
|
||||
if ids_for_key.empty?
|
||||
klass.none
|
||||
else
|
||||
scope = klass.joins(sanitized_join_sql).reorder('x.ordering')
|
||||
scope = scope.offset(@offset) if @offset.present?
|
||||
scope = scope.limit(@limit) if @limit.present?
|
||||
scope
|
||||
end
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def key
|
||||
[@prefix, @allowed ? 'allowed' : 'all', @locale].compact.join(':')
|
||||
end
|
||||
|
||||
def load
|
||||
unless loaded?
|
||||
@records = perform_queries
|
||||
|
@ -93,29 +90,25 @@ class Trends::Query
|
|||
self
|
||||
end
|
||||
|
||||
def ids_for_key
|
||||
@ids_for_key ||= redis.zrevrange(key, 0, -1).map(&:to_i)
|
||||
end
|
||||
|
||||
def sanitized_join_sql
|
||||
ActiveRecord::Base.sanitize_sql_array(join_sql_array)
|
||||
end
|
||||
|
||||
def join_sql_array
|
||||
[join_sql_query, ids_for_key]
|
||||
end
|
||||
|
||||
def join_sql_query
|
||||
<<~SQL.squish
|
||||
JOIN unnest(array[?]) WITH ordinality AS x (id, ordering) ON #{klass.table_name}.id = x.id
|
||||
SQL
|
||||
end
|
||||
|
||||
def perform_queries
|
||||
apply_scopes(to_arel).to_a
|
||||
to_arel.to_a
|
||||
end
|
||||
|
||||
def apply_scopes(scope)
|
||||
scope
|
||||
def language_order_clause
|
||||
Arel::Nodes::Case.new.when(language_is_preferred).then(1).else(0).desc
|
||||
end
|
||||
|
||||
def language_is_preferred
|
||||
trend_class
|
||||
.arel_table[:language]
|
||||
.in(preferred_languages)
|
||||
end
|
||||
|
||||
def preferred_languages
|
||||
if @account&.chosen_languages.present?
|
||||
@account.chosen_languages
|
||||
else
|
||||
@locale
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,18 +13,9 @@ class Trends::Statuses < Trends::Base
|
|||
}
|
||||
|
||||
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 = Status.joins(:trend).reorder(score: :desc)
|
||||
scope = scope.reorder(language_order_clause.desc, score: :desc) if preferred_languages.present?
|
||||
scope = scope.reorder(language_order_clause, 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?
|
||||
|
@ -34,16 +25,8 @@ class Trends::Statuses < Trends::Base
|
|||
|
||||
private
|
||||
|
||||
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
|
||||
@locale
|
||||
end
|
||||
def trend_class
|
||||
StatusTrend
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -78,7 +61,7 @@ class Trends::Statuses < Trends::Base
|
|||
end
|
||||
|
||||
def request_review
|
||||
StatusTrend.pluck('distinct language').flat_map do |language|
|
||||
StatusTrend.locales.flat_map do |language|
|
||||
score_at_threshold = StatusTrend.where(language: language, allowed: true).by_rank.ranked_below(options[:review_threshold]).first&.score || 0
|
||||
status_trends = StatusTrend.where(language: language, allowed: false).joins(:status).includes(status: :account)
|
||||
|
||||
|
@ -106,7 +89,7 @@ class Trends::Statuses < Trends::Base
|
|||
private
|
||||
|
||||
def eligible?(status)
|
||||
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && !status.account.sensitized? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
|
||||
status.created_at.past? && status.public_visibility? && status.account.discoverable? && !status.account.silenced? && !status.account.sensitized? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
|
||||
end
|
||||
|
||||
def calculate_scores(statuses, at_time)
|
||||
|
|
|
@ -6,6 +6,8 @@ class Trends::TagFilter
|
|||
status
|
||||
).freeze
|
||||
|
||||
IGNORED_PARAMS = %w(page).freeze
|
||||
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
|
@ -13,14 +15,10 @@ class Trends::TagFilter
|
|||
end
|
||||
|
||||
def results
|
||||
scope = if params[:status] == 'pending_review'
|
||||
Tag.unscoped.order(id: :desc)
|
||||
else
|
||||
trending_scope
|
||||
end
|
||||
scope = initial_scope
|
||||
|
||||
params.each do |key, value|
|
||||
next if key.to_s == 'page'
|
||||
next if IGNORED_PARAMS.include?(key.to_s)
|
||||
|
||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||
end
|
||||
|
@ -30,19 +28,24 @@ class Trends::TagFilter
|
|||
|
||||
private
|
||||
|
||||
def initial_scope
|
||||
Tag.select(Tag.arel_table[Arel.star])
|
||||
.joins(:trend)
|
||||
.eager_load(:trend)
|
||||
.reorder(score: :desc)
|
||||
end
|
||||
|
||||
def scope_for(key, value)
|
||||
case key.to_s
|
||||
when 'status'
|
||||
status_scope(value)
|
||||
when 'trending'
|
||||
trending_scope(value)
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
raise Mastodon::InvalidParameterError, "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
def trending_scope
|
||||
Trends.tags.query.to_arel
|
||||
end
|
||||
|
||||
def status_scope(value)
|
||||
case value.to_s
|
||||
when 'approved'
|
||||
|
@ -52,7 +55,16 @@ class Trends::TagFilter
|
|||
when 'pending_review'
|
||||
Tag.pending_review
|
||||
else
|
||||
raise "Unknown status: #{value}"
|
||||
raise Mastodon::InvalidParameterError, "Unknown status: #{value}"
|
||||
end
|
||||
end
|
||||
|
||||
def trending_scope(value)
|
||||
case value
|
||||
when 'allowed'
|
||||
TagTrend.allowed
|
||||
else
|
||||
TagTrend.all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
class Trends::Tags < Trends::Base
|
||||
PREFIX = 'trending_tags'
|
||||
|
||||
BATCH_SIZE = 100
|
||||
|
||||
self.default_options = {
|
||||
threshold: 5,
|
||||
review_threshold: 3,
|
||||
|
@ -11,6 +13,23 @@ class Trends::Tags < Trends::Base
|
|||
decay_threshold: 1,
|
||||
}
|
||||
|
||||
class Query < Trends::Query
|
||||
def to_arel
|
||||
scope = Tag.joins(:trend).reorder(score: :desc)
|
||||
scope = scope.reorder(language_order_clause, score: :desc) if preferred_languages.present?
|
||||
scope = scope.merge(TagTrend.allowed) if @allowed
|
||||
scope = scope.offset(@offset) if @offset.present?
|
||||
scope = scope.limit(@limit) if @limit.present?
|
||||
scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trend_class
|
||||
TagTrend
|
||||
end
|
||||
end
|
||||
|
||||
def register(status, at_time = Time.now.utc)
|
||||
return unless !status.reblog? && status.public_visibility? && !status.account.silenced?
|
||||
|
||||
|
@ -24,19 +43,39 @@ class Trends::Tags < Trends::Base
|
|||
record_used_id(tag.id, at_time)
|
||||
end
|
||||
|
||||
def query
|
||||
Query.new(key_prefix, klass)
|
||||
end
|
||||
|
||||
def refresh(at_time = Time.now.utc)
|
||||
tags = Tag.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
|
||||
calculate_scores(tags, at_time)
|
||||
# First, recalculate scores for tags that were trending previously. We split the queries
|
||||
# to avoid having to load all of the IDs into Ruby just to send them back into Postgres
|
||||
Tag.where(id: TagTrend.select(:tag_id)).find_in_batches(batch_size: BATCH_SIZE) do |tags|
|
||||
calculate_scores(tags, at_time)
|
||||
end
|
||||
|
||||
# Then, calculate scores for tags that were used today. There are potentially some
|
||||
# duplicate items here that we might process one more time, but that should be fine
|
||||
Tag.where(id: recently_used_ids(at_time)).find_in_batches(batch_size: BATCH_SIZE) do |tags|
|
||||
calculate_scores(tags, at_time)
|
||||
end
|
||||
|
||||
# Now that all trends have up-to-date scores, and all the ones below the threshold have
|
||||
# been removed, we can recalculate their positions
|
||||
TagTrend.recalculate_ordered_rank
|
||||
end
|
||||
|
||||
def request_review
|
||||
tags = Tag.where(id: currently_trending_ids(false, -1))
|
||||
score_at_threshold = TagTrend.allowed.by_rank.ranked_below(options[:review_threshold]).first&.score || 0
|
||||
tag_trends = TagTrend.not_allowed.includes(:tag)
|
||||
|
||||
tags.filter_map do |tag|
|
||||
next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification?
|
||||
tag_trends.filter_map do |trend|
|
||||
tag = trend.tag
|
||||
|
||||
tag.touch(:requested_review_at)
|
||||
tag
|
||||
if trend.score > score_at_threshold && !tag.trendable? && tag.requires_review_notification?
|
||||
tag.touch(:requested_review_at)
|
||||
tag
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -53,9 +92,7 @@ class Trends::Tags < Trends::Base
|
|||
private
|
||||
|
||||
def calculate_scores(tags, at_time)
|
||||
items = []
|
||||
|
||||
tags.each do |tag|
|
||||
items = tags.map do |tag|
|
||||
expected = tag.history.get(at_time - 1.day).accounts.to_f
|
||||
expected = 1.0 if expected.zero?
|
||||
observed = tag.history.get(at_time).accounts.to_f
|
||||
|
@ -79,19 +116,13 @@ class Trends::Tags < Trends::Base
|
|||
|
||||
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
||||
|
||||
next unless decaying_score >= options[:decay_threshold]
|
||||
|
||||
items << { score: decaying_score, item: tag }
|
||||
[decaying_score, tag]
|
||||
end
|
||||
|
||||
replace_items('', items)
|
||||
end
|
||||
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
|
||||
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
|
||||
|
||||
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)
|
||||
TagTrend.upsert_all(to_insert.map { |(score, tag)| { tag_id: tag.id, score: score, language: '', allowed: tag.trendable? || false } }, unique_by: %w(tag_id language)) if to_insert.any?
|
||||
TagTrend.where(tag_id: to_delete.map { |(_, tag)| tag.id }).delete_all if to_delete.any?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -125,7 +125,7 @@ class User < ApplicationRecord
|
|||
scope :signed_in_recently, -> { where(current_sign_in_at: ACTIVE_DURATION.ago..) }
|
||||
scope :not_signed_in_recently, -> { where(current_sign_in_at: ...ACTIVE_DURATION.ago) }
|
||||
scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
|
||||
scope :matches_ip, ->(value) { left_joins(:ips).where('user_ips.ip <<= ?', value).group('users.id') }
|
||||
scope :matches_ip, ->(value) { left_joins(:ips).merge(IpBlock.contained_by(value)).group(users: [:id]) }
|
||||
|
||||
before_validation :sanitize_role
|
||||
before_create :set_approved
|
||||
|
@ -165,6 +165,10 @@ class User < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def signed_in_recently?
|
||||
current_sign_in_at.present? && current_sign_in_at >= ACTIVE_DURATION.ago
|
||||
end
|
||||
|
||||
def confirmed?
|
||||
confirmed_at.present?
|
||||
end
|
||||
|
@ -280,6 +284,15 @@ class User < ApplicationRecord
|
|||
save!
|
||||
end
|
||||
|
||||
def applications_last_used
|
||||
Doorkeeper::AccessToken
|
||||
.where(resource_owner_id: id)
|
||||
.where.not(last_used_at: nil)
|
||||
.group(:application_id)
|
||||
.maximum(:last_used_at)
|
||||
.to_h
|
||||
end
|
||||
|
||||
def token_for_app(app)
|
||||
return nil if app.nil? || app.owner != self
|
||||
|
||||
|
@ -337,10 +350,10 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def revoke_access!
|
||||
Doorkeeper::AccessGrant.by_resource_owner(self).update_all(revoked_at: Time.now.utc)
|
||||
Doorkeeper::AccessGrant.by_resource_owner(self).touch_all(:revoked_at)
|
||||
|
||||
Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
|
||||
batch.update_all(revoked_at: Time.now.utc)
|
||||
batch.touch_all(:revoked_at)
|
||||
Web::PushSubscription.where(access_token_id: batch).delete_all
|
||||
|
||||
# Revoke each access token for the Streaming API, since `update_all``
|
||||
|
@ -405,8 +418,8 @@ class User < ApplicationRecord
|
|||
@pending_devise_notifications ||= []
|
||||
end
|
||||
|
||||
def render_and_send_devise_message(notification, *args, **kwargs)
|
||||
devise_mailer.send(notification, self, *args, **kwargs).deliver_later
|
||||
def render_and_send_devise_message(notification, *, **)
|
||||
devise_mailer.send(notification, self, *, **).deliver_later
|
||||
end
|
||||
|
||||
def set_approved
|
||||
|
@ -444,7 +457,7 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def sign_up_from_ip_requires_approval?
|
||||
sign_up_ip.present? && IpBlock.severity_sign_up_requires_approval.exists?(['ip >>= ?', sign_up_ip.to_s])
|
||||
sign_up_ip.present? && IpBlock.severity_sign_up_requires_approval.containing(sign_up_ip.to_s).exists?
|
||||
end
|
||||
|
||||
def sign_up_email_requires_approval?
|
||||
|
@ -457,13 +470,7 @@ class User < ApplicationRecord
|
|||
|
||||
# Doing this conditionally is not very satisfying, but this is consistent
|
||||
# with the MX records validations we do and keeps the specs tractable.
|
||||
unless self.class.skip_mx_check?
|
||||
Resolv::DNS.open do |dns|
|
||||
dns.timeouts = 5
|
||||
|
||||
records = dns.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }.compact_blank
|
||||
end
|
||||
end
|
||||
records = DomainResource.new(domain).mx unless self.class.skip_mx_check?
|
||||
|
||||
EmailDomainBlock.requires_approval?(records + [domain], attempt_ip: sign_up_ip)
|
||||
end
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
# Table name: user_invite_requests
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# user_id :bigint(8)
|
||||
# text :text
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_id :bigint(8) not null
|
||||
#
|
||||
|
||||
class UserInviteRequest < ApplicationRecord
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
class UserIp < ApplicationRecord
|
||||
include DatabaseViewRecord
|
||||
include InetContainer
|
||||
|
||||
self.primary_key = :user_id
|
||||
|
||||
|
|
|
@ -41,6 +41,9 @@ class UserRole < ApplicationRecord
|
|||
EVERYONE_ROLE_ID = -99
|
||||
NOBODY_POSITION = -1
|
||||
|
||||
POSITION_LIMIT = (2**31) - 1
|
||||
CSS_COLORS = /\A#?(?:[A-F0-9]{3}){1,2}\z/i # CSS-style hex colors
|
||||
|
||||
module Flags
|
||||
NONE = 0
|
||||
ALL = FLAGS.values.reduce(&:|)
|
||||
|
@ -88,7 +91,8 @@ class UserRole < ApplicationRecord
|
|||
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? }
|
||||
validates :color, format: { with: CSS_COLORS }, if: :color?
|
||||
validates :position, numericality: { in: (-POSITION_LIMIT..POSITION_LIMIT) }
|
||||
|
||||
validate :validate_permissions_elevation
|
||||
validate :validate_position_elevation
|
||||
|
@ -98,9 +102,6 @@ class UserRole < ApplicationRecord
|
|||
before_validation :set_position
|
||||
|
||||
scope :assignable, -> { where.not(id: EVERYONE_ROLE_ID).order(position: :asc) }
|
||||
scope :highlighted, -> { where(highlighted: true) }
|
||||
scope :with_color, -> { where.not(color: [nil, '']) }
|
||||
scope :providing_styles, -> { highlighted.with_color }
|
||||
|
||||
has_many :users, inverse_of: :role, foreign_key: 'role_id', dependent: :nullify
|
||||
|
||||
|
@ -142,6 +143,10 @@ class UserRole < ApplicationRecord
|
|||
other_role.nil? || position > other_role.position
|
||||
end
|
||||
|
||||
def bypass_block?(role)
|
||||
overrides?(role) && highlighted? && can?(*Flags::CATEGORIES[:moderation])
|
||||
end
|
||||
|
||||
def computed_permissions
|
||||
# If called on the everyone role, no further computation needed
|
||||
return permissions if everyone?
|
||||
|
|
|
@ -24,10 +24,12 @@ class UserSettings
|
|||
setting :use_blurhash, default: true
|
||||
setting :use_pending_items, default: false
|
||||
setting :use_system_font, default: false
|
||||
setting :use_system_scrollbars, default: false
|
||||
setting :disable_swiping, default: false
|
||||
setting :disable_hover_cards, default: false
|
||||
setting :delete_modal, default: true
|
||||
setting :reblog_modal, default: false
|
||||
setting :missing_alt_text_modal, default: true
|
||||
setting :reduce_motion, default: false
|
||||
setting :expand_content_warnings, default: false
|
||||
setting :display_media, default: 'default', in: %w(default show_all hide_all)
|
||||
|
|
|
@ -5,10 +5,11 @@
|
|||
# Table name: web_push_subscriptions
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# endpoint :string not null
|
||||
# key_p256dh :string not null
|
||||
# key_auth :string not null
|
||||
# data :json
|
||||
# endpoint :string not null
|
||||
# key_auth :string not null
|
||||
# key_p256dh :string not null
|
||||
# standard :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# access_token_id :bigint(8)
|
||||
|
|
|
@ -53,7 +53,7 @@ class Webhook < ApplicationRecord
|
|||
end
|
||||
|
||||
def required_permissions
|
||||
events.map { |event| Webhook.permission_for_event(event) }
|
||||
events.map { |event| Webhook.permission_for_event(event) }.uniq
|
||||
end
|
||||
|
||||
def self.permission_for_event(event)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue