1
0

Merge branch 'master' into glitch-soc/merge-upstream

This commit is contained in:
Thibaut Girka 2019-05-15 17:11:40 +02:00
commit 6badf2d252
39 changed files with 271 additions and 155 deletions

View File

@ -109,7 +109,7 @@ group :production, :test do
end end
group :test do group :test do
gem 'capybara', '~> 3.18' gem 'capybara', '~> 3.19'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9' gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.1' gem 'microformats', '~> 4.1'
@ -129,7 +129,7 @@ group :development do
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3' gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 0.68', require: false gem 'rubocop', '~> 0.69', require: false
gem 'brakeman', '~> 4.5', require: false gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.58', require: false gem 'scss_lint', '~> 0.58', require: false

View File

@ -103,7 +103,7 @@ GEM
ffi (~> 1.10.0) ffi (~> 1.10.0)
bootsnap (1.4.4) bootsnap (1.4.4)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.5.0) brakeman (4.5.1)
browser (2.5.3) browser (2.5.3)
builder (3.2.3) builder (3.2.3)
bullet (6.0.0) bullet (6.0.0)
@ -129,7 +129,7 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (3.18.0) capybara (3.19.1)
addressable addressable
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.8)
@ -529,13 +529,13 @@ GEM
rspec-core (~> 3.0, >= 3.0.0) rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0) sidekiq (>= 2.4.0)
rspec-support (3.8.0) rspec-support (3.8.0)
rubocop (0.68.1) rubocop (0.69.0)
jaro_winkler (~> 1.5.1) jaro_winkler (~> 1.5.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1) parser (>= 2.6)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.6) unicode-display_width (>= 1.4.0, < 1.7)
ruby-progressbar (1.10.0) ruby-progressbar (1.10.0)
ruby-saml (1.9.0) ruby-saml (1.9.0)
nokogiri (>= 1.5.10) nokogiri (>= 1.5.10)
@ -630,7 +630,7 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.7.5) unf_ext (0.0.7.5)
unicode-display_width (1.5.0) unicode-display_width (1.6.0)
uniform_notifier (1.12.1) uniform_notifier (1.12.1)
warden (1.2.8) warden (1.2.8)
rack (>= 2.0.6) rack (>= 2.0.6)
@ -673,7 +673,7 @@ DEPENDENCIES
capistrano-rails (~> 1.4) capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1) capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 3.18) capybara (~> 3.19)
charlock_holmes (~> 0.7.6) charlock_holmes (~> 0.7.6)
chewy (~> 5.0) chewy (~> 5.0)
cld3 (~> 3.2.4) cld3 (~> 3.2.4)
@ -751,7 +751,7 @@ DEPENDENCIES
rqrcode (~> 0.10) rqrcode (~> 0.10)
rspec-rails (~> 3.8) rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0) rspec-sidekiq (~> 3.0)
rubocop (~> 0.68) rubocop (~> 0.69)
sanitize (~> 5.0) sanitize (~> 5.0)
scss_lint (~> 0.58) scss_lint (~> 0.58)
sidekiq (~> 5.2) sidekiq (~> 5.2)

View File

@ -41,7 +41,7 @@ module Admin
def destroy def destroy
authorize @domain_block, :destroy? authorize @domain_block, :destroy?
UnblockDomainService.new.call(@domain_block, retroactive_unblock?) UnblockDomainService.new.call(@domain_block)
log_action :destroy, @domain_block log_action :destroy, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.destroyed_msg') redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.destroyed_msg')
end end
@ -53,11 +53,7 @@ module Admin
end end
def resource_params def resource_params
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :retroactive) params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports)
end
def retroactive_unblock?
ActiveRecord::Type.lookup(:boolean).cast(resource_params[:retroactive])
end end
end end
end end

View File

@ -64,7 +64,7 @@ class HomeController < ApplicationController
if request.path.start_with?('/web') if request.path.start_with?('/web')
new_user_session_path new_user_session_path
elsif single_user_mode? elsif single_user_mode?
short_account_path(Account.local.where(suspended: false).first) short_account_path(Account.local.without_suspended.first)
else else
about_path about_path
end end

View File

@ -356,6 +356,7 @@ class Status extends ImmutablePureComponent {
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
<div className='status__expand' onClick={this.handleClick} role='presentation' />
<div className='status__info'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>

View File

@ -331,7 +331,7 @@ export default function compose(state = initialState, action) {
})); }));
case REDRAFT: case REDRAFT:
return state.withMutations(map => { return state.withMutations(map => {
map.set('text', action.raw_content || unescapeHTML(expandMentions(action.status))); map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
map.set('in_reply_to', action.status.get('in_reply_to_id')); map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility')); map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments')); map.set('media_attachments', action.status.get('media_attachments'));

View File

@ -162,7 +162,7 @@
.actions-modal ul li:not(:empty) a:focus button, .actions-modal ul li:not(:empty) a:focus button,
.actions-modal ul li:not(:empty) a:hover, .actions-modal ul li:not(:empty) a:hover,
.actions-modal ul li:not(:empty) a:hover button, .actions-modal ul li:not(:empty) a:hover button,
.admin-wrapper .sidebar ul ul a.selected, .admin-wrapper .sidebar ul li a.selected,
.simple_form .block-button, .simple_form .block-button,
.simple_form .button, .simple_form .button,
.simple_form button { .simple_form button {
@ -230,6 +230,7 @@
.empty-column-indicator, .empty-column-indicator,
.error-column { .error-column {
color: $primary-text-color; color: $primary-text-color;
background: $white;
} }
// Change the default colors used on some parts of the profile pages // Change the default colors used on some parts of the profile pages

View File

@ -1412,6 +1412,15 @@ a.account__display-name {
width: 48px; width: 48px;
} }
.status__expand {
width: 68px;
position: absolute;
left: 0;
top: 0;
height: 100%;
cursor: pointer;
}
.muted { .muted {
.status__content p, .status__content p,
.status__content a { .status__content a {

View File

@ -187,7 +187,7 @@ class Formatter
end end
def rewrite(text, entities) def rewrite(text, entities)
chars = text.to_s.to_char_a text = text.to_s
# Sort by start index # Sort by start index
entities = entities.sort_by do |entity| entities = entities.sort_by do |entity|
@ -199,12 +199,12 @@ class Formatter
last_index = entities.reduce(0) do |index, entity| last_index = entities.reduce(0) do |index, entity|
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices] indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
result << encode(chars[index...indices.first].join) result << encode(text[index...indices.first])
result << yield(entity) result << yield(entity)
indices.last indices.last
end end
result << encode(chars[last_index..-1].join) result << encode(text[last_index..-1])
result.flatten.join result.flatten.join
end end
@ -231,23 +231,14 @@ class Formatter
# Note: I couldn't obtain list_slug with @user/list-name format # Note: I couldn't obtain list_slug with @user/list-name format
# for mention so this requires additional check # for mention so this requires additional check
special = Extractor.extract_urls_with_indices(escaped, options).map do |extract| special = Extractor.extract_urls_with_indices(escaped, options).map do |extract|
# exactly one of :url, :hashtag, :screen_name, :cashtag keys is present
key = (extract.keys & [:url, :hashtag, :screen_name, :cashtag]).first
new_indices = [ new_indices = [
old_to_new_index.find_index(extract[:indices].first), old_to_new_index.find_index(extract[:indices].first),
old_to_new_index.find_index(extract[:indices].last), old_to_new_index.find_index(extract[:indices].last),
] ]
has_prefix_char = [:hashtag, :screen_name, :cashtag].include?(key)
value_indices = [
new_indices.first + (has_prefix_char ? 1 : 0), # account for #, @ or $
new_indices.last - 1,
]
next extract.merge( next extract.merge(
:indices => new_indices, indices: new_indices,
key => text[value_indices.first..value_indices.last] url: text[new_indices.first..new_indices.last - 1]
) )
end end

View File

@ -28,8 +28,6 @@
# header_updated_at :datetime # header_updated_at :datetime
# avatar_remote_url :string # avatar_remote_url :string
# subscription_expires_at :datetime # subscription_expires_at :datetime
# silenced :boolean default(FALSE), not null
# suspended :boolean default(FALSE), not null
# locked :boolean default(FALSE), not null # locked :boolean default(FALSE), not null
# header_remote_url :string default(""), not null # header_remote_url :string default(""), not null
# last_webfingered_at :datetime # last_webfingered_at :datetime
@ -45,6 +43,8 @@
# actor_type :string # actor_type :string
# discoverable :boolean # discoverable :boolean
# also_known_as :string is an Array # also_known_as :string is an Array
# silenced_at :datetime
# suspended_at :datetime
# #
class Account < ApplicationRecord class Account < ApplicationRecord
@ -86,10 +86,10 @@ class Account < ApplicationRecord
scope :local, -> { where(domain: nil) } scope :local, -> { where(domain: nil) }
scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) } scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) } scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
scope :silenced, -> { where(silenced: true) } scope :silenced, -> { where.not(silenced_at: nil) }
scope :suspended, -> { where(suspended: true) } scope :suspended, -> { where.not(suspended_at: nil) }
scope :without_suspended, -> { where(suspended: false) } scope :without_suspended, -> { where(suspended_at: nil) }
scope :without_silenced, -> { where(silenced: false) } scope :without_silenced, -> { where(silenced_at: nil) }
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :bots, -> { where(actor_type: %w(Application Service)) } scope :bots, -> { where(actor_type: %w(Application Service)) }
scope :alphabetic, -> { order(domain: :asc, username: :asc) } scope :alphabetic, -> { order(domain: :asc, username: :asc) }
@ -169,25 +169,35 @@ class Account < ApplicationRecord
ResolveAccountService.new.call(acct) ResolveAccountService.new.call(acct)
end end
def silence! def silenced?
update!(silenced: true) silenced_at.present?
end
def silence!(date = nil)
date ||= Time.now.utc
update!(silenced_at: date)
end end
def unsilence! def unsilence!
update!(silenced: false) update!(silenced_at: nil)
end end
def suspend! def suspended?
suspended_at.present?
end
def suspend!(date = nil)
date ||= Time.now.utc
transaction do transaction do
user&.disable! if local? user&.disable! if local?
update!(suspended: true) update!(suspended_at: date)
end end
end end
def unsuspend! def unsuspend!
transaction do transaction do
user&.enable! if local? user&.enable! if local?
update!(suspended: false) update!(suspended_at: nil)
end end
end end
@ -401,7 +411,7 @@ class Account < ApplicationRecord
ts_rank_cd(#{textsearch}, #{query}, 32) AS rank ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
FROM accounts FROM accounts
WHERE #{query} @@ #{textsearch} WHERE #{query} @@ #{textsearch}
AND accounts.suspended = false AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL AND accounts.moved_to_account_id IS NULL
ORDER BY rank DESC ORDER BY rank DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@ -429,7 +439,7 @@ class Account < ApplicationRecord
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
WHERE accounts.id IN (SELECT * FROM first_degree) WHERE accounts.id IN (SELECT * FROM first_degree)
AND #{query} @@ #{textsearch} AND #{query} @@ #{textsearch}
AND accounts.suspended = false AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL AND accounts.moved_to_account_id IS NULL
GROUP BY accounts.id GROUP BY accounts.id
ORDER BY rank DESC ORDER BY rank DESC
@ -445,7 +455,7 @@ class Account < ApplicationRecord
FROM accounts FROM accounts
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
WHERE #{query} @@ #{textsearch} WHERE #{query} @@ #{textsearch}
AND accounts.suspended = false AND accounts.suspended_at IS NULL
AND accounts.moved_to_account_id IS NULL AND accounts.moved_to_account_id IS NULL
GROUP BY accounts.id GROUP BY accounts.id
ORDER BY rank DESC ORDER BY rank DESC

View File

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

View File

@ -17,8 +17,6 @@ class DomainBlock < ApplicationRecord
enum severity: [:silence, :suspend, :noop] enum severity: [:silence, :suspend, :noop]
attr_accessor :retroactive
validates :domain, presence: true, uniqueness: true validates :domain, presence: true, uniqueness: true
has_many :accounts, foreign_key: :domain, primary_key: :domain has_many :accounts, foreign_key: :domain, primary_key: :domain
@ -36,4 +34,9 @@ class DomainBlock < ApplicationRecord
return false if other_block.silence? && noop? return false if other_block.silence? && noop?
(reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports) (reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports)
end end
def affected_accounts_count
scope = suspend? ? accounts.where(suspended_at: created_at) : accounts.where(silenced_at: created_at)
scope.count
end
end end

View File

@ -87,8 +87,8 @@ class Status < ApplicationRecord
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
scope :with_public_visibility, -> { where(visibility: :public) } scope :with_public_visibility, -> { where(visibility: :public) }
scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) } scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: false }) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
scope :including_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: true }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) } scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
scope :tagged_with_all, ->(tags) { scope :tagged_with_all, ->(tags) {

View File

@ -88,7 +88,7 @@ class User < ApplicationRecord
scope :confirmed, -> { where.not(confirmed_at: nil) } scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :enabled, -> { where(disabled: false) } scope :enabled, -> { where(disabled: false) }
scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) } scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended: false }) } scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where.not(accounts: { suspended_at: nil }) }
scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) } scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }

View File

@ -50,12 +50,12 @@ class ActivityPub::ProcessAccountService < BaseService
def create_account def create_account
@account = Account.new @account = Account.new
@account.protocol = :activitypub @account.protocol = :activitypub
@account.username = @username @account.username = @username
@account.domain = @domain @account.domain = @domain
@account.suspended = true if auto_suspend? @account.private_key = nil
@account.silenced = true if auto_silence? @account.suspended_at = domain_block.created_at if auto_suspend?
@account.private_key = nil @account.silenced_at = domain_block.created_at if auto_silence?
end end
def update_account def update_account

View File

@ -29,7 +29,7 @@ class BlockDomainService < BaseService
end end
def silence_accounts! def silence_accounts!
blocked_domain_accounts.in_batches.update_all(silenced: true) blocked_domain_accounts.without_silenced.in_batches.update_all(silenced_at: @domain_block.created_at)
end end
def clear_media! def clear_media!
@ -43,9 +43,9 @@ class BlockDomainService < BaseService
end end
def suspend_accounts! def suspend_accounts!
blocked_domain_accounts.where(suspended: false).reorder(nil).find_each do |account| blocked_domain_accounts.without_suspended.reorder(nil).find_each do |account|
UnsubscribeService.new.call(account) if account.subscribed? UnsubscribeService.new.call(account) if account.subscribed?
SuspendAccountService.new.call(account) SuspendAccountService.new.call(account, suspended_at: @domain_block.created_at)
end end
end end

View File

@ -52,7 +52,7 @@ class PostStatusService < BaseService
@text = @media.find(&:video?) ? '📹' : '🖼' if @media.size > 0 @text = @media.find(&:video?) ? '📹' : '🖼' if @media.size > 0
end end
@visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = @options[:visibility] || @account.user&.setting_default_privacy
@visibility = :unlisted if @visibility == :public && @account.silenced @visibility = :unlisted if @visibility == :public && @account.silenced?
@scheduled_at = @options[:scheduled_at]&.to_datetime @scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past? @scheduled_at = nil if scheduled_in_the_past?
rescue ArgumentError rescue ArgumentError

View File

@ -25,7 +25,7 @@ class ProcessMentionsService < BaseService
end end
end end
next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status) mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status)

View File

@ -119,9 +119,9 @@ class ResolveAccountService < BaseService
Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}" Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
@account = Account.new(username: @username, domain: @domain) @account = Account.new(username: @username, domain: @domain)
@account.suspended = true if auto_suspend? @account.suspended_at = domain_block.created_at if auto_suspend?
@account.silenced = true if auto_silence? @account.silenced_at = domain_block.created_at if auto_silence?
@account.private_key = nil @account.private_key = nil
end end
def update_account def update_account

View File

@ -43,7 +43,7 @@ class SubscribeService < BaseService
end end
def some_local_account def some_local_account
@some_local_account ||= Account.local.where(suspended: false).first @some_local_account ||= Account.local.without_suspended.first
end end
# Any response in the 3xx or 4xx range, except for 429 (rate limit) # Any response in the 3xx or 4xx range, except for 429 (rate limit)

View File

@ -88,8 +88,8 @@ class SuspendAccountService < BaseService
return if @options[:destroy] return if @options[:destroy]
@account.silenced = false @account.silenced_at = nil
@account.suspended = true @account.suspended_at = @options[:suspended_at] || Time.now.utc
@account.locked = false @account.locked = false
@account.display_name = '' @account.display_name = ''
@account.note = '' @account.note = ''

View File

@ -3,9 +3,9 @@
class UnblockDomainService < BaseService class UnblockDomainService < BaseService
attr_accessor :domain_block attr_accessor :domain_block
def call(domain_block, retroactive) def call(domain_block)
@domain_block = domain_block @domain_block = domain_block
process_retroactive_updates if retroactive process_retroactive_updates
domain_block.destroy domain_block.destroy
end end
@ -14,14 +14,19 @@ class UnblockDomainService < BaseService
end end
def blocked_accounts def blocked_accounts
Account.where(domain: domain_block.domain) scope = Account.where(domain: domain_block.domain)
if domain_block.silence?
scope.where(silenced_at: @domain_block.created_at)
else
scope.where(suspended_at: @domain_block.created_at)
end
end end
def update_options def update_options
{ domain_block_impact => false } { domain_block_impact => nil }
end end
def domain_block_impact def domain_block_impact
domain_block.silence? ? :silenced : :suspended domain_block.silence? ? :silenced_at : :suspended_at
end end
end end

View File

@ -3,18 +3,11 @@
= simple_form_for @domain_block, url: admin_domain_block_path(@domain_block), method: :delete do |f| = simple_form_for @domain_block, url: admin_domain_block_path(@domain_block), method: :delete do |f|
- if (@domain_block.noop?) - unless (@domain_block.noop?)
= f.input :retroactive, %p= t(".retroactive.#{@domain_block.severity}")
as: :hidden, %p.hint= t(:affected_accounts,
input_html: { :value => "0" } scope: [:admin, :domain_blocks, :show],
- else count: @domain_block.affected_accounts_count)
= f.input :retroactive,
as: :boolean,
wrapper: :with_label,
label: t(".retroactive.#{@domain_block.severity}"),
hint: t(:affected_accounts,
scope: [:admin, :domain_blocks, :show],
count: @domain_block.accounts_count)
.actions .actions
= f.button :button, t('.undo'), type: :submit = f.button :button, t('.undo'), type: :submit

View File

@ -294,8 +294,8 @@ en:
one: One account in the database affected one: One account in the database affected
other: "%{count} accounts in the database affected" other: "%{count} accounts in the database affected"
retroactive: retroactive:
silence: Unsilence all existing accounts from this domain silence: Unsilence existing affected accounts from this domain
suspend: Unsuspend all existing accounts from this domain suspend: Unsuspend existing affected accounts from this domain
title: Undo domain block for %{domain} title: Undo domain block for %{domain}
undo: Undo undo: Undo
undo: Undo domain block undo: Undo domain block

View File

@ -128,7 +128,7 @@ fr:
follow: Envoyer un courriel lorsque quelquun me suit follow: Envoyer un courriel lorsque quelquun me suit
follow_request: Envoyer un courriel lorsque quelquun demande à me suivre follow_request: Envoyer un courriel lorsque quelquun demande à me suivre
mention: Envoyer un courriel lorsque quelquun me mentionne mention: Envoyer un courriel lorsque quelquun me mentionne
pending_account: Envoyer un courriel lorsqu'un nouveau compte est en attente d'approbation pending_account: Envoyer un courriel lorsquun nouveau compte est en attente dapprobation
reblog: Envoyer un courriel lorsque quelquun partage mes statuts reblog: Envoyer un courriel lorsque quelquun partage mes statuts
report: Envoyer un courriel lorsquun nouveau rapport est soumis report: Envoyer un courriel lorsquun nouveau rapport est soumis
'no': Non 'no': Non

View File

@ -28,12 +28,12 @@ sk:
scopes: Ktoré API budú povolené aplikácii pre prístup. Ak vyberieš vrcholný stupeň, nemusíš už potom vyberať po jednom. scopes: Ktoré API budú povolené aplikácii pre prístup. Ak vyberieš vrcholný stupeň, nemusíš už potom vyberať po jednom.
setting_aggregate_reblogs: Nezobrazuj nové vyzdvihnutia pre príspevky, ktoré už boli len nedávno povýšené (týka sa iba nanovo získaných povýšení) setting_aggregate_reblogs: Nezobrazuj nové vyzdvihnutia pre príspevky, ktoré už boli len nedávno povýšené (týka sa iba nanovo získaných povýšení)
setting_default_language: Jazyk tvojích príspevkov môže byť zistený automaticky, ale nieje to vždy presné setting_default_language: Jazyk tvojích príspevkov môže byť zistený automaticky, ale nieje to vždy presné
setting_display_media_default: Skryť médiá označené ako citlivé setting_display_media_default: Skry médiá označené ako citlivé
setting_display_media_hide_all: Vždy ukryť všetky médiá setting_display_media_hide_all: Vždy ukry všetky médiá
setting_display_media_show_all: Stále ukazuj médiá označené ako citlivé setting_display_media_show_all: Stále zobrazuj médiá označené ako citlivé
setting_hide_network: Koho následuješ, a kto následuje teba nebude zobrazené na tvojom profile setting_hide_network: Koho následuješ, a kto následuje teba, nebude zobrazené na tvojom profile
setting_noindex: Ovplyvňuje verejný profil a stránky s príspevkami setting_noindex: Ovplyvňuje verejný profil a stránky s príspevkami
setting_show_application: Aplikácia, ktorú používaš na písanie príspevkov, bude zobrazená v detailnom náhľade jednotlivých tvojích príspevkov setting_show_application: Aplikácia, ktorú používaš na písanie príspevkov, bude zobrazená v podrobnom náhľade jednotlivých tvojích príspevkov
setting_theme: Ovplyvňuje ako Mastodon vyzerá pri prihlásení z hociakého zariadenia. setting_theme: Ovplyvňuje ako Mastodon vyzerá pri prihlásení z hociakého zariadenia.
username: Tvoja prezývka bude unikátna pre server %{domain} username: Tvoja prezývka bude unikátna pre server %{domain}
whole_word: Ak je kľúčové slovo, alebo fráza poskladaná iba s písmen a čísel, bude použité iba ak sa zhoduje s celým výrazom whole_word: Ak je kľúčové slovo, alebo fráza poskladaná iba s písmen a čísel, bude použité iba ak sa zhoduje s celým výrazom
@ -41,6 +41,8 @@ sk:
name: 'Možno by si chcel/a použiť niektoré z týchto:' name: 'Možno by si chcel/a použiť niektoré z týchto:'
imports: imports:
data: CSV súbor vyexportovaný z iného Mastodon serveru data: CSV súbor vyexportovaný z iného Mastodon serveru
invite_request:
text: Toto pomôže s vyhodnocovaním tvojej žiadosti
sessions: sessions:
otp: 'Napíš sem dvoj-faktorový kód z telefónu, alebo použi jeden z tvojích obnovovacích kódov:' otp: 'Napíš sem dvoj-faktorový kód z telefónu, alebo použi jeden z tvojích obnovovacích kódov:'
user: user:
@ -59,7 +61,7 @@ sk:
types: types:
disable: Deaktivuj disable: Deaktivuj
none: Neurob nič none: Neurob nič
silence: Utíšenie silence: Utíš
suspend: Vylúč a nenávratne vymaž dáta na účte suspend: Vylúč a nenávratne vymaž dáta na účte
warning_preset_id: Použi varovnú predlohu warning_preset_id: Použi varovnú predlohu
defaults: defaults:
@ -119,13 +121,14 @@ sk:
must_be_following: Blokuj oboznámenia od ľudí, ktorých nesledujem must_be_following: Blokuj oboznámenia od ľudí, ktorých nesledujem
must_be_following_dm: Blokuj súkromné správy od ľudí ktorých nesledujem must_be_following_dm: Blokuj súkromné správy od ľudí ktorých nesledujem
notification_emails: notification_emails:
digest: Posielaj súhrnné emaily digest: Zasielať súhrnné emaily
favourite: Poslať email ak si niekto obľúbi tvoj príspevok favourite: Zaslať email, ak si niekto obľúbi tvoj príspevok
follow: Poslať email, ak ťa niekto začne následovať follow: Zaslať email, ak ťa niekto začne následovať
follow_request: Zaslať email ak ti niekto pošle žiadosť o sledovanie follow_request: Zaslať email, ak ti niekto pošle žiadosť o sledovanie
mention: Poslať email ak ťa niekto spomenie v svojom príspevku mention: Zaslať email, ak ťa niekto spomenie vo svojom príspevku
reblog: Poslať email ak niekto re-tootne tvoj príspevok pending_account: Zaslať email, ak treba prehodnotiť nový účet
report: Poslať e-mail ak niekto dodá nové hlásenie reblog: Zaslať email, ak niekto re-tootne tvoj príspevok
report: Zaslať email, ak niekto podá nové nahlásenie
'no': Nie 'no': Nie
required: required:
mark: "*" mark: "*"

View File

@ -0,0 +1,41 @@
class AddSilencedAtSuspendedAtToAccounts < ActiveRecord::Migration[5.2]
class Account < ApplicationRecord
# Dummy class, to make migration possible across version changes
end
class DomainBlock < ApplicationRecord
# Dummy class, to make migration possible across version changes
enum severity: [:silence, :suspend, :noop]
has_many :accounts, foreign_key: :domain, primary_key: :domain
end
def up
add_column :accounts, :silenced_at, :datetime
add_column :accounts, :suspended_at, :datetime
# Record suspend date of blocks and silences for users whose limitations match
# a domain block
DomainBlock.where(severity: [:silence, :suspend]).find_each do |block|
scope = block.accounts
if block.suspend?
block.accounts.where(suspended: true).in_batches.update_all(suspended_at: block.created_at)
else
block.accounts.where(silenced: true).in_batches.update_all(silenced_at: block.created_at)
end
end
# Set dates for accounts which have limitations not related to a domain block
Account.where(suspended: true, suspended_at: nil).in_batches.update_all(suspended_at: Time.now.utc)
Account.where(silenced: true, silenced_at: nil).in_batches.update_all(silenced_at: Time.now.utc)
end
def down
# Block or silence accounts that have a date set
Account.where(suspended: false).where.not(suspended_at: nil).in_batches.update_all(suspended: true)
Account.where(silenced: false).where.not(silenced_at: nil).in_batches.update_all(silenced: true)
remove_column :accounts, :silenced_at
remove_column :accounts, :suspended_at
end
end

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
class RemoveSuspendedSilencedAccountFields < ActiveRecord::Migration[5.2]
class Account < ApplicationRecord
# Dummy class, to make migration possible across version changes
end
class DomainBlock < ApplicationRecord
# Dummy class, to make migration possible across version changes
enum severity: [:silence, :suspend, :noop]
has_many :accounts, foreign_key: :domain, primary_key: :domain
end
disable_ddl_transaction!
def up
# Record suspend date of blocks and silences for users whose limitations match
# a domain block
DomainBlock.where(severity: [:silence, :suspend]).find_each do |block|
scope = block.accounts
if block.suspend?
block.accounts.where(suspended: true).in_batches.update_all(suspended_at: block.created_at)
else
block.accounts.where(silenced: true).in_batches.update_all(silenced_at: block.created_at)
end
end
# Set dates for accounts which have limitations not related to a domain block
Account.where(suspended: true, suspended_at: nil).in_batches.update_all(suspended_at: Time.now.utc)
Account.where(silenced: true, silenced_at: nil).in_batches.update_all(silenced_at: Time.now.utc)
safety_assured do
remove_column :accounts, :suspended, :boolean, null: false, default: false
remove_column :accounts, :silenced, :boolean, null: false, default: false
end
end
def down
safety_assured do
add_column :accounts, :suspended, :boolean, null: false, default: false
add_column :accounts, :silenced, :boolean, null: false, default: false
end
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_05_09_164208) do ActiveRecord::Schema.define(version: 2019_05_11_152737) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -131,8 +131,6 @@ ActiveRecord::Schema.define(version: 2019_05_09_164208) do
t.datetime "header_updated_at" t.datetime "header_updated_at"
t.string "avatar_remote_url" t.string "avatar_remote_url"
t.datetime "subscription_expires_at" t.datetime "subscription_expires_at"
t.boolean "silenced", default: false, null: false
t.boolean "suspended", default: false, null: false
t.boolean "locked", default: false, null: false t.boolean "locked", default: false, null: false
t.string "header_remote_url", default: "", null: false t.string "header_remote_url", default: "", null: false
t.datetime "last_webfingered_at" t.datetime "last_webfingered_at"
@ -148,6 +146,8 @@ ActiveRecord::Schema.define(version: 2019_05_09_164208) do
t.string "actor_type" t.string "actor_type"
t.boolean "discoverable" t.boolean "discoverable"
t.string "also_known_as", array: true t.string "also_known_as", array: true
t.datetime "silenced_at"
t.datetime "suspended_at"
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id" t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id"

View File

@ -106,7 +106,7 @@ module Mastodon
[json, account.id, inbox_url] [json, account.id, inbox_url]
end end
account.update_column(:suspended, true) account.suspend!
end end
processed += 1 processed += 1

View File

@ -87,8 +87,8 @@ module Mastodon
end end
end end
account.suspended = false account.suspended_at = nil
user.account = account user.account = account
if user.save if user.save
if options[:confirmed] if options[:confirmed]

View File

@ -63,9 +63,9 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
service = double(call: true) service = double(call: true)
allow(UnblockDomainService).to receive(:new).and_return(service) allow(UnblockDomainService).to receive(:new).and_return(service)
domain_block = Fabricate(:domain_block) domain_block = Fabricate(:domain_block)
delete :destroy, params: { id: domain_block.id, domain_block: { retroactive: '1' } } delete :destroy, params: { id: domain_block.id }
expect(service).to have_received(:call).with(domain_block, true) expect(service).to have_received(:call).with(domain_block)
expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.destroyed_msg') expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.destroyed_msg')
expect(response).to redirect_to(admin_instances_path(limited: '1')) expect(response).to redirect_to(admin_instances_path(limited: '1'))
end end

View File

@ -3,8 +3,11 @@ public_key = keypair.public_key.to_pem
private_key = keypair.to_pem private_key = keypair.to_pem
Fabricator(:account) do Fabricator(:account) do
transient :suspended, :silenced
username { sequence(:username) { |i| "#{Faker::Internet.user_name(nil, %w(_))}#{i}" } } username { sequence(:username) { |i| "#{Faker::Internet.user_name(nil, %w(_))}#{i}" } }
last_webfingered_at { Time.now.utc } last_webfingered_at { Time.now.utc }
public_key { public_key } public_key { public_key }
private_key { private_key } private_key { private_key }
suspended_at { |attrs| attrs[:suspended] ? Time.now.utc : nil }
silenced_at { |attrs| attrs[:silenced] ? Time.now.utc : nil }
end end

View File

@ -175,13 +175,13 @@ RSpec.describe FeedManager do
it 'returns true for status by silenced account who recipient is not following' do it 'returns true for status by silenced account who recipient is not following' do
status = Fabricate(:status, text: 'Hello world', account: alice) status = Fabricate(:status, text: 'Hello world', account: alice)
alice.update(silenced: true) alice.silence!
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
end end
it 'returns false for status by followed silenced account' do it 'returns false for status by followed silenced account' do
status = Fabricate(:status, text: 'Hello world', account: alice) status = Fabricate(:status, text: 'Hello world', account: alice)
alice.update(silenced: true) alice.silence!
bob.follow!(alice) bob.follow!(alice)
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
end end

View File

@ -15,7 +15,7 @@ describe StatusFilter do
context 'when status account is silenced' do context 'when status account is silenced' do
before do before do
status.account.update(silenced: true) status.account.silence!
end end
it { is_expected.to be_filtered } it { is_expected.to be_filtered }
@ -65,7 +65,7 @@ describe StatusFilter do
context 'when status account is silenced' do context 'when status account is silenced' do
before do before do
status.account.update(silenced: true) status.account.silence!
end end
it { is_expected.to be_filtered } it { is_expected.to be_filtered }

View File

@ -35,7 +35,7 @@ describe StatusThreadingConcern do
end end
it 'does not return conversation history from silenced and not followed users' do it 'does not return conversation history from silenced and not followed users' do
jeff.update(silenced: true) jeff.silence!
expect(reply3.ancestors(4, viewer)).to_not include(reply1) expect(reply3.ancestors(4, viewer)).to_not include(reply1)
end end
@ -110,7 +110,7 @@ describe StatusThreadingConcern do
end end
it 'does not return replies from silenced and not followed users' do it 'does not return replies from silenced and not followed users' do
jeff.update(silenced: true) jeff.silence!
expect(status.descendants(4, viewer)).to_not include(reply3) expect(status.descendants(4, viewer)).to_not include(reply3)
end end

View File

@ -1,20 +1,14 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe BlockDomainService, type: :service do RSpec.describe BlockDomainService, type: :service do
let(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') } let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') }
let(:bad_status1) { Fabricate(:status, account: bad_account, text: 'You suck') } let!(:bad_status1) { Fabricate(:status, account: bad_account, text: 'You suck') }
let(:bad_status2) { Fabricate(:status, account: bad_account, text: 'Hahaha') } let!(:bad_status2) { Fabricate(:status, account: bad_account, text: 'Hahaha') }
let(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status2, file: attachment_fixture('attachment.jpg')) } let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status2, file: attachment_fixture('attachment.jpg')) }
let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: 'evil.org', suspended: true, silenced: true) }
subject { BlockDomainService.new } subject { BlockDomainService.new }
before do
bad_account
bad_status1
bad_status2
bad_attachment
end
describe 'for a suspension' do describe 'for a suspension' do
before do before do
subject.call(DomainBlock.create!(domain: 'evil.org', severity: :suspend)) subject.call(DomainBlock.create!(domain: 'evil.org', severity: :suspend))
@ -28,6 +22,18 @@ RSpec.describe BlockDomainService, type: :service do
expect(Account.find_remote('badguy666', 'evil.org').suspended?).to be true expect(Account.find_remote('badguy666', 'evil.org').suspended?).to be true
end end
it 'records suspension date appropriately' do
expect(Account.find_remote('badguy666', 'evil.org').suspended_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at
end
it 'keeps already-banned accounts banned' do
expect(Account.find_remote('badguy', 'evil.org').suspended?).to be true
end
it 'does not overwrite suspension date of already-banned accounts' do
expect(Account.find_remote('badguy', 'evil.org').suspended_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at
end
it 'removes the remote accounts\'s statuses and media attachments' do it 'removes the remote accounts\'s statuses and media attachments' do
expect { bad_status1.reload }.to raise_exception ActiveRecord::RecordNotFound expect { bad_status1.reload }.to raise_exception ActiveRecord::RecordNotFound
expect { bad_status2.reload }.to raise_exception ActiveRecord::RecordNotFound expect { bad_status2.reload }.to raise_exception ActiveRecord::RecordNotFound
@ -48,6 +54,18 @@ RSpec.describe BlockDomainService, type: :service do
expect(Account.find_remote('badguy666', 'evil.org').silenced?).to be true expect(Account.find_remote('badguy666', 'evil.org').silenced?).to be true
end end
it 'records suspension date appropriately' do
expect(Account.find_remote('badguy666', 'evil.org').silenced_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at
end
it 'keeps already-banned accounts banned' do
expect(Account.find_remote('badguy', 'evil.org').silenced?).to be true
end
it 'does not overwrite suspension date of already-banned accounts' do
expect(Account.find_remote('badguy', 'evil.org').silenced_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at
end
it 'leaves the domains status and attachements, but clears media' do it 'leaves the domains status and attachements, but clears media' do
expect { bad_status1.reload }.not_to raise_error expect { bad_status1.reload }.not_to raise_error
expect { bad_status2.reload }.not_to raise_error expect { bad_status2.reload }.not_to raise_error

View File

@ -39,12 +39,12 @@ RSpec.describe NotifyService, type: :service do
end end
it 'does not notify when sender is silenced and not followed' do it 'does not notify when sender is silenced and not followed' do
sender.update(silenced: true) sender.silence!
is_expected.to_not change(Notification, :count) is_expected.to_not change(Notification, :count)
end end
it 'does not notify when recipient is suspended' do it 'does not notify when recipient is suspended' do
recipient.update(suspended: true) recipient.suspend!
is_expected.to_not change(Notification, :count) is_expected.to_not change(Notification, :count)
end end

View File

@ -7,36 +7,33 @@ describe UnblockDomainService, type: :service do
describe 'call' do describe 'call' do
before do before do
@silenced = Fabricate(:account, domain: 'example.com', silenced: true) @independently_suspended = Fabricate(:account, domain: 'example.com', suspended_at: 1.hour.ago)
@suspended = Fabricate(:account, domain: 'example.com', suspended: true) @independently_silenced = Fabricate(:account, domain: 'example.com', silenced_at: 1.hour.ago)
@domain_block = Fabricate(:domain_block, domain: 'example.com') @domain_block = Fabricate(:domain_block, domain: 'example.com')
@silenced = Fabricate(:account, domain: 'example.com', silenced_at: @domain_block.created_at)
@suspended = Fabricate(:account, domain: 'example.com', suspended_at: @domain_block.created_at)
end end
context 'without retroactive' do it 'unsilences accounts and removes block' do
it 'removes the domain block' do @domain_block.update(severity: :silence)
subject.call(@domain_block, false)
expect_deleted_domain_block subject.call(@domain_block)
end expect_deleted_domain_block
expect(@silenced.reload.silenced?).to be false
expect(@suspended.reload.suspended?).to be true
expect(@independently_suspended.reload.suspended?).to be true
expect(@independently_silenced.reload.silenced?).to be true
end end
context 'with retroactive' do it 'unsuspends accounts and removes block' do
it 'unsilences accounts and removes block' do @domain_block.update(severity: :suspend)
@domain_block.update(severity: :silence)
subject.call(@domain_block, true) subject.call(@domain_block)
expect_deleted_domain_block expect_deleted_domain_block
expect(@silenced.reload.silenced).to be false expect(@suspended.reload.suspended?).to be false
expect(@suspended.reload.suspended).to be true expect(@silenced.reload.silenced?).to be true
end expect(@independently_suspended.reload.suspended?).to be true
expect(@independently_silenced.reload.silenced?).to be true
it 'unsuspends accounts and removes block' do
@domain_block.update(severity: :suspend)
subject.call(@domain_block, true)
expect_deleted_domain_block
expect(@suspended.reload.suspended).to be false
expect(@silenced.reload.silenced).to be true
end
end end
end end