Browse Source

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

Conflicts:
- `app/views/admin/pending_accounts/index.html.haml`:
  Removed upstream, while it had glitch-soc-specific changes to accomodate
  for glitch-soc's theming system.
  Removed the file.

Additional changes:
- `app/views/admin/accounts/index.html.haml':
  Accomodate for glitch-soc's theming system.
main
Claire 1 month ago
parent
commit
b2526316f5
  1. 1
      .dockerignore
  2. 1
      .gitignore
  3. 33
      app/controllers/admin/accounts_controller.rb
  4. 52
      app/controllers/admin/pending_accounts_controller.rb
  5. 4
      app/controllers/concerns/accountable_concern.rb
  6. 2
      app/controllers/concerns/two_factor_authentication_concern.rb
  7. 2
      app/helpers/admin/action_logs_helper.rb
  8. 39
      app/helpers/admin/dashboard_helper.rb
  9. 27
      app/javascript/mastodon/actions/compose.js
  10. 14
      app/javascript/mastodon/reducers/compose.js
  11. 8
      app/javascript/packs/public.js
  12. 30
      app/javascript/styles/mastodon/accounts.scss
  13. 5
      app/javascript/styles/mastodon/tables.scss
  14. 18
      app/javascript/styles/mastodon/widgets.scss
  15. 2
      app/models/account.rb
  16. 91
      app/models/account_filter.rb
  17. 2
      app/models/admin/action_log.rb
  18. 2
      app/models/admin/action_log_filter.rb
  19. 51
      app/models/form/account_batch.rb
  20. 2
      app/models/trends/tags.rb
  21. 59
      app/views/admin/accounts/_account.html.haml
  22. 53
      app/views/admin/accounts/index.html.haml
  23. 2
      app/views/admin/dashboard/index.html.haml
  24. 2
      app/views/admin/instances/show.html.haml
  25. 6
      app/views/admin/ip_blocks/_ip_block.html.haml
  26. 16
      app/views/admin/pending_accounts/_account.html.haml
  27. 30
      app/views/admin/pending_accounts/index.html.haml
  28. 2
      app/views/admin_mailer/new_pending_account.text.erb
  29. 4
      app/workers/scheduler/follow_recommendations_scheduler.rb
  30. 17
      config/locales/en.yml
  31. 2
      config/navigation.rb
  32. 12
      config/routes.rb
  33. 6
      crowdin.yml
  34. 24
      db/migrate/20211213040746_update_account_summaries_to_version_2.rb
  35. 4
      db/schema.rb
  36. 23
      db/views/account_summaries_v02.sql
  37. 186
      lib/mastodon/statuses_cli.rb
  38. 14
      package.json
  39. 14
      spec/controllers/admin/accounts_controller_spec.rb
  40. 42
      spec/models/account_filter_spec.rb
  41. 917
      yarn.lock

1
.dockerignore

@ -15,6 +15,7 @@ vendor/bundle
*.swp
*~
postgres
postgres14
redis
elasticsearch
chart

1
.gitignore

@ -40,6 +40,7 @@
# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose
/postgres
/postgres14
/redis
/elasticsearch

33
app/controllers/admin/accounts_controller.rb

@ -2,13 +2,24 @@
module Admin
class AccountsController < BaseController
before_action :set_account, except: [:index]
before_action :set_account, except: [:index, :batch]
before_action :require_remote_account!, only: [:redownload]
before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
def index
authorize :account, :index?
@accounts = filtered_accounts.page(params[:page])
@form = Form::AccountBatch.new
end
def batch
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_accounts_path(filter_params)
end
def show
@ -38,13 +49,13 @@ module Admin
def approve
authorize @account.user, :approve?
@account.user.approve!
redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
end
def reject
authorize @account.user, :reject?
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
redirect_to admin_accounts_path(status: 'pending'), notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
end
def destroy
@ -121,11 +132,25 @@ module Admin
end
def filtered_accounts
AccountFilter.new(filter_params).results
AccountFilter.new(filter_params.with_defaults(order: 'recent')).results
end
def filter_params
params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def action_from_button
if params[:suspend]
'suspend'
elsif params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
end
end

52
app/controllers/admin/pending_accounts_controller.rb

@ -1,52 +0,0 @@
# frozen_string_literal: true
module Admin
class PendingAccountsController < BaseController
before_action :set_accounts, only: :index
def index
@form = Form::AccountBatch.new
end
def batch
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
ensure
redirect_to admin_pending_accounts_path(current_params)
end
def approve_all
Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'approve').save
redirect_to admin_pending_accounts_path(current_params)
end
def reject_all
Form::AccountBatch.new(current_account: current_account, account_ids: User.pending.pluck(:account_id), action: 'reject').save
redirect_to admin_pending_accounts_path(current_params)
end
private
def set_accounts
@accounts = Account.joins(:user).merge(User.pending.recent).includes(user: :invite_request).page(params[:page])
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def action_from_button
if params[:approve]
'approve'
elsif params[:reject]
'reject'
end
end
def current_params
params.slice(:page).permit(:page)
end
end
end

4
app/controllers/concerns/accountable_concern.rb

@ -3,7 +3,7 @@
module AccountableConcern
extend ActiveSupport::Concern
def log_action(action, target)
Admin::ActionLog.create(account: current_account, action: action, target: target)
def log_action(action, target, options = {})
Admin::ActionLog.create(account: current_account, action: action, target: target, recorded_changes: options.stringify_keys)
end
end

2
app/controllers/concerns/two_factor_authentication_concern.rb

@ -57,7 +57,7 @@ module TwoFactorAuthenticationConcern
if valid_webauthn_credential?(user, webauthn_credential)
on_authentication_success(user, :webauthn)
render json: { redirect_path: root_path }, status: :ok
render json: { redirect_path: after_sign_in_path_for(user) }, status: :ok
else
on_authentication_failure(user, :webauthn, :invalid_credential)
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity

2
app/helpers/admin/action_logs_helper.rb

@ -36,6 +36,8 @@ module Admin::ActionLogsHelper
def log_target_from_history(type, attributes)
case type
when 'User'
attributes['username']
when 'CustomEmoji'
attributes['shortcode']
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'

39
app/helpers/admin/dashboard_helper.rb

@ -1,10 +1,41 @@
# frozen_string_literal: true
module Admin::DashboardHelper
def feature_hint(feature, enabled)
indicator = safe_join([enabled ? t('simple_form.yes') : t('simple_form.no'), fa_icon('power-off fw')], ' ')
class_names = enabled ? 'pull-right positive-hint' : 'pull-right neutral-hint'
def relevant_account_ip(account, ip_query)
default_ip = [account.user_current_sign_in_ip || account.user_sign_up_ip]
safe_join([feature, content_tag(:span, indicator, class: class_names)])
matched_ip = begin
ip_query_addr = IPAddr.new(ip_query)
account.user.recent_ips.find { |(_, ip)| ip_query_addr.include?(ip) } || default_ip
rescue IPAddr::Error
default_ip
end.last
if matched_ip
link_to matched_ip, admin_accounts_path(ip: matched_ip)
else
'-'
end
end
def relevant_account_timestamp(account)
timestamp, exact = begin
if account.user_current_sign_in_at && account.user_current_sign_in_at < 24.hours.ago
[account.user_current_sign_in_at, true]
elsif account.user_current_sign_in_at
[account.user_current_sign_in_at, false]
elsif account.user_pending?
[account.user_created_at, true]
elsif account.last_status_at.present?
[account.last_status_at, true]
else
[nil, false]
end
end
return '-' if timestamp.nil?
return t('generic.today') unless exact
content_tag(:time, l(timestamp), class: 'time-ago', datetime: timestamp.iso8601, title: l(timestamp))
end
end

27
app/javascript/mastodon/actions/compose.js

@ -37,6 +37,7 @@ export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
export const COMPOSE_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE';
export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
@ -536,13 +537,25 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
startPosition = position;
}
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position: startPosition,
token,
completion,
path,
});
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
// the suggestions are dismissed and the cursor moves forward.
if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position: startPosition,
token,
completion,
path,
});
} else {
dispatch({
type: COMPOSE_SUGGESTION_IGNORE,
position: startPosition,
token,
completion,
path,
});
}
};
};

14
app/javascript/mastodon/reducers/compose.js

@ -21,6 +21,7 @@ import {
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_IGNORE,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_SENSITIVITY_CHANGE,
@ -165,6 +166,17 @@ const insertSuggestion = (state, position, token, completion, path) => {
});
};
const ignoreSuggestion = (state, position, token, completion, path) => {
return state.withMutations(map => {
map.updateIn(path, oldText => `${oldText.slice(0, position + token.length)} ${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null);
map.set('suggestions', ImmutableList());
map.set('focusDate', new Date());
map.set('caretPosition', position + token.length + 1);
map.set('idempotencyKey', uuid());
});
};
const sortHashtagsByUse = (state, tags) => {
const personalHistory = state.get('tagHistory');
@ -398,6 +410,8 @@ export default function compose(state = initialState, action) {
return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion, action.path);
case COMPOSE_SUGGESTION_IGNORE:
return ignoreSuggestion(state, action.position, action.token, action.completion, action.path);
case COMPOSE_SUGGESTION_TAGS_UPDATE:
return updateSuggestionTags(state, action.token);
case COMPOSE_TAG_HISTORY_UPDATE:

8
app/javascript/packs/public.js

@ -103,7 +103,9 @@ function main() {
delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
const password = document.getElementById('registration_user_password');
const confirmation = document.getElementById('registration_user_password_confirmation');
if (password.value && password.value !== confirmation.value) {
if (confirmation.value && confirmation.value.length > password.maxLength) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
} else if (password.value && password.value !== confirmation.value) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
} else {
confirmation.setCustomValidity('');
@ -115,7 +117,9 @@ function main() {
const confirmation = document.getElementById('user_password_confirmation');
if (!confirmation) return;
if (password.value && password.value !== confirmation.value) {
if (confirmation.value && confirmation.value.length > password.maxLength) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
} else if (password.value && password.value !== confirmation.value) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
} else {
confirmation.setCustomValidity('');

30
app/javascript/styles/mastodon/accounts.scss

@ -326,7 +326,12 @@
}
}
.batch-table__row--muted .pending-account__header {
.batch-table__row--muted {
color: lighten($ui-base-color, 26%);
}
.batch-table__row--muted .pending-account__header,
.batch-table__row--muted .accounts-table {
&,
a,
strong {
@ -334,10 +339,31 @@
}
}
.batch-table__row--attention .pending-account__header {
.batch-table__row--muted .accounts-table {
tbody td.accounts-table__extra,
&__count,
&__count small {
color: lighten($ui-base-color, 26%);
}
}
.batch-table__row--attention {
color: $gold-star;
}
.batch-table__row--attention .pending-account__header,
.batch-table__row--attention .accounts-table {
&,
a,
strong {
color: $gold-star;
}
}
.batch-table__row--attention .accounts-table {
tbody td.accounts-table__extra,
&__count,
&__count small {
color: $gold-star;
}
}

5
app/javascript/styles/mastodon/tables.scss

@ -237,6 +237,11 @@ a.table-action-link {
flex: 1 1 auto;
}
&__quote {
padding: 12px;
padding-top: 0;
}
&__extra {
flex: 0 0 auto;
text-align: right;

18
app/javascript/styles/mastodon/widgets.scss

@ -443,6 +443,24 @@
}
}
tbody td.accounts-table__extra {
width: 120px;
text-align: right;
color: $darker-text-color;
padding-right: 16px;
a {
text-decoration: none;
color: inherit;
&:focus,
&:hover,
&:active {
text-decoration: underline;
}
}
}
&__comment {
width: 50%;
vertical-align: initial !important;

2
app/models/account.rb

@ -129,6 +129,8 @@ class Account < ApplicationRecord
:unconfirmed_email,
:current_sign_in_ip,
:current_sign_in_at,
:created_at,
:sign_up_ip,
:confirmed?,
:approved?,
:pending?,

91
app/models/account_filter.rb

@ -2,18 +2,15 @@
class AccountFilter
KEYS = %i(
local
remote
by_domain
active
pending
silenced
suspended
origin
status
permissions
username
by_domain
display_name
email
ip
staff
invited_by
order
).freeze
@ -21,11 +18,10 @@ class AccountFilter
def initialize(params)
@params = params
set_defaults!
end
def results
scope = Account.includes(:user).reorder(nil)
scope = Account.includes(:account_stat, user: [:session_activations, :invite_request]).without_instance_actor.reorder(nil)
params.each do |key, value|
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
@ -36,30 +32,16 @@ class AccountFilter
private
def set_defaults!
params['local'] = '1' if params['remote'].blank?
params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? && params['pending'].blank?
params['order'] = 'recent' if params['order'].blank?
end
def scope_for(key, value)
case key.to_s
when 'local'
Account.local.without_instance_actor
when 'remote'
Account.remote
when 'origin'
origin_scope(value)
when 'permissions'
permissions_scope(value)
when 'status'
status_scope(value)
when 'by_domain'
Account.where(domain: value)
when 'active'
Account.without_suspended
when 'pending'
accounts_with_users.merge(User.pending)
when 'disabled'
accounts_with_users.merge(User.disabled)
when 'silenced'
Account.silenced
when 'suspended'
Account.suspended
when 'username'
Account.matches_username(value)
when 'display_name'
@ -68,8 +50,8 @@ class AccountFilter
accounts_with_users.merge(User.matches_email(value))
when 'ip'
valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none
when 'staff'
accounts_with_users.merge(User.staff)
when 'invited_by'
invited_by_scope(value)
when 'order'
order_scope(value)
else
@ -77,21 +59,56 @@ class AccountFilter
end
end
def origin_scope(value)
case value.to_s
when 'local'
Account.local
when 'remote'
Account.remote
else
raise "Unknown origin: #{value}"
end
end
def status_scope(value)
case value.to_s
when 'active'
Account.without_suspended
when 'pending'
accounts_with_users.merge(User.pending)
when 'suspended'
Account.suspended
else
raise "Unknown status: #{value}"
end
end
def order_scope(value)
case value
case value.to_s
when 'active'
params['remote'] ? Account.joins(:account_stat).by_recent_status : Account.joins(:user).by_recent_sign_in
accounts_with_users.left_joins(:account_stat).order(Arel.sql('coalesce(users.current_sign_in_at, account_stats.last_status_at, to_timestamp(0)) desc, accounts.id desc'))
when 'recent'
Account.recent
when 'alphabetic'
Account.alphabetic
else
raise "Unknown order: #{value}"
end
end
def invited_by_scope(value)
Account.left_joins(user: :invite).merge(Invite.where(user_id: value.to_s))
end
def permissions_scope(value)
case value.to_s
when 'staff'
accounts_with_users.merge(User.staff)
else
raise "Unknown permissions: #{value}"
end
end
def accounts_with_users
Account.joins(:user)
Account.left_joins(:user)
end
def valid_ip?(value)

2
app/models/admin/action_log.rb

@ -17,7 +17,7 @@ class Admin::ActionLog < ApplicationRecord
serialize :recorded_changes
belongs_to :account
belongs_to :target, polymorphic: true
belongs_to :target, polymorphic: true, optional: true
default_scope -> { order('id desc') }

2
app/models/admin/action_log_filter.rb

@ -11,6 +11,8 @@ class Admin::ActionLogFilter
assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
confirm_user: { target_type: 'User', action: 'confirm' }.freeze,
approve_user: { target_type: 'User', action: 'approve' }.freeze,
reject_user: { target_type: 'User', action: 'reject' }.freeze,
create_account_warning: { target_type: 'AccountWarning', action: 'create' }.freeze,
create_announcement: { target_type: 'Announcement', action: 'create' }.freeze,
create_custom_emoji: { target_type: 'CustomEmoji', action: 'create' }.freeze,

51
app/models/form/account_batch.rb

@ -3,6 +3,7 @@
class Form::AccountBatch
include ActiveModel::Model
include Authorization
include AccountableConcern
include Payloadable
attr_accessor :account_ids, :action, :current_account
@ -25,19 +26,21 @@ class Form::AccountBatch
suppress_follow_recommendation!
when 'unsuppress_follow_recommendation'
unsuppress_follow_recommendation!
when 'suspend'
suspend!
end
end
private
def follow!
accounts.find_each do |target_account|
accounts.each do |target_account|
FollowService.new.call(current_account, target_account)
end
end
def unfollow!
accounts.find_each do |target_account|
accounts.each do |target_account|
UnfollowService.new.call(current_account, target_account)
end
end
@ -61,23 +64,31 @@ class Form::AccountBatch
end
def approve!
users = accounts.includes(:user).map(&:user)
users.each { |user| authorize(user, :approve?) }
.each(&:approve!)
accounts.includes(:user).find_each do |account|
approve_account(account)
end
end
def reject!
records = accounts.includes(:user)
accounts.includes(:user).find_each do |account|
reject_account(account)
end
end
records.each { |account| authorize(account.user, :reject?) }
.each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) }
def suspend!
accounts.find_each do |account|
if account.user_pending?
reject_account(account)
else
suspend_account(account)
end
end
end
def suppress_follow_recommendation!
authorize(:follow_recommendation, :suppress?)
accounts.each do |account|
accounts.find_each do |account|
FollowRecommendationSuppression.create(account: account)
end
end
@ -87,4 +98,24 @@ class Form::AccountBatch
FollowRecommendationSuppression.where(account_id: account_ids).destroy_all
end
def reject_account(account)
authorize(account.user, :reject?)
log_action(:reject, account.user, username: account.username)
account.suspend!(origin: :local)
AccountDeletionWorker.perform_async(account.id, reserve_username: false)
end
def suspend_account(account)
authorize(account, :suspend?)
log_action(:suspend, account)
account.suspend!(origin: :local)
Admin::SuspensionWorker.perform_async(account.id)
end
def approve_account(account)
authorize(account.user, :approve?)
log_action(:approve, account.user)
account.user.approve!
end
end

2
app/models/trends/tags.rb

@ -4,7 +4,7 @@ class Trends::Tags < Trends::Base
PREFIX = 'trending_tags'
self.default_options = {
threshold: 15,
threshold: 5,
review_threshold: 10,
max_score_cooldown: 2.days.freeze,
max_score_halflife: 4.hours.freeze,

59
app/views/admin/accounts/_account.html.haml

@ -1,24 +1,35 @@
%tr
%td
= admin_account_link_to(account)
%td
%div.account-badges= account_badge(account, all: true)
%td
- if account.user_current_sign_in_ip
%samp.ellipsized-ip{ title: account.user_current_sign_in_ip }= account.user_current_sign_in_ip
- else
\-
%td
- if account.user_current_sign_in_at
%time.time-ago{ datetime: account.user_current_sign_in_at.iso8601, title: l(account.user_current_sign_in_at) }= l account.user_current_sign_in_at
- elsif account.last_status_at.present?
%time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
- else
\-
%td
- if account.local? && account.user_pending?
= table_link_to 'check', t('admin.accounts.approve'), approve_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:approve, account.user)
= table_link_to 'times', t('admin.accounts.reject'), reject_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:reject, account.user)
- else
= table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}")
= table_link_to 'globe', t('admin.accounts.public'), ActivityPub::TagManager.instance.url_for(account)
.batch-table__row{ class: [!account.suspended? && account.user_pending? && 'batch-table__row--attention', account.suspended? && 'batch-table__row--muted'] }
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
.batch-table__row__content.batch-table__row__content--unpadded
%table.accounts-table
%tbody
%tr
%td
= account_link_to account, path: admin_account_path(account.id)
%td.accounts-table__count.optional
- if account.suspended? || account.user_pending?
\-
- else
= friendly_number_to_human account.statuses_count
%small= t('accounts.posts', count: account.statuses_count).downcase
%td.accounts-table__count.optional
- if account.suspended? || account.user_pending?
\-
- else
= friendly_number_to_human account.followers_count
%small= t('accounts.followers', count: account.followers_count).downcase
%td.accounts-table__count
= relevant_account_timestamp(account)
%small= t('accounts.last_active')
%td.accounts-table__extra
- if account.local?
- if account.user_email
= link_to account.user_email.split('@').last, admin_accounts_path(email: "%@#{account.user_email.split('@').last}"), title: account.user_email
- else
\-
%br/
%samp.ellipsized-ip= relevant_account_ip(account, params[:ip])
- if !account.suspended? && account.user_pending? && account.user&.invite_request&.text&.present?
.batch-table__row__content__quote
%p= account.user&.invite_request&.text

53
app/views/admin/accounts/index.html.haml

@ -5,30 +5,30 @@
.filter-subset
%strong= t('admin.accounts.location.title')
%ul
%li= filter_link_to t('admin.accounts.location.local'), remote: nil
%li= filter_link_to t('admin.accounts.location.remote'), remote: '1'
%li= filter_link_to t('generic.all'), origin: nil
%li= filter_link_to t('admin.accounts.location.local'), origin: 'local'
%li= filter_link_to t('admin.accounts.location.remote'), origin: 'remote'
.filter-subset
%strong= t('admin.accounts.moderation.title')
%ul
%li= link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), admin_pending_accounts_path
%li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil, pending: nil
%li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil, pending: nil
%li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil, pending: nil
%li= filter_link_to t('generic.all'), status: nil
%li= filter_link_to t('admin.accounts.moderation.active'), status: 'active'
%li= filter_link_to t('admin.accounts.moderation.suspended'), status: 'suspended'
%li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), status: 'pending'
.filter-subset
%strong= t('admin.accounts.role')
%ul
%li= filter_link_to t('admin.accounts.moderation.all'), staff: nil
%li= filter_link_to t('admin.accounts.roles.staff'), staff: '1'
%li= filter_link_to t('admin.accounts.moderation.all'), permissions: nil
%li= filter_link_to t('admin.accounts.roles.staff'), permissions: 'staff'
.filter-subset
%strong= t 'generic.order_by'
%ul
%li= filter_link_to t('relationships.most_recent'), order: nil
%li= filter_link_to t('admin.accounts.username'), order: 'alphabetic'
%li= filter_link_to t('relationships.last_active'), order: 'active'
= form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
.fields-group
- AccountFilter::KEYS.each do |key|
- (AccountFilter::KEYS - %i(origin status permissions)).each do |key|
- if params[key].present?
= hidden_field_tag key, params[key]
@ -41,16 +41,27 @@
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
.table-wrapper
%table.table
%thead
%tr
%th= t('admin.accounts.username')
%th= t('admin.accounts.role')
%th= t('admin.accounts.most_recent_ip')
%th= t('admin.accounts.most_recent_activity')
%th
%tbody
= render partial: 'account', collection: @accounts
= form_for(@form, url: batch_admin_accounts_path) do |f|
= hidden_field_tag :page, params[:page] || 1
- AccountFilter::KEYS.each do |key|
= hidden_field_tag key, params[key] if params[key].present?
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
- if @accounts.any? { |account| account.user_pending? }
= f.button safe_join([fa_icon('check'), t('admin.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('lock'), t('admin.accounts.perform_full_suspension')]), name: :suspend, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
.batch-table__body
- if @accounts.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'account', collection: @accounts, locals: { f: f }
= paginate @accounts

2
app/views/admin/dashboard/index.html.haml

@ -35,7 +35,7 @@
%span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
= fa_icon 'chevron-right fw'
= link_to admin_pending_accounts_path, class: 'dashboard__quick-access' do
= link_to admin_accounts_path(status: 'pending'), class: 'dashboard__quick-access' do
%span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
= fa_icon 'chevron-right fw'

2
app/views/admin/instances/show.html.haml

@ -15,7 +15,7 @@
.dashboard__counters
%div
= link_to admin_accounts_path(remote: '1', by_domain: @instance.domain) do
= link_to admin_accounts_path(origin: 'remote', by_domain: @instance.domain) do
.dashboard__counters__num= number_with_delimiter @instance.accounts_count
.dashboard__counters__label= t 'admin.accounts.title'
%div

6
app/views/admin/ip_blocks/_ip_block.html.haml

@ -1,9 +1,9 @@
.batch-table__row
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id
.batch-table__row__content
.batch-table__row__content__text
%samp= "#{ip_block.ip}/#{ip_block.ip.prefix}"
.batch-table__row__content.pending-account
.pending-account__header
%samp= link_to "#{ip_block.ip}/#{ip_block.ip.prefix}", admin_accounts_path(ip: "#{ip_block.ip}/#{ip_block.ip.prefix}")
- if ip_block.comment.present?
= ip_block.comment

16
app/views/admin/pending_accounts/_account.html.haml

@ -1,16 +0,0 @@
.batch-table__row
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
.batch-table__row__content.pending-account
.pending-account__header
= link_to admin_account_path(account.id) do
%strong= account.user_email
= "(@#{account.username})"
%br/
%samp= account.user_current_sign_in_ip
= t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)
- if account.user&.invite_request&.text&.present?
.pending-account__body
%p= account.user&.invite_request&.text

30
app/views/admin/pending_accounts/index.html.haml

@ -1,30 +0,0 @@
- content_for :page_title do
= t('admin.pending_accounts.title', count: User.pending.count)
= form_for(@form, url: batch_admin_pending_accounts_path) do |f|
= hidden_field_tag :page, params[:page] || 1
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
= f.button safe_join([fa_icon('check'), t('admin.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
.batch-table__body
- if @accounts.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'account', collection: @accounts, locals: { f: f }
= paginate @accounts
%hr.spacer/
%div.action-buttons
%div
= link_to t('admin.accounts.approve_all'), approve_all_admin_pending_accounts_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button'
%div
= link_to t('admin.accounts.reject_all'), reject_all_admin_pending_accounts_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive'

2
app/views/admin_mailer/new_pending_account.text.erb

@ -9,4 +9,4 @@
<%= quote_wrap(@account.user&.invite_request&.text) %>
<% end %>
<%= raw t('application_mailer.view')%> <%= admin_pending_accounts_url %>
<%= raw t('application_mailer.view')%> <%= admin_accounts_url(status: 'pending') %>

4
app/workers/scheduler/follow_recommendations_scheduler.rb

@ -16,12 +16,12 @@ class Scheduler::FollowRecommendationsScheduler
AccountSummary.refresh
FollowRecommendation.refresh
fallback_recommendations = FollowRecommendation.limit(SET_SIZE).index_by(&:account_id)
fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE).index_by(&:account_id)
I18n.available_locales.each do |locale|
recommendations = begin
if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
FollowRecommendation.localized(locale).limit(SET_SIZE).index_by(&:account_id)
FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).index_by(&:account_id)
else
{}
end

17
config/locales/en.yml

@ -99,7 +99,6 @@ en:
accounts:
add_email_domain_block: Block e-mail domain
approve: Approve
approve_all: Approve all
approved_msg: Successfully approved %{username}'s sign-up application
are_you_sure: Are you sure?
avatar: Avatar
@ -153,7 +152,6 @@ en:
active: Active
all: All
pending: Pending
silenced: Limited
suspended: Suspended
title: Moderation
moderation_notes: Moderation notes
@ -171,7 +169,6 @@ en:
redownload: Refresh profile
redownloaded_msg: Successfully refreshed %{username}'s profile from origin
reject: Reject
reject_all: Reject all
rejected_msg: Successfully rejected %{username}'s sign-up application
remove_avatar: Remove avatar
remove_header: Remove header
@ -210,7 +207,6 @@ en:
suspended: Suspended
suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had.
suspension_reversible_hint_html: The account has been suspended, and the data will be fully removed on %{date}. Until then, the account can be restored without any ill effects. If you wish to remove all of the account's data immediately, you can do so below.
time_in_queue: Waiting in queue %{time}
title: Accounts
unconfirmed_email: Unconfirmed email
undo_sensitized: Undo force-sensitive
@ -226,6 +222,7 @@ en:
whitelisted: Allowed for federation
action_logs:
action_types:
approve_user: Approve User
assigned_to_self_report: Assign Report
change_email_user: Change E-mail for User
confirm_user: Confirm User
@ -255,6 +252,7 @@ en:
enable_user: Enable User
memorialize_account: Memorialize Account
promote_user: Promote User
reject_user: Reject User
remove_avatar_user: Remove Avatar
reopen_report: Reopen Report
reset_password_user: Reset Password
@ -271,6 +269,7 @@ en:
update_domain_block: Update Domain Block
update_status: Update Post
actions:
approve_user_html: "%{name} approved sign-up from %{target}"
assigned_to_self_report_html: "%{name} assigned report %{target} to themselves"
change_email_user_html: "%{name} changed the e-mail address of user %{target}"
confirm_user_html: "%{name} confirmed e-mail address of user %{target}"
@ -300,6 +299,7 @@ en:
enable_user_html: "%{name} enabled login for user %{target}"
memorialize_account_html: "%{name} turned %{target}'s account into a memoriam page"
promote_user_html: "%{name} promoted user %{target}"
reject_user_html: "%{name} rejected sign-up from %{target}"
remove_avatar_user_html: "%{name} removed %{target}'s avatar"
reopen_report_html: "%{name} reopened report %{target}"
reset_password_user_html: "%{name} reset password of user %{target}"
@ -377,13 +377,13 @@ en:
new_users: new users
opened_reports: reports opened
pending_reports_html:
one: "<strong>1</strong> pending reports"
one: "<strong>1</strong> pending report"
other: "<strong>%{count}</strong> pending reports"
pending_tags_html:
one: "<strong>1</strong> pending hashtags"
one: "<strong>1</strong> pending hashtag"
other: "<strong>%{count}</strong> pending hashtags"
pending_users_html:
one: "<strong>1</strong> pending users"
one: "<strong>1</strong> pending user"
other: "<strong>%{count}</strong> pending users"
resolved_reports: reports resolved
software: Software
@ -519,8 +519,6 @@ en:
title: Create new IP rule
no_ip_block_selected: No IP rules were changed as none were selected
title: IP rules
pending_accounts:
title: Pending accounts (%{count})
relationships:
title: "%{acct}'s relationships"
relays:
@ -980,6 +978,7 @@ en:
none: None
order_by: Order by
save_changes: Save changes
today: today
validation_errors:
one: Something isn't quite right yet! Please review the error below
other: Something isn't quite right yet! Please review %{count} errors below

2
config/navigation.rb

@ -47,7 +47,7 @@ SimpleNavigation::Configuration.run do |navigation|
n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s|
s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url
s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports}
s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts}
s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts}
s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}
s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }

12
config/routes.rb

@ -253,6 +253,10 @@ Rails.application.routes.draw do
post :reject
end
collection do
post :batch
end
resource :change_email, only: [:show, :update]
resource :reset, only: [:create]
resource :action, only: [:new, :create], controller: 'account_actions'
@ -273,14 +277,6 @@ Rails.application.routes.draw do
end
end
resources :pending_accounts, only: [:index] do
collection do
post :approve_all
post :reject_all
post :batch
end
end
resources :users, only: [] do
resource :two_factor_authentication, only: [:destroy]
resource :sign_in_token_authentication, only: [:create, :destroy]

6
crowdin.yml

@ -2,19 +2,13 @@ commit_message: '[ci skip]'
files:
- source: /app/javascript/mastodon/locales/en.json
translation: /app/javascript/mastodon/locales/%two_letters_code%.json
update_option: update_as_unapproved
- source: /config/locales/en.yml
translation: /config/locales/%two_letters_code%.yml
update_option: update_as_unapproved
- source: /config/locales/simple_form.en.yml
translation: /config/locales/simple_form.%two_letters_code%.yml
update_option: update_as_unapproved
- source: /config/locales/activerecord.en.yml
translation: /config/locales/activerecord.%two_letters_code%.yml
update_option: update_as_unapproved
- source: /config/locales/devise.en.yml
translation: /config/locales/devise.%two_letters_code%.yml
update_option: update_as_unapproved
- source: /config/locales/doorkeeper.en.yml
translation: /config/locales/doorkeeper.%two_letters_code%.yml
update_option: update_as_unapproved

24
db/migrate/20211213040746_update_account_summaries_to_version_2.rb

@ -0,0 +1,24 @@
class UpdateAccountSummariesToVersion2 < ActiveRecord::Migration[6.1]
def up
reapplication_follow_recommendations_v2 do
drop_view :account_summaries, materialized: true
create_view :account_summaries, version: 2, materialized: { no_data: true }
safety_assured { add_index :account_summaries, :account_id, unique: true }
end
end
def down
reapplication_follow_recommendations_v2 do
drop_view :account_summaries, materialized: true
create_view :account_summaries, version: 1, materialized: { no_data: true }
safety_assured { add_index :account_summaries, :account_id, unique: true }
end
end
def reapplication_follow_recommendations_v2
drop_view :follow_recommendations, materialized: true
yield
create_view :follow_recommendations, version: 2, materialized: { no_data: true }
safety_assured { add_index :follow_recommendations, :account_id, unique: true }
end
end

4
db/schema.rb

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_11_26_000907) do
ActiveRecord::Schema.define(version: 2021_12_13_040746) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -1131,7 +1131,7 @@ ActiveRecord::Schema.define(version: 2021_11_26_000907) do
statuses.language,
statuses.sensitive
FROM statuses
WHERE ((statuses.account_id = accounts.id) AND (statuses.deleted_at IS NULL))
WHERE ((statuses.account_id = accounts.id) AND (statuses.deleted_at IS NULL) AND (statuses.reblog_of_id IS NULL))
ORDER BY statuses.id DESC
LIMIT 20) t0)
WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false))

23
db/views/account_summaries_v02.sql

@ -0,0 +1,23 @@
SELECT
accounts.id AS account_id,
mode() WITHIN GROUP (ORDER BY language ASC) AS language,
mode() WITHIN GROUP (ORDER BY sensitive ASC) AS sensitive
FROM accounts
CROSS JOIN LATERAL (
SELECT
statuses.account_id,
statuses.language,
statuses.sensitive
FROM statuses
WHERE statuses.account_id = accounts.id
AND statuses.deleted_at IS NULL
AND statuses.reblog_of_id IS NULL
ORDER BY statuses.id DESC
LIMIT 20
) t0
WHERE accounts.suspended_at IS NULL
AND accounts.silenced_at IS NULL
AND accounts.moved_to_account_id IS NULL
AND accounts.discoverable = 't'
AND accounts.locked = 'f'
GROUP BY accounts.id

186
lib/mastodon/statuses_cli.rb

@ -14,16 +14,21 @@ module Mastodon
end
option :days, type: :numeric, default: 90
option :clean_followed, type: :boolean
option :skip_media_remove, type: :boolean
option :vacuum, type: :boolean, default: false, desc: 'Reduce the file size and update the statistics. This option locks the table for a long time, so run it offline'
option :batch_size, type: :numeric, default: 1_000, aliases: [:b], desc: 'Number of records in each batch'
option :continue, type: :boolean, default: false, desc: 'If remove is not completed, execute from the previous continuation'
option :clean_followed, type: :boolean, default: false, desc: 'Include the status of remote accounts that are followed by local accounts as candidates for remove'
option :skip_status_remove, type: :boolean, default: false, desc: 'Skip status remove (run only cleanup tasks)'
option :skip_media_remove, type: :boolean, default: false, desc: 'Skip remove orphaned media attachments'
option :compress_database, type: :boolean, default: false, desc: 'Compress database and update the statistics. This option locks the table for a long time, so run it offline'
desc 'remove', 'Remove unreferenced statuses'
long_desc <<~LONG_DESC
Remove statuses that are not referenced by local user activity, such as
ones that came from relays, or belonging to users that were once followed
by someone locally but no longer are.
It also removes orphaned records and performs additional cleanup tasks
such as updating statistics and recovering disk space.
This is a computationally heavy procedure that creates extra database
indices before commencing, and removes them afterward.
LONG_DESC
@ -33,41 +38,56 @@ module Mastodon
exit(1)
end
remove_statuses
vacuum_and_analyze_statuses
remove_orphans_media_attachments
remove_orphans_conversations
vacuum_and_analyze_conversations
end
private
def remove_statuses
return if options[:skip_status_remove]
say('Creating temporary database indices...')
ActiveRecord::Base.connection.add_index(:accounts, :id, name: :index_accounts_local, where: 'domain is null', algorithm: :concurrently, if_not_exists: true)
ActiveRecord::Base.connection.add_index(:status_pins, :status_id, name: :index_status_pins_status_id, algorithm: :concurrently, if_not_exists: true)
ActiveRecord::Base.connection.add_index(:media_attachments, :remote_url, name: :index_media_attachments_remote_url, where: 'remote_url is not null', algorithm: :concurrently, if_not_exists: true)
max_id = Mastodon::Snowflake.id_at(options[:days].days.ago)
start_at = Time.now.to_f
say('Extract the deletion target... This might take a while...')
unless options[:continue] && ActiveRecord::Base.connection.table_exists?('statuses_to_be_deleted')
ActiveRecord::Base.connection.add_index(:accounts, :id, name: :index_accounts_local, where: 'domain is null', algorithm: :concurrently, if_not_exists: true)
ActiveRecord::Base.connection.add_index(:status_pins, :status_id, name: :index_status_pins_status_id, algorithm: :concurrently, if_not_exists: true)
ActiveRecord::Base.connection.create_table('statuses_to_be_deleted', temporary: true)
say('Extract the deletion target from statuses... This might take a while...')
# Skip accounts followed by local accounts
clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed]
ActiveRecord::Base.connection.create_table('statuses_to_be_deleted', force: true)
ActiveRecord::Base.connection.exec_insert(<<-SQL.squish, 'SQL', [[nil, max_id]])
INSERT INTO statuses_to_be_deleted (id)
SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1)
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.in_reply_to_id)
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses1.id = statuses.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local))
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local OR statuses1.id >= $1))
AND NOT EXISTS (SELECT 1 FROM status_pins WHERE statuses.id = status_id)
AND NOT EXISTS (SELECT 1 FROM mentions WHERE statuses.id = mentions.status_id AND mentions.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
AND NOT EXISTS (SELECT 1 FROM favourites WHERE statuses.id = favourites.status_id AND favourites.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
AND NOT EXISTS (SELECT 1 FROM bookmarks WHERE statuses.id = bookmarks.status_id AND bookmarks.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
#{clean_followed_sql}
SQL
# Skip accounts followed by local accounts
clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed]
say('Removing temporary database indices to restore write performance...')
ActiveRecord::Base.connection.exec_insert(<<-SQL.squish, 'SQL', [[nil, max_id]])
INSERT INTO statuses_to_be_deleted (id)
SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1)
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.in_reply_to_id)
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses1.id = statuses.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local))
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local OR statuses1.id >= $1))
AND NOT EXISTS (SELECT 1 FROM status_pins WHERE statuses.id = status_id)
AND NOT EXISTS (SELECT 1 FROM mentions WHERE statuses.id = mentions.status_id AND mentions.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
AND NOT EXISTS (SELECT 1 FROM favourites WHERE statuses.id = favourites.status_id AND favourites.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
AND NOT EXISTS (SELECT 1 FROM bookmarks WHERE statuses.id = bookmarks.status_id AND bookmarks.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL))
#{clean_followed_sql}
SQL
ActiveRecord::Base.connection.remove_index(:accounts, name: :index_accounts_local, if_exists: true)
ActiveRecord::Base.connection.remove_index(:status_pins, name: :index_status_pins_status_id, if_exists: true)
say('Removing temporary database indices to restore write performance...')
ActiveRecord::Base.connection.remove_index(:accounts, name: :index_accounts_local, if_exists: true)
ActiveRecord::Base.connection.remove_index(:status_pins, name: :index_status_pins_status_id, if_exists: true)
end
say('Beginning removal... This might take a while...')
say('Beginning statuses removal... This might take a while...')
klass = Class.new(ApplicationRecord) do |c|
c.table_name = 'statuses_to_be_deleted'
@ -89,28 +109,118 @@ module Mastodon
progress.stop
if options[:vacuum]
say('Run VACUUM and ANALYZE to statuses...')
ActiveRecord::Base.connection.drop_table('statuses_to_be_deleted')
ActiveRecord::Base.connection.execute('VACUUM FULL ANALYZE statuses')
else
say('Run ANALYZE to statuses...')
say("Done after #{Time.now.to_f - start_at}s, removed #{removed} out of #{processed} statuses.", :green)
ensure
say('Removing temporary database indices to restore write performance...')
ActiveRecord::Base.connection.execute('ANALYZE statuses')
ActiveRecord::Base.connection.remove_index(:accounts, name: :index_accounts_local, if_exists: true)
ActiveRecord::Base.connection.remove_index(:status_pins, name: :index_status_pins_status_id, if_exists: true)
ActiveRecord::Base.connection.remove_index(:media_attachments, name: :index_media_attachments_remote_url, if_exists: true)
end
def remove_orphans_media_attachments
return if options[:skip_media_remove]
start_at = Time.now.to_f
say('Beginning removal of now-orphaned media attachments to free up disk space...')
scope = MediaAttachment.reorder(nil).unattached.where('created_at < ?', options[:days].pred.days.ago)
processed = 0
removed = 0
progress = create_progress_bar(scope.count)
scope.find_each do |media_attachment|
media_attachment.destroy!
removed += 1
rescue => e
progress.log pastel.red("Error processing #{media_attachment.id}: #{e}")
ensure
progress.increment
processed += 1
end
unless options[:skip_media_remove]
say('Beginning removal of now-orphaned media attachments to free up disk space...')
Scheduler::MediaCleanupScheduler.new.perform
progress.stop
say("Done after #{Time.now.to_f - start_at}s, removed #{removed} out of #{processed} media_attachments.", :green)
end
def remove_orphans_conversations
start_at = Time.now.to_f
unless options[:continue] && ActiveRecord::Base.connection.table_exists?('conversations_to_be_deleted')
say('Creating temporary database indices...')
ActiveRecord::Base.connection.add_index(:statuses, :conversation_id, name: :index_statuses_conversation_id, algorithm: :concurrently, if_not_exists: true)
say('Extract the deletion target from coversations... This might take a while...')
ActiveRecord::Base.connection.create_table('conversations_to_be_deleted', force: true)
ActiveRecord::Base.connection.exec_insert(<<-SQL.squish, 'SQL')
INSERT INTO conversations_to_be_deleted (id)
SELECT id FROM conversations WHERE NOT EXISTS (SELECT 1 FROM statuses WHERE statuses.conversation_id = conversations.id)
SQL
say('Removing temporary database indices to restore write performance...')
ActiveRecord::Base.connection.remove_index(:statuses, name: :index_statuses_conversation_id, if_exists: true)
end
say("Done after #{Time.now.to_f - start_at}s, removed #{removed} out of #{processed} statuses.", :green)
say('Beginning orphans removal... This might take a while...')
klass = Class.new(ApplicationRecord) do |c|
c.table_name = 'conversations_to_be_deleted'
end
Object.const_set('ConversationsToBeDeleted', klass)
scope = ConversationsToBeDeleted