Merge pull request #2579 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to f866413e72
This commit is contained in:
commit
915cd36ac1
|
@ -82,12 +82,9 @@ Rails/WhereExists:
|
||||||
- 'app/lib/feed_manager.rb'
|
- 'app/lib/feed_manager.rb'
|
||||||
- 'app/lib/status_cache_hydrator.rb'
|
- 'app/lib/status_cache_hydrator.rb'
|
||||||
- 'app/lib/suspicious_sign_in_detector.rb'
|
- 'app/lib/suspicious_sign_in_detector.rb'
|
||||||
- 'app/models/concerns/account/interactions.rb'
|
|
||||||
- 'app/models/featured_tag.rb'
|
|
||||||
- 'app/models/poll.rb'
|
- 'app/models/poll.rb'
|
||||||
- 'app/models/session_activation.rb'
|
- 'app/models/session_activation.rb'
|
||||||
- 'app/models/status.rb'
|
- 'app/models/status.rb'
|
||||||
- 'app/models/user.rb'
|
|
||||||
- 'app/policies/status_policy.rb'
|
- 'app/policies/status_policy.rb'
|
||||||
- 'app/serializers/rest/announcement_serializer.rb'
|
- 'app/serializers/rest/announcement_serializer.rb'
|
||||||
- 'app/serializers/rest/tag_serializer.rb'
|
- 'app/serializers/rest/tag_serializer.rb'
|
||||||
|
|
|
@ -27,7 +27,7 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
||||||
@domains = InstancesIndex.query(function_score: {
|
@domains = InstancesIndex.query(function_score: {
|
||||||
query: {
|
query: {
|
||||||
prefix: {
|
prefix: {
|
||||||
domain: TagManager.instance.normalize_domain(params[:q].strip),
|
domain: normalized_domain,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -37,11 +37,18 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
||||||
},
|
},
|
||||||
}).limit(10).pluck(:domain)
|
}).limit(10).pluck(:domain)
|
||||||
else
|
else
|
||||||
domain = params[:q].strip
|
domain = normalized_domain
|
||||||
domain = TagManager.instance.normalize_domain(domain)
|
@domains = Instance.searchable.domain_starts_with(domain).limit(10).pluck(:domain)
|
||||||
@domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain)
|
|
||||||
end
|
end
|
||||||
rescue Addressable::URI::InvalidURIError
|
rescue Addressable::URI::InvalidURIError
|
||||||
@domains = []
|
@domains = []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalized_domain
|
||||||
|
TagManager.instance.normalize_domain(query_value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def query_value
|
||||||
|
params[:q].strip
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -129,7 +129,6 @@ class Account < ApplicationRecord
|
||||||
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
||||||
scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
|
scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
|
||||||
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
|
||||||
scope :without_unapproved, -> { left_outer_joins(:user).merge(User.approved.confirmed).or(remote) }
|
scope :without_unapproved, -> { left_outer_joins(:user).merge(User.approved.confirmed).or(remote) }
|
||||||
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) }
|
||||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat) }
|
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat) }
|
||||||
|
|
|
@ -20,8 +20,11 @@ class Appeal < ApplicationRecord
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id', inverse_of: :appeal
|
belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id', inverse_of: :appeal
|
||||||
belongs_to :approved_by_account, class_name: 'Account', optional: true
|
|
||||||
belongs_to :rejected_by_account, class_name: 'Account', optional: true
|
with_options class_name: 'Account', optional: true do
|
||||||
|
belongs_to :approved_by_account
|
||||||
|
belongs_to :rejected_by_account
|
||||||
|
end
|
||||||
|
|
||||||
validates :text, presence: true, length: { maximum: 2_000 }
|
validates :text, presence: true, length: { maximum: 2_000 }
|
||||||
validates :account_warning_id, uniqueness: true
|
validates :account_warning_id, uniqueness: true
|
||||||
|
|
|
@ -183,7 +183,7 @@ module Account::Interactions
|
||||||
end
|
end
|
||||||
|
|
||||||
def following?(other_account)
|
def following?(other_account)
|
||||||
active_relationships.where(target_account: other_account).exists?
|
active_relationships.exists?(target_account: other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def following_anyone?
|
def following_anyone?
|
||||||
|
@ -199,51 +199,51 @@ module Account::Interactions
|
||||||
end
|
end
|
||||||
|
|
||||||
def blocking?(other_account)
|
def blocking?(other_account)
|
||||||
block_relationships.where(target_account: other_account).exists?
|
block_relationships.exists?(target_account: other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def domain_blocking?(other_domain)
|
def domain_blocking?(other_domain)
|
||||||
domain_blocks.where(domain: other_domain).exists?
|
domain_blocks.exists?(domain: other_domain)
|
||||||
end
|
end
|
||||||
|
|
||||||
def muting?(other_account)
|
def muting?(other_account)
|
||||||
mute_relationships.where(target_account: other_account).exists?
|
mute_relationships.exists?(target_account: other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def muting_conversation?(conversation)
|
def muting_conversation?(conversation)
|
||||||
conversation_mutes.where(conversation: conversation).exists?
|
conversation_mutes.exists?(conversation: conversation)
|
||||||
end
|
end
|
||||||
|
|
||||||
def muting_notifications?(other_account)
|
def muting_notifications?(other_account)
|
||||||
mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
|
mute_relationships.exists?(target_account: other_account, hide_notifications: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def muting_reblogs?(other_account)
|
def muting_reblogs?(other_account)
|
||||||
active_relationships.where(target_account: other_account, show_reblogs: false).exists?
|
active_relationships.exists?(target_account: other_account, show_reblogs: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def requested?(other_account)
|
def requested?(other_account)
|
||||||
follow_requests.where(target_account: other_account).exists?
|
follow_requests.exists?(target_account: other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def favourited?(status)
|
def favourited?(status)
|
||||||
status.proper.favourites.where(account: self).exists?
|
status.proper.favourites.exists?(account: self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def bookmarked?(status)
|
def bookmarked?(status)
|
||||||
status.proper.bookmarks.where(account: self).exists?
|
status.proper.bookmarks.exists?(account: self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reblogged?(status)
|
def reblogged?(status)
|
||||||
status.proper.reblogs.where(account: self).exists?
|
status.proper.reblogs.exists?(account: self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def pinned?(status)
|
def pinned?(status)
|
||||||
status_pins.where(status: status).exists?
|
status_pins.exists?(status: status)
|
||||||
end
|
end
|
||||||
|
|
||||||
def endorsed?(account)
|
def endorsed?(account)
|
||||||
account_pins.where(target_account: account).exists?
|
account_pins.exists?(target_account: account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_matches_filters(status)
|
def status_matches_filters(status)
|
||||||
|
|
|
@ -17,8 +17,6 @@ class DomainAllow < ApplicationRecord
|
||||||
|
|
||||||
validates :domain, presence: true, uniqueness: true, domain: true
|
validates :domain, presence: true, uniqueness: true, domain: true
|
||||||
|
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
|
||||||
|
|
||||||
def to_log_human_identifier
|
def to_log_human_identifier
|
||||||
domain
|
domain
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,7 +28,6 @@ class DomainBlock < ApplicationRecord
|
||||||
has_many :accounts, foreign_key: :domain, primary_key: :domain, inverse_of: false, dependent: nil
|
has_many :accounts, foreign_key: :domain, primary_key: :domain, inverse_of: false, dependent: nil
|
||||||
delegate :count, to: :accounts, prefix: true
|
delegate :count, to: :accounts, prefix: true
|
||||||
|
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
|
||||||
scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]) }
|
scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]) }
|
||||||
scope :with_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) }
|
scope :with_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) }
|
||||||
scope :by_severity, -> { in_order_of(:severity, %w(noop silence suspend)).order(:domain) }
|
scope :by_severity, -> { in_order_of(:severity, %w(noop silence suspend)).order(:domain) }
|
||||||
|
|
|
@ -21,8 +21,10 @@ class EmailDomainBlock < ApplicationRecord
|
||||||
include DomainNormalizable
|
include DomainNormalizable
|
||||||
include Paginable
|
include Paginable
|
||||||
|
|
||||||
belongs_to :parent, class_name: 'EmailDomainBlock', optional: true
|
with_options class_name: 'EmailDomainBlock' do
|
||||||
has_many :children, class_name: 'EmailDomainBlock', foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy
|
belongs_to :parent, optional: true
|
||||||
|
has_many :children, foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy
|
||||||
|
end
|
||||||
|
|
||||||
validates :domain, presence: true, uniqueness: true, domain: true
|
validates :domain, presence: true, uniqueness: true, domain: true
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ class FeaturedTag < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def decrement(deleted_status_id)
|
def decrement(deleted_status_id)
|
||||||
update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at)
|
update(statuses_count: [0, statuses_count - 1].max, last_status_at: visible_tagged_account_statuses.where.not(id: deleted_status_id).select(:created_at).first&.created_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -55,8 +55,8 @@ class FeaturedTag < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_data
|
def reset_data
|
||||||
self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count
|
self.statuses_count = visible_tagged_account_statuses.count
|
||||||
self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at
|
self.last_status_at = visible_tagged_account_statuses.select(:created_at).first&.created_at
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_featured_tags_limit
|
def validate_featured_tags_limit
|
||||||
|
@ -66,6 +66,14 @@ class FeaturedTag < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_tag_uniqueness
|
def validate_tag_uniqueness
|
||||||
errors.add(:name, :taken) if FeaturedTag.by_name(name).where(account_id: account_id).exists?
|
errors.add(:name, :taken) if tag_already_featured_for_account?
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_already_featured_for_account?
|
||||||
|
FeaturedTag.by_name(name).exists?(account_id: account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_tagged_account_statuses
|
||||||
|
account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,6 +23,7 @@ class Instance < ApplicationRecord
|
||||||
|
|
||||||
scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) }
|
scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) }
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||||
|
scope :domain_starts_with, ->(value) { where(arel_table[:domain].matches("#{sanitize_sql_like(value)}%", false, true)) }
|
||||||
scope :by_domain_and_subdomains, ->(domain) { where("reverse('.' || domain) LIKE reverse(?)", "%.#{domain}") }
|
scope :by_domain_and_subdomains, ->(domain) { where("reverse('.' || domain) LIKE reverse(?)", "%.#{domain}") }
|
||||||
|
|
||||||
def self.refresh
|
def self.refresh
|
||||||
|
|
|
@ -27,8 +27,11 @@ class Poll < ApplicationRecord
|
||||||
belongs_to :status
|
belongs_to :status
|
||||||
|
|
||||||
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all
|
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all
|
||||||
has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account
|
|
||||||
has_many :local_voters, -> { group('accounts.id').merge(Account.local) }, through: :votes, class_name: 'Account', source: :account
|
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) }
|
||||||
|
end
|
||||||
|
|
||||||
has_many :notifications, as: :activity, dependent: :destroy
|
has_many :notifications, as: :activity, dependent: :destroy
|
||||||
|
|
||||||
|
|
|
@ -29,9 +29,12 @@ class Report < ApplicationRecord
|
||||||
rate_limit by: :account, family: :reports
|
rate_limit by: :account, family: :reports
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :target_account, class_name: 'Account'
|
|
||||||
belongs_to :action_taken_by_account, class_name: 'Account', optional: true
|
with_options class_name: 'Account' do
|
||||||
belongs_to :assigned_account, class_name: 'Account', optional: true
|
belongs_to :target_account
|
||||||
|
belongs_to :action_taken_by_account, optional: true
|
||||||
|
belongs_to :assigned_account, optional: true
|
||||||
|
end
|
||||||
|
|
||||||
has_many :notes, class_name: 'ReportNote', inverse_of: :report, dependent: :destroy
|
has_many :notes, class_name: 'ReportNote', inverse_of: :report, dependent: :destroy
|
||||||
has_many :notifications, as: :activity, dependent: :destroy
|
has_many :notifications, as: :activity, dependent: :destroy
|
||||||
|
|
|
@ -61,8 +61,10 @@ class Status < ApplicationRecord
|
||||||
belongs_to :conversation, optional: true
|
belongs_to :conversation, optional: true
|
||||||
belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true, inverse_of: false
|
belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true, inverse_of: false
|
||||||
|
|
||||||
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
|
with_options class_name: 'Status', optional: true do
|
||||||
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
|
belongs_to :thread, foreign_key: 'in_reply_to_id', inverse_of: :replies
|
||||||
|
belongs_to :reblog, foreign_key: 'reblog_of_id', inverse_of: :reblogs
|
||||||
|
end
|
||||||
|
|
||||||
has_many :favourites, inverse_of: :status, dependent: :destroy
|
has_many :favourites, inverse_of: :status, dependent: :destroy
|
||||||
has_many :bookmarks, inverse_of: :status, dependent: :destroy
|
has_many :bookmarks, inverse_of: :status, dependent: :destroy
|
||||||
|
|
|
@ -434,7 +434,7 @@ class User < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def sign_up_from_ip_requires_approval?
|
def sign_up_from_ip_requires_approval?
|
||||||
!sign_up_ip.nil? && IpBlock.where(severity: :sign_up_requires_approval).where('ip >>= ?', sign_up_ip.to_s).exists?
|
sign_up_ip.present? && IpBlock.sign_up_requires_approval.exists?(['ip >>= ?', sign_up_ip.to_s])
|
||||||
end
|
end
|
||||||
|
|
||||||
def sign_up_email_requires_approval?
|
def sign_up_email_requires_approval?
|
||||||
|
|
|
@ -20,8 +20,7 @@ class CopyStatusStats < ActiveRecord::Migration[5.2]
|
||||||
private
|
private
|
||||||
|
|
||||||
def supports_upsert?
|
def supports_upsert?
|
||||||
version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
|
ActiveRecord::Base.connection.database_version >= 90_500
|
||||||
version >= 90_500
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def up_fast
|
def up_fast
|
||||||
|
|
|
@ -24,8 +24,7 @@ class CopyAccountStats < ActiveRecord::Migration[5.2]
|
||||||
private
|
private
|
||||||
|
|
||||||
def supports_upsert?
|
def supports_upsert?
|
||||||
version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
|
ActiveRecord::Base.connection.database_version >= 90_500
|
||||||
version >= 90_500
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def up_fast
|
def up_fast
|
||||||
|
|
|
@ -17,8 +17,7 @@ class AddUniqueIndexOnPreviewCardsStatuses < ActiveRecord::Migration[6.1]
|
||||||
|
|
||||||
def supports_concurrent_reindex?
|
def supports_concurrent_reindex?
|
||||||
@supports_concurrent_reindex ||= begin
|
@supports_concurrent_reindex ||= begin
|
||||||
version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
|
ActiveRecord::Base.connection.database_version >= 120_000
|
||||||
version >= 120_000
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -223,7 +223,7 @@ module Mastodon::CLI
|
||||||
say 'Deduplicating accounts… for local accounts, you will be asked to chose which account to keep unchanged.'
|
say 'Deduplicating accounts… for local accounts, you will be asked to chose which account to keep unchanged.'
|
||||||
|
|
||||||
find_duplicate_accounts.each do |row|
|
find_duplicate_accounts.each do |row|
|
||||||
accounts = Account.where(id: row['ids'].split(',')).to_a
|
accounts = Account.where(id: row['ids'].split(','))
|
||||||
|
|
||||||
if accounts.first.local?
|
if accounts.first.local?
|
||||||
deduplicate_local_accounts!(accounts)
|
deduplicate_local_accounts!(accounts)
|
||||||
|
@ -275,7 +275,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
def deduplicate_users_process_email
|
def deduplicate_users_process_email
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row|
|
||||||
users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse
|
users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).to_a
|
||||||
ref_user = users.shift
|
ref_user = users.shift
|
||||||
say "Multiple users registered with e-mail address #{ref_user.email}.", :yellow
|
say "Multiple users registered with e-mail address #{ref_user.email}.", :yellow
|
||||||
say "e-mail will be disabled for the following accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
say "e-mail will be disabled for the following accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
||||||
|
@ -289,7 +289,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
def deduplicate_users_process_confirmation_token
|
def deduplicate_users_process_confirmation_token
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE confirmation_token IS NOT NULL GROUP BY confirmation_token HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE confirmation_token IS NOT NULL GROUP BY confirmation_token HAVING count(*) > 1").each do |row|
|
||||||
users = User.where(id: row['ids'].split(',')).sort_by(&:created_at).reverse.drop(1)
|
users = User.where(id: row['ids'].split(',')).order(created_at: :desc).to_a.drop(1)
|
||||||
say "Unsetting confirmation token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
say "Unsetting confirmation token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
||||||
|
|
||||||
users.each do |user|
|
users.each do |user|
|
||||||
|
@ -301,7 +301,7 @@ module Mastodon::CLI
|
||||||
def deduplicate_users_process_remember_token
|
def deduplicate_users_process_remember_token
|
||||||
if migrator_version < 2022_01_18_183010
|
if migrator_version < 2022_01_18_183010
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row|
|
||||||
users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
|
users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).to_a.drop(1)
|
||||||
say "Unsetting remember token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
say "Unsetting remember token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
||||||
|
|
||||||
users.each do |user|
|
users.each do |user|
|
||||||
|
@ -313,7 +313,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
def deduplicate_users_process_password_token
|
def deduplicate_users_process_password_token
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE reset_password_token IS NOT NULL GROUP BY reset_password_token HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE reset_password_token IS NOT NULL GROUP BY reset_password_token HAVING count(*) > 1").each do |row|
|
||||||
users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
|
users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).to_a.drop(1)
|
||||||
say "Unsetting password reset token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
say "Unsetting password reset token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
|
||||||
|
|
||||||
users.each do |user|
|
users.each do |user|
|
||||||
|
@ -341,7 +341,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Removing duplicate account identity proofs…'
|
say 'Removing duplicate account identity proofs…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_identity_proofs GROUP BY account_id, provider, provider_username HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_identity_proofs GROUP BY account_id, provider, provider_username HAVING count(*) > 1").each do |row|
|
||||||
AccountIdentityProof.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
|
AccountIdentityProof.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Restoring account identity proofs indexes…'
|
say 'Restoring account identity proofs indexes…'
|
||||||
|
@ -355,7 +355,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Removing duplicate announcement reactions…'
|
say 'Removing duplicate announcement reactions…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM announcement_reactions GROUP BY account_id, announcement_id, name HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM announcement_reactions GROUP BY account_id, announcement_id, name HAVING count(*) > 1").each do |row|
|
||||||
AnnouncementReaction.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
|
AnnouncementReaction.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Restoring announcement_reactions indexes…'
|
say 'Restoring announcement_reactions indexes…'
|
||||||
|
@ -367,7 +367,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating conversations…'
|
say 'Deduplicating conversations…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM conversations WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM conversations WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
|
||||||
conversations = Conversation.where(id: row['ids'].split(',')).sort_by(&:id).reverse
|
conversations = Conversation.where(id: row['ids'].split(',')).order(id: :desc).to_a
|
||||||
|
|
||||||
ref_conversation = conversations.shift
|
ref_conversation = conversations.shift
|
||||||
|
|
||||||
|
@ -390,7 +390,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating custom_emojis…'
|
say 'Deduplicating custom_emojis…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emojis GROUP BY shortcode, domain HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emojis GROUP BY shortcode, domain HAVING count(*) > 1").each do |row|
|
||||||
emojis = CustomEmoji.where(id: row['ids'].split(',')).sort_by(&:id).reverse
|
emojis = CustomEmoji.where(id: row['ids'].split(',')).order(id: :desc).to_a
|
||||||
|
|
||||||
ref_emoji = emojis.shift
|
ref_emoji = emojis.shift
|
||||||
|
|
||||||
|
@ -409,7 +409,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating custom_emoji_categories…'
|
say 'Deduplicating custom_emoji_categories…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emoji_categories GROUP BY name HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emoji_categories GROUP BY name HAVING count(*) > 1").each do |row|
|
||||||
categories = CustomEmojiCategory.where(id: row['ids'].split(',')).sort_by(&:id).reverse
|
categories = CustomEmojiCategory.where(id: row['ids'].split(',')).order(id: :desc).to_a
|
||||||
|
|
||||||
ref_category = categories.shift
|
ref_category = categories.shift
|
||||||
|
|
||||||
|
@ -428,7 +428,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating domain_allows…'
|
say 'Deduplicating domain_allows…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row|
|
||||||
DomainAllow.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
|
DomainAllow.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Restoring domain_allows indexes…'
|
say 'Restoring domain_allows indexes…'
|
||||||
|
@ -466,7 +466,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating unavailable_domains…'
|
say 'Deduplicating unavailable_domains…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row|
|
||||||
UnavailableDomain.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
|
UnavailableDomain.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Restoring unavailable_domains indexes…'
|
say 'Restoring unavailable_domains indexes…'
|
||||||
|
@ -478,7 +478,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating email_domain_blocks…'
|
say 'Deduplicating email_domain_blocks…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM email_domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM email_domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
|
||||||
domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).sort_by { |b| b.parent.nil? ? 1 : 0 }.to_a
|
domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).order(EmailDomainBlock.arel_table[:parent_id].asc.nulls_first).to_a
|
||||||
domain_blocks.drop(1).each(&:destroy)
|
domain_blocks.drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -507,7 +507,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating preview_cards…'
|
say 'Deduplicating preview_cards…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row|
|
||||||
PreviewCard.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
|
PreviewCard.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Restoring preview_cards indexes…'
|
say 'Restoring preview_cards indexes…'
|
||||||
|
@ -519,7 +519,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating statuses…'
|
say 'Deduplicating statuses…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM statuses WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM statuses WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
|
||||||
statuses = Status.where(id: row['ids'].split(',')).sort_by(&:id)
|
statuses = Status.where(id: row['ids'].split(',')).order(id: :asc).to_a
|
||||||
ref_status = statuses.shift
|
ref_status = statuses.shift
|
||||||
statuses.each do |status|
|
statuses.each do |status|
|
||||||
merge_statuses!(ref_status, status) if status.account_id == ref_status.account_id
|
merge_statuses!(ref_status, status) if status.account_id == ref_status.account_id
|
||||||
|
@ -541,7 +541,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating tags…'
|
say 'Deduplicating tags…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row|
|
||||||
tags = Tag.where(id: row['ids'].split(',')).sort_by { |t| [t.usable?, t.trendable?, t.listable?].count(false) }
|
tags = Tag.where(id: row['ids'].split(',')).order(Arel.sql('(usable::int + trendable::int + listable::int) desc')).to_a
|
||||||
ref_tag = tags.shift
|
ref_tag = tags.shift
|
||||||
tags.each do |tag|
|
tags.each do |tag|
|
||||||
merge_tags!(ref_tag, tag)
|
merge_tags!(ref_tag, tag)
|
||||||
|
@ -564,7 +564,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating webauthn_credentials…'
|
say 'Deduplicating webauthn_credentials…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webauthn_credentials GROUP BY external_id HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webauthn_credentials GROUP BY external_id HAVING count(*) > 1").each do |row|
|
||||||
WebauthnCredential.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
|
WebauthnCredential.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Restoring webauthn_credentials indexes…'
|
say 'Restoring webauthn_credentials indexes…'
|
||||||
|
@ -578,7 +578,7 @@ module Mastodon::CLI
|
||||||
|
|
||||||
say 'Deduplicating webhooks…'
|
say 'Deduplicating webhooks…'
|
||||||
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row|
|
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row|
|
||||||
Webhook.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
|
Webhook.where(id: row['ids'].split(',')).order(id: :desc).drop(1).each(&:destroy)
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Restoring webhooks indexes…'
|
say 'Restoring webhooks indexes…'
|
||||||
|
@ -590,8 +590,8 @@ module Mastodon::CLI
|
||||||
SoftwareUpdate.delete_all
|
SoftwareUpdate.delete_all
|
||||||
end
|
end
|
||||||
|
|
||||||
def deduplicate_local_accounts!(accounts)
|
def deduplicate_local_accounts!(scope)
|
||||||
accounts = accounts.sort_by(&:id).reverse
|
accounts = scope.order(id: :desc).to_a
|
||||||
|
|
||||||
say "Multiple local accounts were found for username '#{accounts.first.username}'.", :yellow
|
say "Multiple local accounts were found for username '#{accounts.first.username}'.", :yellow
|
||||||
say 'All those accounts are distinct accounts but only the most recently-created one is fully-functional.', :yellow
|
say 'All those accounts are distinct accounts but only the most recently-created one is fully-functional.', :yellow
|
||||||
|
@ -629,8 +629,8 @@ module Mastodon::CLI
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def deduplicate_remote_accounts!(accounts)
|
def deduplicate_remote_accounts!(scope)
|
||||||
accounts = accounts.sort_by(&:updated_at).reverse
|
accounts = scope.order(updated_at: :desc).to_a
|
||||||
|
|
||||||
reference_account = accounts.shift
|
reference_account = accounts.shift
|
||||||
|
|
||||||
|
|
|
@ -8,15 +8,15 @@
|
||||||
# shorten temporary column names.
|
# shorten temporary column names.
|
||||||
|
|
||||||
# Documentation on using these functions (and why one might do so):
|
# Documentation on using these functions (and why one might do so):
|
||||||
# https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/what_requires_downtime.md
|
# https://gitlab.com/gitlab-org/gitlab-foss/-/blob/master/doc/development/database/avoiding_downtime_in_migrations.md
|
||||||
|
|
||||||
# The file itself:
|
# The original file (since updated):
|
||||||
# https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/database/migration_helpers.rb
|
# https://gitlab.com/gitlab-org/gitlab-foss/-/blob/master/lib/gitlab/database/migration_helpers.rb
|
||||||
|
|
||||||
# It is licensed as follows:
|
# It is licensed as follows:
|
||||||
|
|
||||||
# Copyright (c) 2011-2017 GitLab B.V.
|
# Copyright (c) 2011-present GitLab B.V.
|
||||||
|
#
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
# of this software and associated documentation files (the "Software"), to deal
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
# in the Software without restriction, including without limitation the rights
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
@ -24,16 +24,16 @@
|
||||||
# copies of the Software, and to permit persons to whom the Software is
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
# furnished to do so, subject to the following conditions:
|
# furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
# The above copyright notice and this permission notice shall be included in
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
# all copies or substantial portions of the Software.
|
# copies or substantial portions of the Software.
|
||||||
|
|
||||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
# THE SOFTWARE.
|
# SOFTWARE.
|
||||||
|
|
||||||
# This is bad form, but there are enough differences that it's impractical to do
|
# This is bad form, but there are enough differences that it's impractical to do
|
||||||
# otherwise:
|
# otherwise:
|
||||||
|
@ -77,37 +77,12 @@ module Mastodon
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
BACKGROUND_MIGRATION_BATCH_SIZE = 1000 # Number of rows to process per job
|
|
||||||
BACKGROUND_MIGRATION_JOB_BUFFER_SIZE = 1000 # Number of jobs to bulk queue at a time
|
|
||||||
|
|
||||||
# Gets an estimated number of rows for a table
|
# Gets an estimated number of rows for a table
|
||||||
def estimate_rows_in_table(table_name)
|
def estimate_rows_in_table(table_name)
|
||||||
exec_query('SELECT reltuples FROM pg_class WHERE relname = ' +
|
exec_query('SELECT reltuples FROM pg_class WHERE relname = ' +
|
||||||
"'#{table_name}'").to_a.first['reltuples']
|
"'#{table_name}'").to_a.first['reltuples']
|
||||||
end
|
end
|
||||||
|
|
||||||
# Adds `created_at` and `updated_at` columns with timezone information.
|
|
||||||
#
|
|
||||||
# This method is an improved version of Rails' built-in method `add_timestamps`.
|
|
||||||
#
|
|
||||||
# Available options are:
|
|
||||||
# default - The default value for the column.
|
|
||||||
# null - When set to `true` the column will allow NULL values.
|
|
||||||
# The default is to not allow NULL values.
|
|
||||||
def add_timestamps_with_timezone(table_name, **options)
|
|
||||||
options[:null] = false if options[:null].nil?
|
|
||||||
|
|
||||||
[:created_at, :updated_at].each do |column_name|
|
|
||||||
if options[:default] && transaction_open?
|
|
||||||
raise '`add_timestamps_with_timezone` with default value cannot be run inside a transaction. ' \
|
|
||||||
'You can disable transactions by calling `disable_ddl_transaction!` ' \
|
|
||||||
'in the body of your migration class'
|
|
||||||
end
|
|
||||||
|
|
||||||
add_column(table_name, column_name, :datetime_with_timezone, **options)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Creates a new index, concurrently when supported
|
# Creates a new index, concurrently when supported
|
||||||
#
|
#
|
||||||
# On PostgreSQL this method creates an index concurrently, on MySQL this
|
# On PostgreSQL this method creates an index concurrently, on MySQL this
|
||||||
|
@ -746,39 +721,6 @@ module Mastodon
|
||||||
rename_index table_name, "#{index_name}_new", index_name
|
rename_index table_name, "#{index_name}_new", index_name
|
||||||
end
|
end
|
||||||
|
|
||||||
# This will replace the first occurrence of a string in a column with
|
|
||||||
# the replacement
|
|
||||||
# On postgresql we can use `regexp_replace` for that.
|
|
||||||
# On mysql we find the location of the pattern, and overwrite it
|
|
||||||
# with the replacement
|
|
||||||
def replace_sql(column, pattern, replacement)
|
|
||||||
quoted_pattern = Arel::Nodes::Quoted.new(pattern.to_s)
|
|
||||||
quoted_replacement = Arel::Nodes::Quoted.new(replacement.to_s)
|
|
||||||
|
|
||||||
replace = Arel::Nodes::NamedFunction
|
|
||||||
.new("regexp_replace", [column, quoted_pattern, quoted_replacement])
|
|
||||||
Arel::Nodes::SqlLiteral.new(replace.to_sql)
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_foreign_key_without_error(*args)
|
|
||||||
remove_foreign_key(*args)
|
|
||||||
rescue ArgumentError
|
|
||||||
end
|
|
||||||
|
|
||||||
def sidekiq_queue_migrate(queue_from, to:)
|
|
||||||
while sidekiq_queue_length(queue_from) > 0
|
|
||||||
Sidekiq.redis do |conn|
|
|
||||||
conn.rpoplpush "queue:#{queue_from}", "queue:#{to}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sidekiq_queue_length(queue_name)
|
|
||||||
Sidekiq.redis do |conn|
|
|
||||||
conn.llen("queue:#{queue_name}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_trigger_permissions!(table)
|
def check_trigger_permissions!(table)
|
||||||
unless Grant.create_and_execute_trigger?(table)
|
unless Grant.create_and_execute_trigger?(table)
|
||||||
dbname = ActiveRecord::Base.configurations[Rails.env]['database']
|
dbname = ActiveRecord::Base.configurations[Rails.env]['database']
|
||||||
|
@ -799,91 +741,6 @@ into similar problems in the future (e.g. when new tables are created).
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Bulk queues background migration jobs for an entire table, batched by ID range.
|
|
||||||
# "Bulk" meaning many jobs will be pushed at a time for efficiency.
|
|
||||||
# If you need a delay interval per job, then use `queue_background_migration_jobs_by_range_at_intervals`.
|
|
||||||
#
|
|
||||||
# model_class - The table being iterated over
|
|
||||||
# job_class_name - The background migration job class as a string
|
|
||||||
# batch_size - The maximum number of rows per job
|
|
||||||
#
|
|
||||||
# Example:
|
|
||||||
#
|
|
||||||
# class Route < ActiveRecord::Base
|
|
||||||
# include EachBatch
|
|
||||||
# self.table_name = 'routes'
|
|
||||||
# end
|
|
||||||
#
|
|
||||||
# bulk_queue_background_migration_jobs_by_range(Route, 'ProcessRoutes')
|
|
||||||
#
|
|
||||||
# Where the model_class includes EachBatch, and the background migration exists:
|
|
||||||
#
|
|
||||||
# class Gitlab::BackgroundMigration::ProcessRoutes
|
|
||||||
# def perform(start_id, end_id)
|
|
||||||
# # do something
|
|
||||||
# end
|
|
||||||
# end
|
|
||||||
def bulk_queue_background_migration_jobs_by_range(model_class, job_class_name, batch_size: BACKGROUND_MIGRATION_BATCH_SIZE)
|
|
||||||
raise "#{model_class} does not have an ID to use for batch ranges" unless model_class.column_names.include?('id')
|
|
||||||
|
|
||||||
jobs = []
|
|
||||||
|
|
||||||
model_class.each_batch(of: batch_size) do |relation|
|
|
||||||
start_id, end_id = relation.pluck('MIN(id), MAX(id)').first
|
|
||||||
|
|
||||||
if jobs.length >= BACKGROUND_MIGRATION_JOB_BUFFER_SIZE
|
|
||||||
# Note: This code path generally only helps with many millions of rows
|
|
||||||
# We push multiple jobs at a time to reduce the time spent in
|
|
||||||
# Sidekiq/Redis operations. We're using this buffer based approach so we
|
|
||||||
# don't need to run additional queries for every range.
|
|
||||||
BackgroundMigrationWorker.perform_bulk(jobs)
|
|
||||||
jobs.clear
|
|
||||||
end
|
|
||||||
|
|
||||||
jobs << [job_class_name, [start_id, end_id]]
|
|
||||||
end
|
|
||||||
|
|
||||||
BackgroundMigrationWorker.perform_bulk(jobs) unless jobs.empty?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Queues background migration jobs for an entire table, batched by ID range.
|
|
||||||
# Each job is scheduled with a `delay_interval` in between.
|
|
||||||
# If you use a small interval, then some jobs may run at the same time.
|
|
||||||
#
|
|
||||||
# model_class - The table being iterated over
|
|
||||||
# job_class_name - The background migration job class as a string
|
|
||||||
# delay_interval - The duration between each job's scheduled time (must respond to `to_f`)
|
|
||||||
# batch_size - The maximum number of rows per job
|
|
||||||
#
|
|
||||||
# Example:
|
|
||||||
#
|
|
||||||
# class Route < ActiveRecord::Base
|
|
||||||
# include EachBatch
|
|
||||||
# self.table_name = 'routes'
|
|
||||||
# end
|
|
||||||
#
|
|
||||||
# queue_background_migration_jobs_by_range_at_intervals(Route, 'ProcessRoutes', 1.minute)
|
|
||||||
#
|
|
||||||
# Where the model_class includes EachBatch, and the background migration exists:
|
|
||||||
#
|
|
||||||
# class Gitlab::BackgroundMigration::ProcessRoutes
|
|
||||||
# def perform(start_id, end_id)
|
|
||||||
# # do something
|
|
||||||
# end
|
|
||||||
# end
|
|
||||||
def queue_background_migration_jobs_by_range_at_intervals(model_class, job_class_name, delay_interval, batch_size: BACKGROUND_MIGRATION_BATCH_SIZE)
|
|
||||||
raise "#{model_class} does not have an ID to use for batch ranges" unless model_class.column_names.include?('id')
|
|
||||||
|
|
||||||
model_class.each_batch(of: batch_size) do |relation, index|
|
|
||||||
start_id, end_id = relation.pluck('MIN(id), MAX(id)').first
|
|
||||||
|
|
||||||
# `BackgroundMigrationWorker.bulk_perform_in` schedules all jobs for
|
|
||||||
# the same time, which is not helpful in most cases where we wish to
|
|
||||||
# spread the work over time.
|
|
||||||
BackgroundMigrationWorker.perform_in(delay_interval * index, job_class_name, [start_id, end_id])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb#L678-L684
|
# https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb#L678-L684
|
||||||
|
|
|
@ -9,14 +9,10 @@ RSpec.describe Account do
|
||||||
let(:bob) { Fabricate(:account, username: 'bob') }
|
let(:bob) { Fabricate(:account, username: 'bob') }
|
||||||
|
|
||||||
describe '#suspend!' do
|
describe '#suspend!' do
|
||||||
it 'marks the account as suspended' do
|
it 'marks the account as suspended and creates a deletion request' do
|
||||||
subject.suspend!
|
expect { subject.suspend! }
|
||||||
expect(subject.suspended?).to be true
|
.to change(subject, :suspended?).from(false).to(true)
|
||||||
end
|
.and(change { AccountDeletionRequest.exists?(account: subject) }.from(false).to(true))
|
||||||
|
|
||||||
it 'creates a deletion request' do
|
|
||||||
subject.suspend!
|
|
||||||
expect(AccountDeletionRequest.where(account: subject).exists?).to be true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the account is of a local user' do
|
context 'when the account is of a local user' do
|
||||||
|
|
|
@ -3,16 +3,18 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
describe DomainAllow do
|
describe DomainAllow do
|
||||||
describe 'scopes' do
|
describe 'Validations' do
|
||||||
describe 'matches_domain' do
|
it 'is invalid without a domain' do
|
||||||
let(:domain) { Fabricate(:domain_allow, domain: 'example.com') }
|
domain_allow = Fabricate.build(:domain_allow, domain: nil)
|
||||||
let(:other_domain) { Fabricate(:domain_allow, domain: 'example.biz') }
|
domain_allow.valid?
|
||||||
|
expect(domain_allow).to model_have_error_on_field(:domain)
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns the correct records' do
|
it 'is invalid if the same normalized domain already exists' do
|
||||||
results = described_class.matches_domain('example.com')
|
_domain_allow = Fabricate(:domain_allow, domain: 'にゃん')
|
||||||
|
domain_allow_with_normalized_value = Fabricate.build(:domain_allow, domain: 'xn--r9j5b5b')
|
||||||
expect(results).to eq([domain])
|
domain_allow_with_normalized_value.valid?
|
||||||
end
|
expect(domain_allow_with_normalized_value).to model_have_error_on_field(:domain)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe 'API Peers Search' do
|
||||||
|
describe 'GET /api/v1/peers/search' do
|
||||||
|
context 'when peers api is disabled' do
|
||||||
|
before do
|
||||||
|
Setting.peers_api_enabled = false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http not found response' do
|
||||||
|
get '/api/v1/peers/search'
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no search param' do
|
||||||
|
it 'returns http success and empty response' do
|
||||||
|
get '/api/v1/peers/search'
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(body_as_json)
|
||||||
|
.to be_blank
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid search param' do
|
||||||
|
it 'returns http success and empty response' do
|
||||||
|
get '/api/v1/peers/search', params: { q: 'ftp://Invalid-Host!!.valüe' }
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(body_as_json)
|
||||||
|
.to be_blank
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with search param' do
|
||||||
|
let!(:account) { Fabricate(:account, domain: 'host.example') }
|
||||||
|
|
||||||
|
before { Instance.refresh }
|
||||||
|
|
||||||
|
it 'returns http success and json with known domains' do
|
||||||
|
get '/api/v1/peers/search', params: { q: 'host.example' }
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(body_as_json.size)
|
||||||
|
.to eq(1)
|
||||||
|
expect(body_as_json.first)
|
||||||
|
.to eq(account.domain)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -5,25 +5,25 @@ require 'rails_helper'
|
||||||
RSpec.describe PurgeDomainService, type: :service do
|
RSpec.describe PurgeDomainService, type: :service do
|
||||||
subject { described_class.new }
|
subject { described_class.new }
|
||||||
|
|
||||||
let!(:old_account) { Fabricate(:account, domain: 'obsolete.org') }
|
let(:domain) { 'obsolete.org' }
|
||||||
let!(:old_status_plain) { Fabricate(:status, account: old_account) }
|
let!(:account) { Fabricate(:account, domain: domain) }
|
||||||
let!(:old_status_with_attachment) { Fabricate(:status, account: old_account) }
|
let!(:status_plain) { Fabricate(:status, account: account) }
|
||||||
let!(:old_attachment) { Fabricate(:media_attachment, account: old_account, status: old_status_with_attachment, file: attachment_fixture('attachment.jpg')) }
|
let!(:status_with_attachment) { Fabricate(:status, account: account) }
|
||||||
|
let!(:attachment) { Fabricate(:media_attachment, account: account, status: status_with_attachment, file: attachment_fixture('attachment.jpg')) }
|
||||||
|
|
||||||
describe 'for a suspension' do
|
describe 'for a suspension' do
|
||||||
before do
|
it 'refreshes instance view and removes associated records' do
|
||||||
subject.call('obsolete.org')
|
expect { subject.call(domain) }
|
||||||
|
.to change { domain_instance_exists }.from(true).to(false)
|
||||||
|
|
||||||
|
expect { account.reload }.to raise_exception ActiveRecord::RecordNotFound
|
||||||
|
expect { status_plain.reload }.to raise_exception ActiveRecord::RecordNotFound
|
||||||
|
expect { status_with_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound
|
||||||
|
expect { attachment.reload }.to raise_exception ActiveRecord::RecordNotFound
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'removes the remote accounts\'s statuses and media attachments' do
|
def domain_instance_exists
|
||||||
expect { old_account.reload }.to raise_exception ActiveRecord::RecordNotFound
|
Instance.exists?(domain: domain)
|
||||||
expect { old_status_plain.reload }.to raise_exception ActiveRecord::RecordNotFound
|
|
||||||
expect { old_status_with_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound
|
|
||||||
expect { old_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'refreshes instances view' do
|
|
||||||
expect(Instance.where(domain: 'obsolete.org').exists?).to be false
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,12 +5,13 @@ require 'rails_helper'
|
||||||
RSpec.describe UnallowDomainService, type: :service do
|
RSpec.describe UnallowDomainService, type: :service do
|
||||||
subject { described_class.new }
|
subject { described_class.new }
|
||||||
|
|
||||||
let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') }
|
let(:bad_domain) { 'evil.org' }
|
||||||
|
let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: bad_domain) }
|
||||||
let!(:bad_status_harassment) { Fabricate(:status, account: bad_account, text: 'You suck') }
|
let!(:bad_status_harassment) { Fabricate(:status, account: bad_account, text: 'You suck') }
|
||||||
let!(:bad_status_mean) { Fabricate(:status, account: bad_account, text: 'Hahaha') }
|
let!(:bad_status_mean) { Fabricate(:status, account: bad_account, text: 'Hahaha') }
|
||||||
let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status_mean, file: attachment_fixture('attachment.jpg')) }
|
let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status_mean, file: attachment_fixture('attachment.jpg')) }
|
||||||
let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: 'evil.org', suspended: true, silenced: true) }
|
let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: bad_domain, suspended: true, silenced: true) }
|
||||||
let!(:domain_allow) { Fabricate(:domain_allow, domain: 'evil.org') }
|
let!(:domain_allow) { Fabricate(:domain_allow, domain: bad_domain) }
|
||||||
|
|
||||||
context 'with limited federation mode', :sidekiq_inline do
|
context 'with limited federation mode', :sidekiq_inline do
|
||||||
before do
|
before do
|
||||||
|
@ -18,23 +19,15 @@ RSpec.describe UnallowDomainService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
before do
|
it 'makes the domain not allowed and removes accounts from that domain' do
|
||||||
subject.call(domain_allow)
|
expect { subject.call(domain_allow) }
|
||||||
end
|
.to change { bad_domain_allowed }.from(true).to(false)
|
||||||
|
.and change { bad_domain_account_exists }.from(true).to(false)
|
||||||
|
|
||||||
it 'removes the allowed domain' do
|
|
||||||
expect(DomainAllow.allowed?('evil.org')).to be false
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'removes remote accounts from that domain' do
|
|
||||||
expect { already_banned_account.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
expect { already_banned_account.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
expect(Account.where(domain: 'evil.org').exists?).to be false
|
expect { bad_status_harassment.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
end
|
expect { bad_status_mean.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
expect { bad_attachment.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
it 'removes the remote accounts\'s statuses and media attachments' do
|
|
||||||
expect { bad_status_harassment.reload }.to raise_exception ActiveRecord::RecordNotFound
|
|
||||||
expect { bad_status_mean.reload }.to raise_exception ActiveRecord::RecordNotFound
|
|
||||||
expect { bad_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -45,23 +38,23 @@ RSpec.describe UnallowDomainService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
before do
|
it 'makes the domain not allowed but preserves accounts from the domain' do
|
||||||
subject.call(domain_allow)
|
expect { subject.call(domain_allow) }
|
||||||
end
|
.to change { bad_domain_allowed }.from(true).to(false)
|
||||||
|
.and not_change { bad_domain_account_exists }.from(true)
|
||||||
|
|
||||||
it 'removes the allowed domain' do
|
|
||||||
expect(DomainAllow.allowed?('evil.org')).to be false
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not remove accounts from that domain' do
|
|
||||||
expect(Account.where(domain: 'evil.org').exists?).to be true
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'removes the remote accounts\'s statuses and media attachments' do
|
|
||||||
expect { bad_status_harassment.reload }.to_not raise_error
|
expect { bad_status_harassment.reload }.to_not raise_error
|
||||||
expect { bad_status_mean.reload }.to_not raise_error
|
expect { bad_status_mean.reload }.to_not raise_error
|
||||||
expect { bad_attachment.reload }.to_not raise_error
|
expect { bad_attachment.reload }.to_not raise_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bad_domain_allowed
|
||||||
|
DomainAllow.allowed?(bad_domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bad_domain_account_exists
|
||||||
|
Account.exists?(domain: bad_domain)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
36
yarn.lock
36
yarn.lock
|
@ -4616,11 +4616,11 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"async-mutex@npm:^0.4.0":
|
"async-mutex@npm:^0.4.0":
|
||||||
version: 0.4.0
|
version: 0.4.1
|
||||||
resolution: "async-mutex@npm:0.4.0"
|
resolution: "async-mutex@npm:0.4.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: "npm:^2.4.0"
|
tslib: "npm:^2.4.0"
|
||||||
checksum: 6541695f80c1d6c5acbf3f7f04e8ff0733b3e029312c48d77bb95243fbe21fc5319f45ac3d72ce08551e6df83dc32440285ce9a3ac17bfc5d385ff0cc8ccd62a
|
checksum: 3c412736c0bc4a9a2cfd948276a8caab8686aa615866a5bd20986e616f8945320acb310058a17afa1b31b8de6f634a78b7ec2217a33d7559b38f68bb85a95854
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -4680,12 +4680,12 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"autoprefixer@npm:^10.4.14":
|
"autoprefixer@npm:^10.4.14":
|
||||||
version: 10.4.16
|
version: 10.4.17
|
||||||
resolution: "autoprefixer@npm:10.4.16"
|
resolution: "autoprefixer@npm:10.4.17"
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: "npm:^4.21.10"
|
browserslist: "npm:^4.22.2"
|
||||||
caniuse-lite: "npm:^1.0.30001538"
|
caniuse-lite: "npm:^1.0.30001578"
|
||||||
fraction.js: "npm:^4.3.6"
|
fraction.js: "npm:^4.3.7"
|
||||||
normalize-range: "npm:^0.1.2"
|
normalize-range: "npm:^0.1.2"
|
||||||
picocolors: "npm:^1.0.0"
|
picocolors: "npm:^1.0.0"
|
||||||
postcss-value-parser: "npm:^4.2.0"
|
postcss-value-parser: "npm:^4.2.0"
|
||||||
|
@ -4693,7 +4693,7 @@ __metadata:
|
||||||
postcss: ^8.1.0
|
postcss: ^8.1.0
|
||||||
bin:
|
bin:
|
||||||
autoprefixer: bin/autoprefixer
|
autoprefixer: bin/autoprefixer
|
||||||
checksum: e00256e754d481a026d928bca729b25954074dd142dbec022f0a7db0d3bbc0dc2e2dc7542e94fec22eff81e21fe140e6856448e2d9a002660cb1e2ad434daee0
|
checksum: 1d21cc8edb7bf993682094ceed03a32c18f5293f071182a64c2c6defb44bbe91d576ad775d2347469a81997b80cea0bbc4ad3eeb5b12710f9feacf2e6c04bb51
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -5240,7 +5240,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.22.2":
|
"browserslist@npm:^4.0.0, browserslist@npm:^4.22.2":
|
||||||
version: 4.22.2
|
version: 4.22.2
|
||||||
resolution: "browserslist@npm:4.22.2"
|
resolution: "browserslist@npm:4.22.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -5466,10 +5466,10 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001538, caniuse-lite@npm:^1.0.30001565":
|
"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001565, caniuse-lite@npm:^1.0.30001578":
|
||||||
version: 1.0.30001568
|
version: 1.0.30001578
|
||||||
resolution: "caniuse-lite@npm:1.0.30001568"
|
resolution: "caniuse-lite@npm:1.0.30001578"
|
||||||
checksum: 13f01e5a2481134bd61cf565ce9fecbd8e107902927a0dcf534230a92191a81f1715792170f5f39719c767c3a96aa6df9917a8d5601f15bbd5e4041a8cfecc99
|
checksum: c3bd9c08a945cee4f0cc284a217ebe9c2613e04d5aef4b48f1871a779b1875c34286469eb8d7d94bd028b5a354613e676ad503b6bf8db20a2f154574bd5fde48
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -8298,10 +8298,10 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"fraction.js@npm:^4.3.6":
|
"fraction.js@npm:^4.3.7":
|
||||||
version: 4.3.6
|
version: 4.3.7
|
||||||
resolution: "fraction.js@npm:4.3.6"
|
resolution: "fraction.js@npm:4.3.7"
|
||||||
checksum: d224bf62e350c4dbe66c6ac5ad9c4ec6d3c8e64c13323686dbebe7c8cc118491c297dca4961d3c93f847670794cb05e6d8b706f0e870846ab66a9c4491d0e914
|
checksum: df291391beea9ab4c263487ffd9d17fed162dbb736982dee1379b2a8cc94e4e24e46ed508c6d278aded9080ba51872f1bc5f3a5fd8d7c74e5f105b508ac28711
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue