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 2021-12-16 16:19:28 +01:00
commit b2526316f5
41 changed files with 1046 additions and 773 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@ -2,13 +2,24 @@
module Admin module Admin
class AccountsController < BaseController 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_remote_account!, only: [:redownload]
before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
def index def index
authorize :account, :index? authorize :account, :index?
@accounts = filtered_accounts.page(params[:page]) @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 end
def show def show
@ -38,13 +49,13 @@ module Admin
def approve def approve
authorize @account.user, :approve? authorize @account.user, :approve?
@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 end
def reject def reject
authorize @account.user, :reject? authorize @account.user, :reject?
DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) 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 end
def destroy def destroy
@ -121,11 +132,25 @@ module Admin
end end
def filtered_accounts def filtered_accounts
AccountFilter.new(filter_params).results AccountFilter.new(filter_params.with_defaults(order: 'recent')).results
end end
def filter_params def filter_params
params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS) params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
end 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
end end

View File

@ -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

View File

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

View File

@ -57,7 +57,7 @@ module TwoFactorAuthenticationConcern
if valid_webauthn_credential?(user, webauthn_credential) if valid_webauthn_credential?(user, webauthn_credential)
on_authentication_success(user, :webauthn) 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 else
on_authentication_failure(user, :webauthn, :invalid_credential) on_authentication_failure(user, :webauthn, :invalid_credential)
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity

View File

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

View File

@ -1,10 +1,41 @@
# frozen_string_literal: true # frozen_string_literal: true
module Admin::DashboardHelper module Admin::DashboardHelper
def feature_hint(feature, enabled) def relevant_account_ip(account, ip_query)
indicator = safe_join([enabled ? t('simple_form.yes') : t('simple_form.no'), fa_icon('power-off fw')], ' ') default_ip = [account.user_current_sign_in_ip || account.user_sign_up_ip]
class_names = enabled ? 'pull-right positive-hint' : 'pull-right neutral-hint'
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
end end

View File

@ -37,6 +37,7 @@ export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; 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_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
@ -536,13 +537,25 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
startPosition = position; startPosition = position;
} }
dispatch({ // 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
type: COMPOSE_SUGGESTION_SELECT, // the suggestions are dismissed and the cursor moves forward.
position: startPosition, if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
token, dispatch({
completion, type: COMPOSE_SUGGESTION_SELECT,
path, position: startPosition,
}); token,
completion,
path,
});
} else {
dispatch({
type: COMPOSE_SUGGESTION_IGNORE,
position: startPosition,
token,
completion,
path,
});
}
}; };
}; };

View File

@ -21,6 +21,7 @@ import {
COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_IGNORE,
COMPOSE_SUGGESTION_TAGS_UPDATE, COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_SENSITIVITY_CHANGE, 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 sortHashtagsByUse = (state, tags) => {
const personalHistory = state.get('tagHistory'); 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); return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT: case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion, action.path); 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: case COMPOSE_SUGGESTION_TAGS_UPDATE:
return updateSuggestionTags(state, action.token); return updateSuggestionTags(state, action.token);
case COMPOSE_TAG_HISTORY_UPDATE: case COMPOSE_TAG_HISTORY_UPDATE:

View File

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

View File

@ -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, a,
strong { 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, a,
strong { strong {
color: $gold-star; color: $gold-star;
} }
} }
.batch-table__row--attention .accounts-table {
tbody td.accounts-table__extra,
&__count,
&__count small {
color: $gold-star;
}
}

View File

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

View File

@ -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 { &__comment {
width: 50%; width: 50%;
vertical-align: initial !important; vertical-align: initial !important;

View File

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

View File

@ -2,18 +2,15 @@
class AccountFilter class AccountFilter
KEYS = %i( KEYS = %i(
local origin
remote status
by_domain permissions
active
pending
silenced
suspended
username username
by_domain
display_name display_name
email email
ip ip
staff invited_by
order order
).freeze ).freeze
@ -21,11 +18,10 @@ class AccountFilter
def initialize(params) def initialize(params)
@params = params @params = params
set_defaults!
end end
def results 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| params.each do |key, value|
scope.merge!(scope_for(key, value.to_s.strip)) if value.present? scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
@ -36,30 +32,16 @@ class AccountFilter
private 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) def scope_for(key, value)
case key.to_s case key.to_s
when 'local' when 'origin'
Account.local.without_instance_actor origin_scope(value)
when 'remote' when 'permissions'
Account.remote permissions_scope(value)
when 'status'
status_scope(value)
when 'by_domain' when 'by_domain'
Account.where(domain: value) 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' when 'username'
Account.matches_username(value) Account.matches_username(value)
when 'display_name' when 'display_name'
@ -68,8 +50,8 @@ class AccountFilter
accounts_with_users.merge(User.matches_email(value)) accounts_with_users.merge(User.matches_email(value))
when 'ip' when 'ip'
valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none
when 'staff' when 'invited_by'
accounts_with_users.merge(User.staff) invited_by_scope(value)
when 'order' when 'order'
order_scope(value) order_scope(value)
else else
@ -77,21 +59,56 @@ class AccountFilter
end end
end end
def order_scope(value) def origin_scope(value)
case 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' when 'active'
params['remote'] ? Account.joins(:account_stat).by_recent_status : Account.joins(:user).by_recent_sign_in 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.to_s
when 'active'
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' when 'recent'
Account.recent Account.recent
when 'alphabetic'
Account.alphabetic
else else
raise "Unknown order: #{value}" raise "Unknown order: #{value}"
end end
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 def accounts_with_users
Account.joins(:user) Account.left_joins(:user)
end end
def valid_ip?(value) def valid_ip?(value)

View File

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

View File

@ -11,6 +11,8 @@ class Admin::ActionLogFilter
assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze, assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
change_email_user: { target_type: 'User', action: 'change_email' }.freeze, change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
confirm_user: { target_type: 'User', action: 'confirm' }.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_account_warning: { target_type: 'AccountWarning', action: 'create' }.freeze,
create_announcement: { target_type: 'Announcement', action: 'create' }.freeze, create_announcement: { target_type: 'Announcement', action: 'create' }.freeze,
create_custom_emoji: { target_type: 'CustomEmoji', action: 'create' }.freeze, create_custom_emoji: { target_type: 'CustomEmoji', action: 'create' }.freeze,

View File

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

View File

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

View File

@ -1,24 +1,35 @@
%tr .batch-table__row{ class: [!account.suspended? && account.user_pending? && 'batch-table__row--attention', account.suspended? && 'batch-table__row--muted'] }
%td %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= admin_account_link_to(account) = f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
%td .batch-table__row__content.batch-table__row__content--unpadded
%div.account-badges= account_badge(account, all: true) %table.accounts-table
%td %tbody
- if account.user_current_sign_in_ip %tr
%samp.ellipsized-ip{ title: account.user_current_sign_in_ip }= account.user_current_sign_in_ip %td
- else = account_link_to account, path: admin_account_path(account.id)
\- %td.accounts-table__count.optional
%td - if account.suspended? || account.user_pending?
- 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 - else
- elsif account.last_status_at.present? = friendly_number_to_human account.statuses_count
%time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at %small= t('accounts.posts', count: account.statuses_count).downcase
- else %td.accounts-table__count.optional
\- - if account.suspended? || account.user_pending?
%td \-
- if account.local? && account.user_pending? - else
= 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) = friendly_number_to_human account.followers_count
= 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) %small= t('accounts.followers', count: account.followers_count).downcase
- else %td.accounts-table__count
= table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}") = relevant_account_timestamp(account)
= table_link_to 'globe', t('admin.accounts.public'), ActivityPub::TagManager.instance.url_for(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

View File

@ -5,30 +5,30 @@
.filter-subset .filter-subset
%strong= t('admin.accounts.location.title') %strong= t('admin.accounts.location.title')
%ul %ul
%li= filter_link_to t('admin.accounts.location.local'), remote: nil %li= filter_link_to t('generic.all'), origin: nil
%li= filter_link_to t('admin.accounts.location.remote'), remote: '1' %li= filter_link_to t('admin.accounts.location.local'), origin: 'local'
%li= filter_link_to t('admin.accounts.location.remote'), origin: 'remote'
.filter-subset .filter-subset
%strong= t('admin.accounts.moderation.title') %strong= t('admin.accounts.moderation.title')
%ul %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('generic.all'), status: nil
%li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil, pending: nil %li= filter_link_to t('admin.accounts.moderation.active'), status: 'active'
%li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil, pending: nil %li= filter_link_to t('admin.accounts.moderation.suspended'), status: 'suspended'
%li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil, pending: nil %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), status: 'pending'
.filter-subset .filter-subset
%strong= t('admin.accounts.role') %strong= t('admin.accounts.role')
%ul %ul
%li= filter_link_to t('admin.accounts.moderation.all'), staff: nil %li= filter_link_to t('admin.accounts.moderation.all'), permissions: nil
%li= filter_link_to t('admin.accounts.roles.staff'), staff: '1' %li= filter_link_to t('admin.accounts.roles.staff'), permissions: 'staff'
.filter-subset .filter-subset
%strong= t 'generic.order_by' %strong= t 'generic.order_by'
%ul %ul
%li= filter_link_to t('relationships.most_recent'), order: nil %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' %li= filter_link_to t('relationships.last_active'), order: 'active'
= form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
.fields-group .fields-group
- AccountFilter::KEYS.each do |key| - (AccountFilter::KEYS - %i(origin status permissions)).each do |key|
- if params[key].present? - if params[key].present?
= hidden_field_tag key, params[key] = hidden_field_tag key, params[key]
@ -41,16 +41,27 @@
%button.button= t('admin.accounts.search') %button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative' = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
.table-wrapper = form_for(@form, url: batch_admin_accounts_path) do |f|
%table.table = hidden_field_tag :page, params[:page] || 1
%thead
%tr - AccountFilter::KEYS.each do |key|
%th= t('admin.accounts.username') = hidden_field_tag key, params[key] if params[key].present?
%th= t('admin.accounts.role')
%th= t('admin.accounts.most_recent_ip') .batch-table
%th= t('admin.accounts.most_recent_activity') .batch-table__toolbar
%th %label.batch-table__toolbar__select.batch-checkbox-all
%tbody = check_box_tag :batch_checkbox_all, nil, false
= render partial: 'account', collection: @accounts .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 = paginate @accounts

View File

@ -35,7 +35,7 @@
%span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count) %span= t('admin.dashboard.pending_reports_html', count: @pending_reports_count)
= fa_icon 'chevron-right fw' = 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) %span= t('admin.dashboard.pending_users_html', count: @pending_users_count)
= fa_icon 'chevron-right fw' = fa_icon 'chevron-right fw'

View File

@ -15,7 +15,7 @@
.dashboard__counters .dashboard__counters
%div %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__num= number_with_delimiter @instance.accounts_count
.dashboard__counters__label= t 'admin.accounts.title' .dashboard__counters__label= t 'admin.accounts.title'
%div %div

View File

@ -1,9 +1,9 @@
.batch-table__row .batch-table__row
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox %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 = f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id
.batch-table__row__content .batch-table__row__content.pending-account
.batch-table__row__content__text .pending-account__header
%samp= "#{ip_block.ip}/#{ip_block.ip.prefix}" %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? - if ip_block.comment.present?
= ip_block.comment = ip_block.comment

View File

@ -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

View File

@ -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'

View File

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

View File

@ -16,12 +16,12 @@ class Scheduler::FollowRecommendationsScheduler
AccountSummary.refresh AccountSummary.refresh
FollowRecommendation.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| I18n.available_locales.each do |locale|
recommendations = begin recommendations = begin
if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist 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 else
{} {}
end end

View File

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

View File

@ -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| 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 :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 :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 :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 :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? } 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? }

View File

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

View File

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

View File

@ -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

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: 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -1131,7 +1131,7 @@ ActiveRecord::Schema.define(version: 2021_11_26_000907) do
statuses.language, statuses.language,
statuses.sensitive statuses.sensitive
FROM statuses 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 ORDER BY statuses.id DESC
LIMIT 20) t0) 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)) 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))

View File

@ -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

View File

@ -14,16 +14,21 @@ module Mastodon
end end
option :days, type: :numeric, default: 90 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 :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' desc 'remove', 'Remove unreferenced statuses'
long_desc <<~LONG_DESC long_desc <<~LONG_DESC
Remove statuses that are not referenced by local user activity, such as 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 ones that came from relays, or belonging to users that were once followed
by someone locally but no longer are. 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 This is a computationally heavy procedure that creates extra database
indices before commencing, and removes them afterward. indices before commencing, and removes them afterward.
LONG_DESC LONG_DESC
@ -33,41 +38,56 @@ module Mastodon
exit(1) exit(1)
end 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...') 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) 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) max_id = Mastodon::Snowflake.id_at(options[:days].days.ago)
start_at = Time.now.to_f 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 ActiveRecord::Base.connection.create_table('statuses_to_be_deleted', force: true)
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.exec_insert(<<-SQL.squish, 'SQL', [[nil, max_id]]) # Skip accounts followed by local accounts
INSERT INTO statuses_to_be_deleted (id) clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed]
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
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) say('Removing temporary database indices to restore write performance...')
ActiveRecord::Base.connection.remove_index(:status_pins, name: :index_status_pins_status_id, if_exists: true)
say('Beginning removal... This might take a while...') 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 statuses removal... This might take a while...')
klass = Class.new(ApplicationRecord) do |c| klass = Class.new(ApplicationRecord) do |c|
c.table_name = 'statuses_to_be_deleted' c.table_name = 'statuses_to_be_deleted'
@ -89,20 +109,7 @@ module Mastodon
progress.stop progress.stop
if options[:vacuum] ActiveRecord::Base.connection.drop_table('statuses_to_be_deleted')
say('Run VACUUM and ANALYZE to statuses...')
ActiveRecord::Base.connection.execute('VACUUM FULL ANALYZE statuses')
else
say('Run ANALYZE to statuses...')
ActiveRecord::Base.connection.execute('ANALYZE statuses')
end
unless options[:skip_media_remove]
say('Beginning removal of now-orphaned media attachments to free up disk space...')
Scheduler::MediaCleanupScheduler.new.perform
end
say("Done after #{Time.now.to_f - start_at}s, removed #{removed} out of #{processed} statuses.", :green) say("Done after #{Time.now.to_f - start_at}s, removed #{removed} out of #{processed} statuses.", :green)
ensure ensure
@ -112,5 +119,108 @@ module Mastodon
ActiveRecord::Base.connection.remove_index(:status_pins, name: :index_status_pins_status_id, 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) ActiveRecord::Base.connection.remove_index(:media_attachments, name: :index_media_attachments_remote_url, if_exists: true)
end 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
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('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
processed = 0
removed = 0
progress = create_progress_bar(scope.count.fdiv(options[:batch_size]).ceil)
scope.in_batches(of: options[:batch_size]) do |relation|
ids = relation.pluck(:id)
processed += ids.count
removed += Conversation.unscoped.where(id: ids).delete_all
progress.increment
end
progress.stop
ActiveRecord::Base.connection.drop_table('conversations_to_be_deleted')
say("Done after #{Time.now.to_f - start_at}s, removed #{removed} out of #{processed} conversations.", :green)
ensure
say('Removing temporary database indices to restore write performance...')
ActiveRecord::Base.connection.remove_index(:statuses, name: :index_statuses_conversation_id, if_exists: true)
end
def vacuum_and_analyze_statuses
if options[:compress_database]
say('Run VACUUM FULL ANALYZE to statuses...')
ActiveRecord::Base.connection.execute('VACUUM FULL ANALYZE statuses')
say('Run REINDEX to statuses...')
ActiveRecord::Base.connection.execute('REINDEX TABLE statuses')
else
say('Run ANALYZE to statuses...')
ActiveRecord::Base.connection.execute('ANALYZE statuses')
end
end
def vacuum_and_analyze_conversations
if options[:compress_database]
say('Run VACUUM FULL ANALYZE to conversations...')
ActiveRecord::Base.connection.execute('VACUUM FULL ANALYZE conversations')
say('Run REINDEX to conversations...')
ActiveRecord::Base.connection.execute('REINDEX TABLE conversations')
else
say('Run ANALYZE to conversations...')
ActiveRecord::Base.connection.execute('ANALYZE conversations')
end
end
end end
end end

View File

@ -149,13 +149,13 @@
"redis": "^3.1.2", "redis": "^3.1.2",
"redux": "^4.1.2", "redux": "^4.1.2",
"redux-immutable": "^4.0.0", "redux-immutable": "^4.0.0",
"redux-thunk": "^2.4.0", "redux-thunk": "^2.4.1",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"rellax": "^1.12.1", "rellax": "^1.12.1",
"requestidlecallback": "^0.3.0", "requestidlecallback": "^0.3.0",
"reselect": "^4.1.4", "reselect": "^4.1.5",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.43.4", "sass": "^1.43.5",
"sass-loader": "^10.2.0", "sass-loader": "^10.2.0",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"stringz": "^2.1.0", "stringz": "^2.1.0",
@ -172,19 +172,19 @@
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-merge": "^5.8.0", "webpack-merge": "^5.8.0",
"wicg-inert": "^3.1.1", "wicg-inert": "^3.1.1",
"ws": "^8.2.3" "ws": "^8.3.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.15.0", "@testing-library/jest-dom": "^5.16.0",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^27.3.1", "babel-jest": "^27.4.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-import": "~2.25.3", "eslint-plugin-import": "~2.25.3",
"eslint-plugin-jsx-a11y": "~6.5.1", "eslint-plugin-jsx-a11y": "~6.5.1",
"eslint-plugin-promise": "~5.1.1", "eslint-plugin-promise": "~5.1.1",
"eslint-plugin-react": "~7.27.1", "eslint-plugin-react": "~7.27.1",
"jest": "^27.3.1", "jest": "^27.4.3",
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",

View File

@ -21,12 +21,9 @@ RSpec.describe Admin::AccountsController, type: :controller do
expect(AccountFilter).to receive(:new) do |params| expect(AccountFilter).to receive(:new) do |params|
h = params.to_h h = params.to_h
expect(h[:local]).to eq '1' expect(h[:origin]).to eq 'local'
expect(h[:remote]).to eq '1'
expect(h[:by_domain]).to eq 'domain' expect(h[:by_domain]).to eq 'domain'
expect(h[:active]).to eq '1' expect(h[:status]).to eq 'active'
expect(h[:silenced]).to eq '1'
expect(h[:suspended]).to eq '1'
expect(h[:username]).to eq 'username' expect(h[:username]).to eq 'username'
expect(h[:display_name]).to eq 'display name' expect(h[:display_name]).to eq 'display name'
expect(h[:email]).to eq 'local-part@domain' expect(h[:email]).to eq 'local-part@domain'
@ -36,12 +33,9 @@ RSpec.describe Admin::AccountsController, type: :controller do
end end
get :index, params: { get :index, params: {
local: '1', origin: 'local',
remote: '1',
by_domain: 'domain', by_domain: 'domain',
active: '1', status: 'active',
silenced: '1',
suspended: '1',
username: 'username', username: 'username',
display_name: 'display name', display_name: 'display name',
email: 'local-part@domain', email: 'local-part@domain',

View File

@ -2,10 +2,10 @@ require 'rails_helper'
describe AccountFilter do describe AccountFilter do
describe 'with empty params' do describe 'with empty params' do
it 'defaults to recent local not-suspended account list' do it 'excludes instance actor by default' do
filter = described_class.new({}) filter = described_class.new({})
expect(filter.results).to eq Account.local.without_instance_actor.recent.without_suspended expect(filter.results).to eq Account.without_instance_actor
end end
end end
@ -16,42 +16,4 @@ describe AccountFilter do
expect { filter.results }.to raise_error(/wrong/) expect { filter.results }.to raise_error(/wrong/)
end end
end end
describe 'with valid params' do
it 'combines filters on Account' do
filter = described_class.new(
by_domain: 'test.com',
silenced: true,
username: 'test',
display_name: 'name',
email: 'user@example.com',
)
allow(Account).to receive(:where).and_return(Account.none)
allow(Account).to receive(:silenced).and_return(Account.none)
allow(Account).to receive(:matches_display_name).and_return(Account.none)
allow(Account).to receive(:matches_username).and_return(Account.none)
allow(User).to receive(:matches_email).and_return(User.none)
filter.results
expect(Account).to have_received(:where).with(domain: 'test.com')
expect(Account).to have_received(:silenced)
expect(Account).to have_received(:matches_username).with('test')
expect(Account).to have_received(:matches_display_name).with('name')
expect(User).to have_received(:matches_email).with('user@example.com')
end
describe 'that call account methods' do
%i(local remote silenced suspended).each do |option|
it "delegates the #{option} option" do
allow(Account).to receive(option).and_return(Account.none)
filter = described_class.new({ option => true })
filter.results
expect(Account).to have_received(option).at_least(1)
end
end
end
end
end end

911
yarn.lock

File diff suppressed because it is too large Load Diff