From bfdcf76a6481476e84fde34c3dedc86d267bcbf7 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Sun, 19 Nov 2017 12:13:57 +0900 Subject: Add “staff” badge to admin user pages (#5735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow staff badge to change from setting to hidden * i18n: Update Polish translation --- app/controllers/admin/settings_controller.rb | 2 ++ 1 file changed, 2 insertions(+) (limited to 'app/controllers') diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index e81290228..d9199b3d5 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -13,6 +13,7 @@ module Admin closed_registrations_message open_deletion timeline_preview + show_staff_badge bootstrap_timeline_accounts thumbnail ).freeze @@ -21,6 +22,7 @@ module Admin open_registrations open_deletion timeline_preview + show_staff_badge ).freeze UPLOAD_SETTINGS = %w( -- cgit From e84fecb7e97851ed56f4d954e2d68128bb87da37 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 24 Nov 2017 02:05:53 +0100 Subject: Add logging of admin actions (#5757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add logging of admin actions * Update brakeman whitelist * Log creates, updates and destroys with history of changes * i18n: Update Polish translation (#5782) Signed-off-by: Marcin Mikołajczak * Split admin navigation into moderation and administration * Redesign audit log page * 🇵🇱 (#5795) * Add color coding to audit log * Change dismiss->resolve, log all outcomes of report as resolve * Update terminology (e-mail blacklist) (#5796) * Update terminology (e-mail blacklist) imho looks better * Update en.yml * Fix code style issues * i18n-tasks normalize --- .../admin/account_moderation_notes_controller.rb | 2 +- app/controllers/admin/accounts_controller.rb | 3 + app/controllers/admin/action_logs_controller.rb | 9 ++ app/controllers/admin/base_controller.rb | 1 + app/controllers/admin/confirmations_controller.rb | 1 + app/controllers/admin/custom_emojis_controller.rb | 8 +- app/controllers/admin/domain_blocks_controller.rb | 2 + .../admin/email_domain_blocks_controller.rb | 4 +- .../admin/reported_statuses_controller.rb | 6 +- app/controllers/admin/reports_controller.rb | 9 +- app/controllers/admin/resets_controller.rb | 1 + app/controllers/admin/roles_controller.rb | 2 + app/controllers/admin/silences_controller.rb | 6 +- app/controllers/admin/statuses_controller.rb | 6 +- app/controllers/admin/suspensions_controller.rb | 2 + .../admin/two_factor_authentications_controller.rb | 1 + app/controllers/concerns/accountable_concern.rb | 9 ++ app/helpers/admin/action_logs_helper.rb | 103 +++++++++++++++++++++ app/javascript/styles/mastodon/admin.scss | 101 ++++++++++++++++++++ app/models/admin.rb | 7 ++ app/models/admin/action_log.rb | 40 ++++++++ app/models/form/status_batch.rb | 8 +- app/views/admin/action_logs/_action_log.html.haml | 15 +++ app/views/admin/action_logs/index.html.haml | 7 ++ config/brakeman.ignore | 55 +++++++---- config/i18n-tasks.yml | 1 + config/locales/en.yml | 44 +++++++-- config/locales/pl.yml | 30 ++++++ config/navigation.rb | 12 ++- config/routes.rb | 1 + .../20171119172437_create_admin_action_logs.rb | 12 +++ db/schema.rb | 15 ++- spec/fabricators/admin_action_log_fabricator.rb | 5 + spec/models/admin/action_log_spec.rb | 5 + 34 files changed, 490 insertions(+), 43 deletions(-) create mode 100644 app/controllers/admin/action_logs_controller.rb create mode 100644 app/controllers/concerns/accountable_concern.rb create mode 100644 app/helpers/admin/action_logs_helper.rb create mode 100644 app/models/admin.rb create mode 100644 app/models/admin/action_log.rb create mode 100644 app/views/admin/action_logs/_action_log.html.haml create mode 100644 app/views/admin/action_logs/index.html.haml create mode 100644 db/migrate/20171119172437_create_admin_action_logs.rb create mode 100644 spec/fabricators/admin_action_log_fabricator.rb create mode 100644 spec/models/admin/action_log_spec.rb (limited to 'app/controllers') diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb index 7f69a3363..7d5b9bf52 100644 --- a/app/controllers/admin/account_moderation_notes_controller.rb +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -21,7 +21,7 @@ module Admin def destroy authorize @account_moderation_note, :destroy? - @account_moderation_note.destroy + @account_moderation_note.destroy! redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg') end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 0829bc769..e9a512e70 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -32,18 +32,21 @@ module Admin def memorialize authorize @account, :memorialize? @account.memorialize! + log_action :memorialize, @account redirect_to admin_account_path(@account.id) end def enable authorize @account.user, :enable? @account.user.enable! + log_action :enable, @account.user redirect_to admin_account_path(@account.id) end def disable authorize @account.user, :disable? @account.user.disable! + log_action :disable, @account.user redirect_to admin_account_path(@account.id) end diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb new file mode 100644 index 000000000..e273dfeae --- /dev/null +++ b/app/controllers/admin/action_logs_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Admin + class ActionLogsController < BaseController + def index + @action_logs = Admin::ActionLog.page(params[:page]) + end + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index db4839a8f..7fb69d578 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -3,6 +3,7 @@ module Admin class BaseController < ApplicationController include Authorization + include AccountableConcern before_action :require_staff! diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index c10b0ebee..34dfb458e 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -7,6 +7,7 @@ module Admin def create authorize @user, :confirm? @user.confirm! + log_action :confirm, @user redirect_to admin_accounts_path end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 509f7a48f..3fa2a0b72 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -20,6 +20,7 @@ module Admin @custom_emoji = CustomEmoji.new(resource_params) if @custom_emoji.save + log_action :create, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg') else render :new @@ -30,6 +31,7 @@ module Admin authorize @custom_emoji, :update? if @custom_emoji.update(resource_params) + log_action :update, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg') else redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg') @@ -38,7 +40,8 @@ module Admin def destroy authorize @custom_emoji, :destroy? - @custom_emoji.destroy + @custom_emoji.destroy! + log_action :destroy, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') end @@ -49,6 +52,7 @@ module Admin emoji.image = @custom_emoji.image if emoji.save + log_action :create, emoji flash[:notice] = I18n.t('admin.custom_emojis.copied_msg') else flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg') @@ -60,12 +64,14 @@ module Admin def enable authorize @custom_emoji, :enable? @custom_emoji.update!(disabled: false) + log_action :enable, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg') end def disable authorize @custom_emoji, :disable? @custom_emoji.update!(disabled: true) + log_action :disable, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg') end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index e383dc831..64de2cbf0 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -21,6 +21,7 @@ module Admin if @domain_block.save DomainBlockWorker.perform_async(@domain_block.id) + log_action :create, @domain_block redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.created_msg') else render :new @@ -34,6 +35,7 @@ module Admin def destroy authorize @domain_block, :destroy? UnblockDomainService.new.call(@domain_block, retroactive_unblock?) + log_action :destroy, @domain_block redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg') end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index 01058bf46..9fe85064e 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -20,6 +20,7 @@ module Admin @email_domain_block = EmailDomainBlock.new(resource_params) if @email_domain_block.save + log_action :create, @email_domain_block redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') else render :new @@ -28,7 +29,8 @@ module Admin def destroy authorize @email_domain_block, :destroy? - @email_domain_block.destroy + @email_domain_block.destroy! + log_action :destroy, @email_domain_block redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg') end diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb index 4f66ce708..535bd11d4 100644 --- a/app/controllers/admin/reported_statuses_controller.rb +++ b/app/controllers/admin/reported_statuses_controller.rb @@ -8,7 +8,7 @@ module Admin def create authorize :status, :update? - @form = Form::StatusBatch.new(form_status_batch_params) + @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account)) flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save redirect_to admin_report_path(@report) @@ -16,13 +16,15 @@ module Admin def update authorize @status, :update? - @status.update(status_params) + @status.update!(status_params) + log_action :update, @status redirect_to admin_report_path(@report) end def destroy authorize @status, :destroy? RemovalWorker.perform_async(@status.id) + log_action :destroy, @status render json: @status end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 745757ee8..75db6b78a 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -25,12 +25,17 @@ module Admin def process_report case params[:outcome].to_s when 'resolve' - @report.update(action_taken_by_current_attributes) + @report.update!(action_taken_by_current_attributes) + log_action :resolve, @report when 'suspend' Admin::SuspensionWorker.perform_async(@report.target_account.id) + log_action :resolve, @report + log_action :suspend, @report.target_account resolve_all_target_account_reports when 'silence' - @report.target_account.update(silenced: true) + @report.target_account.update!(silenced: true) + log_action :resolve, @report + log_action :silence, @report.target_account resolve_all_target_account_reports else raise ActiveRecord::RecordNotFound diff --git a/app/controllers/admin/resets_controller.rb b/app/controllers/admin/resets_controller.rb index 00b590bf6..3e27d01ac 100644 --- a/app/controllers/admin/resets_controller.rb +++ b/app/controllers/admin/resets_controller.rb @@ -7,6 +7,7 @@ module Admin def create authorize @user, :reset_password? @user.send_reset_password_instructions + log_action :reset_password, @user redirect_to admin_accounts_path end diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index 8f8685827..af7ec0740 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -7,12 +7,14 @@ module Admin def promote authorize @user, :promote? @user.promote! + log_action :promote, @user redirect_to admin_account_path(@user.account_id) end def demote authorize @user, :demote? @user.demote! + log_action :demote, @user redirect_to admin_account_path(@user.account_id) end diff --git a/app/controllers/admin/silences_controller.rb b/app/controllers/admin/silences_controller.rb index 01fb292de..4c06a9c0c 100644 --- a/app/controllers/admin/silences_controller.rb +++ b/app/controllers/admin/silences_controller.rb @@ -6,13 +6,15 @@ module Admin def create authorize @account, :silence? - @account.update(silenced: true) + @account.update!(silenced: true) + log_action :silence, @account redirect_to admin_accounts_path end def destroy authorize @account, :unsilence? - @account.update(silenced: false) + @account.update!(silenced: false) + log_action :unsilence, @account redirect_to admin_accounts_path end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index b54a9b824..5d4325f57 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -26,7 +26,7 @@ module Admin def create authorize :status, :update? - @form = Form::StatusBatch.new(form_status_batch_params) + @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account)) flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save redirect_to admin_account_statuses_path(@account.id, current_params) @@ -34,13 +34,15 @@ module Admin def update authorize @status, :update? - @status.update(status_params) + @status.update!(status_params) + log_action :update, @status redirect_to admin_account_statuses_path(@account.id, current_params) end def destroy authorize @status, :destroy? RemovalWorker.perform_async(@status.id) + log_action :destroy, @status render json: @status end diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb index 778feea5e..5f222e125 100644 --- a/app/controllers/admin/suspensions_controller.rb +++ b/app/controllers/admin/suspensions_controller.rb @@ -7,12 +7,14 @@ module Admin def create authorize @account, :suspend? Admin::SuspensionWorker.perform_async(@account.id) + log_action :suspend, @account redirect_to admin_accounts_path end def destroy authorize @account, :unsuspend? @account.unsuspend! + log_action :unsuspend, @account redirect_to admin_accounts_path end diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb index 5a45d25cd..022107203 100644 --- a/app/controllers/admin/two_factor_authentications_controller.rb +++ b/app/controllers/admin/two_factor_authentications_controller.rb @@ -7,6 +7,7 @@ module Admin def destroy authorize @user, :disable_2fa? @user.disable_two_factor! + log_action :disable_2fa, @user redirect_to admin_accounts_path end diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb new file mode 100644 index 000000000..3cdcffc51 --- /dev/null +++ b/app/controllers/concerns/accountable_concern.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module AccountableConcern + extend ActiveSupport::Concern + + def log_action(action, target) + Admin::ActionLog.create(account: current_account, action: action, target: target) + end +end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb new file mode 100644 index 000000000..e85243e57 --- /dev/null +++ b/app/helpers/admin/action_logs_helper.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Admin::ActionLogsHelper + def log_target(log) + if log.target + linkable_log_target(log.target) + else + log_target_from_history(log.target_type, log.recorded_changes) + end + end + + def linkable_log_target(record) + case record.class.name + when 'Account' + link_to record.acct, admin_account_path(record.id) + when 'User' + link_to record.account.acct, admin_account_path(record.account_id) + when 'CustomEmoji' + record.shortcode + when 'Report' + link_to "##{record.id}", admin_report_path(record) + when 'DomainBlock', 'EmailDomainBlock' + link_to record.domain, "https://#{record.domain}" + when 'Status' + link_to record.account.acct, TagManager.instance.url_for(record) + end + end + + def log_target_from_history(type, attributes) + case type + when 'CustomEmoji' + attributes['shortcode'] + when 'DomainBlock', 'EmailDomainBlock' + link_to attributes['domain'], "https://#{attributes['domain']}" + when 'Status' + tmp_status = Status.new(attributes) + link_to tmp_status.account.acct, TagManager.instance.url_for(tmp_status) + end + end + + def relevant_log_changes(log) + if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action) + log.recorded_changes.slice('domain') + elsif log.target_type == 'CustomEmoji' && log.action == :update + log.recorded_changes.slice('domain', 'visible_in_picker') + elsif log.target_type == 'User' && [:promote, :demote].include?(log.action) + log.recorded_changes.slice('moderator', 'admin') + elsif log.target_type == 'DomainBlock' + log.recorded_changes.slice('severity', 'reject_media') + elsif log.target_type == 'Status' && log.action == :update + log.recorded_changes.slice('sensitive') + end + end + + def log_extra_attributes(hash) + safe_join(hash.to_a.map { |key, value| safe_join([content_tag(:span, key, class: 'diff-key'), '=', log_change(value)]) }, ' ') + end + + def log_change(val) + return content_tag(:span, val, class: 'diff-neutral') unless val.is_a?(Array) + safe_join([content_tag(:span, val.first, class: 'diff-old'), content_tag(:span, val.last, class: 'diff-new')], '→') + end + + def icon_for_log(log) + case log.target_type + when 'Account', 'User' + 'user' + when 'CustomEmoji' + 'file' + when 'Report' + 'flag' + when 'DomainBlock' + 'lock' + when 'EmailDomainBlock' + 'envelope' + when 'Status' + 'pencil' + end + end + + def class_for_log_icon(log) + case log.action + when :enable, :unsuspend, :unsilence, :confirm, :promote, :resolve + 'positive' + when :create + opposite_verbs?(log) ? 'negative' : 'positive' + when :update, :reset_password, :disable_2fa, :memorialize + 'neutral' + when :demote, :silence, :disable, :suspend + 'negative' + when :destroy + opposite_verbs?(log) ? 'positive' : 'negative' + else + '' + end + end + + private + + def opposite_verbs?(log) + %w(DomainBlock EmailDomainBlock).include?(log.target_type) + end +end diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 87bc710af..d4d62336f 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -347,3 +347,104 @@ } } } + +.spacer { + flex: 1 1 auto; +} + +.log-entry { + margin-bottom: 8px; + line-height: 20px; + + &__header { + display: flex; + justify-content: flex-start; + align-items: center; + padding: 10px; + background: $ui-base-color; + color: $ui-primary-color; + border-radius: 4px 4px 0 0; + font-size: 14px; + position: relative; + } + + &__avatar { + margin-right: 10px; + + .avatar { + display: block; + margin: 0; + border-radius: 50%; + width: 40px; + height: 40px; + } + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__timestamp { + color: lighten($ui-base-color, 34%); + } + + &__extras { + background: lighten($ui-base-color, 6%); + border-radius: 0 0 4px 4px; + padding: 10px; + color: $ui-primary-color; + font-family: 'mastodon-font-monospace', monospace; + font-size: 12px; + white-space: nowrap; + min-height: 20px; + } + + &__icon { + font-size: 28px; + margin-right: 10px; + color: lighten($ui-base-color, 34%); + } + + &__icon__overlay { + position: absolute; + top: 10px; + right: 10px; + width: 10px; + height: 10px; + border-radius: 50%; + + &.positive { + background: $success-green; + } + + &.negative { + background: $error-red; + } + + &.neutral { + background: $ui-highlight-color; + } + } + + a, + .username, + .target { + color: $ui-secondary-color; + text-decoration: none; + font-weight: 500; + } + + .diff-old { + color: $error-red; + } + + .diff-neutral { + color: $ui-secondary-color; + } + + .diff-new { + color: $success-green; + } +} diff --git a/app/models/admin.rb b/app/models/admin.rb new file mode 100644 index 000000000..d41d18449 --- /dev/null +++ b/app/models/admin.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Admin + def self.table_name_prefix + 'admin_' + end +end diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb new file mode 100644 index 000000000..4e950fbf7 --- /dev/null +++ b/app/models/admin/action_log.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: admin_action_logs +# +# id :integer not null, primary key +# account_id :integer +# action :string default(""), not null +# target_type :string +# target_id :integer +# recorded_changes :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Admin::ActionLog < ApplicationRecord + serialize :recorded_changes + + belongs_to :account, required: true + belongs_to :target, required: true, polymorphic: true + + default_scope -> { order('id desc') } + + def action + super.to_sym + end + + before_validation :set_changes + + private + + def set_changes + case action + when :destroy, :create + self.recorded_changes = target.attributes + when :update, :promote, :demote + self.recorded_changes = target.previous_changes + end + end +end diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb index a97b4aa28..4f08a3049 100644 --- a/app/models/form/status_batch.rb +++ b/app/models/form/status_batch.rb @@ -2,8 +2,9 @@ class Form::StatusBatch include ActiveModel::Model + include AccountableConcern - attr_accessor :status_ids, :action + attr_accessor :status_ids, :action, :current_account ACTION_TYPE = %w(nsfw_on nsfw_off delete).freeze @@ -20,11 +21,14 @@ class Form::StatusBatch def change_sensitive(sensitive) media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id) + ApplicationRecord.transaction do Status.where(id: media_attached_status_ids).find_each do |status| status.update!(sensitive: sensitive) + log_action :update, status end end + true rescue ActiveRecord::RecordInvalid false @@ -33,7 +37,9 @@ class Form::StatusBatch def delete_statuses Status.where(id: status_ids).find_each do |status| RemovalWorker.perform_async(status.id) + log_action :destroy, status end + true end end diff --git a/app/views/admin/action_logs/_action_log.html.haml b/app/views/admin/action_logs/_action_log.html.haml new file mode 100644 index 000000000..72816d731 --- /dev/null +++ b/app/views/admin/action_logs/_action_log.html.haml @@ -0,0 +1,15 @@ +%li.log-entry + .log-entry__header + .log-entry__avatar + = image_tag action_log.account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar' + .log-entry__content + .log-entry__title + = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe + .log-entry__timestamp + %time= l action_log.created_at + .spacer + .log-entry__icon + = fa_icon icon_for_log(action_log) + .log-entry__icon__overlay{ class: class_for_log_icon(action_log) } + .log-entry__extras + = log_extra_attributes relevant_log_changes(action_log) diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml new file mode 100644 index 000000000..bb6d7b5d7 --- /dev/null +++ b/app/views/admin/action_logs/index.html.haml @@ -0,0 +1,7 @@ +- content_for :page_title do + = t('admin.action_logs.title') + +%ul + = render @action_logs + += paginate @action_logs diff --git a/config/brakeman.ignore b/config/brakeman.ignore index f7cf89dff..db7e37bb9 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -7,10 +7,10 @@ "check_name": "LinkToHref", "message": "Potentially unsafe model attribute in link_to href", "file": "app/views/admin/accounts/show.html.haml", - "line": 122, + "line": 143, "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", "code": "link_to(Account.find(params[:id]).inbox_url, Account.find(params[:id]).inbox_url)", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/show" @@ -26,10 +26,10 @@ "check_name": "LinkToHref", "message": "Potentially unsafe model attribute in link_to href", "file": "app/views/admin/accounts/show.html.haml", - "line": 128, + "line": 149, "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", "code": "link_to(Account.find(params[:id]).shared_inbox_url, Account.find(params[:id]).shared_inbox_url)", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/show" @@ -45,10 +45,10 @@ "check_name": "LinkToHref", "message": "Potentially unsafe model attribute in link_to href", "file": "app/views/admin/accounts/show.html.haml", - "line": 35, + "line": 54, "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", "code": "link_to(Account.find(params[:id]).url, Account.find(params[:id]).url)", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/show" @@ -76,6 +76,25 @@ "confidence": "Weak", "note": "" }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "4b6a895e2805578d03ceedbe1d469cc75a0c759eba093722523edb4b8683c873", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/admin/action_logs/index.html.haml", + "line": 5, + "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => Admin::ActionLog.page(params[:page]), {})", + "render_path": [{"type":"controller","class":"Admin::ActionLogsController","method":"index","line":7,"file":"app/controllers/admin/action_logs_controller.rb"}], + "location": { + "type": "template", + "template": "admin/action_logs/index" + }, + "user_input": "params[:page]", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 4, @@ -83,10 +102,10 @@ "check_name": "LinkToHref", "message": "Potentially unsafe model attribute in link_to href", "file": "app/views/admin/accounts/show.html.haml", - "line": 131, + "line": 152, "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", "code": "link_to(Account.find(params[:id]).followers_url, Account.find(params[:id]).followers_url)", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/show" @@ -102,10 +121,10 @@ "check_name": "LinkToHref", "message": "Potentially unsafe model attribute in link_to href", "file": "app/views/admin/accounts/show.html.haml", - "line": 106, + "line": 127, "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", "code": "link_to(Account.find(params[:id]).salmon_url, Account.find(params[:id]).salmon_url)", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/show" @@ -124,7 +143,7 @@ "line": 31, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(action => filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]), {})", - "render_path": [{"type":"controller","class":"Admin::CustomEmojisController","method":"index","line":9,"file":"app/controllers/admin/custom_emojis_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::CustomEmojisController","method":"index","line":10,"file":"app/controllers/admin/custom_emojis_controller.rb"}], "location": { "type": "template", "template": "admin/custom_emojis/index" @@ -163,7 +182,7 @@ "line": 64, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(action => filtered_accounts.page(params[:page]), {})", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":10,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":12,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/index" @@ -179,10 +198,10 @@ "check_name": "LinkToHref", "message": "Potentially unsafe model attribute in link_to href", "file": "app/views/admin/accounts/show.html.haml", - "line": 95, + "line": 116, "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", "code": "link_to(Account.find(params[:id]).remote_url, Account.find(params[:id]).remote_url)", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/show" @@ -221,7 +240,7 @@ "line": 25, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(action => filtered_reports.page(params[:page]), {})", - "render_path": [{"type":"controller","class":"Admin::ReportsController","method":"index","line":9,"file":"app/controllers/admin/reports_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::ReportsController","method":"index","line":10,"file":"app/controllers/admin/reports_controller.rb"}], "location": { "type": "template", "template": "admin/reports/index" @@ -237,10 +256,10 @@ "check_name": "LinkToHref", "message": "Potentially unsafe model attribute in link_to href", "file": "app/views/admin/accounts/show.html.haml", - "line": 125, + "line": 146, "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", "code": "link_to(Account.find(params[:id]).outbox_url, Account.find(params[:id]).outbox_url)", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":15,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":18,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/show" @@ -269,6 +288,6 @@ "note": "" } ], - "updated": "2017-10-20 00:00:54 +0900", + "updated": "2017-11-19 20:34:18 +0100", "brakeman_version": "4.0.1" } diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 08a96f727..014055804 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -60,3 +60,4 @@ ignore_unused: - 'activerecord.errors.models.doorkeeper/*' - 'errors.429' - 'admin.accounts.roles.*' + - 'admin.action_logs.actions.*' diff --git a/config/locales/en.yml b/config/locales/en.yml index cadedab8b..13b90cf0f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -133,6 +133,32 @@ en: unsubscribe: Unsubscribe username: Username web: Web + action_logs: + actions: + confirm_user: "%{name} confirmed e-mail address of user %{target}" + create_custom_emoji: "%{name} uploaded new emoji %{target}" + create_domain_block: "%{name} blocked domain %{target}" + create_email_domain_block: "%{name} blacklisted e-mail domain %{target}" + demote_user: "%{name} demoted user %{target}" + destroy_domain_block: "%{name} unblocked domain %{target}" + destroy_email_domain_block: "%{name} whitelisted e-mail domain %{target}" + destroy_status: "%{name} removed status by %{target}" + disable_2fa_user: "%{name} disabled two factor requirement for user %{target}" + disable_custom_emoji: "%{name} disabled emoji %{target}" + disable_user: "%{name} disabled login for user %{target}" + enable_custom_emoji: "%{name} enabled emoji %{target}" + enable_user: "%{name} enabled login for user %{target}" + memorialize_account: "%{name} turned %{target}'s account into a memoriam page" + promote_user: "%{name} promoted user %{target}" + reset_password_user: "%{name} reset password of user %{target}" + resolve_report: "%{name} dismissed report %{target}" + silence_account: "%{name} silenced %{target}'s account" + suspend_account: "%{name} suspended %{target}'s account" + unsilence_account: "%{name} unsilenced %{target}'s account" + unsuspend_account: "%{name} unsuspended %{target}'s account" + update_custom_emoji: "%{name} updated emoji %{target}" + update_status: "%{name} updated status by %{target}" + title: Audit log custom_emojis: copied_msg: Successfully created local copy of the emoji copy: Copy @@ -187,24 +213,24 @@ en: suspend: Unsuspend all existing accounts from this domain title: Undo domain block for %{domain} undo: Undo - title: Domain Blocks + title: Domain blocks undo: Undo email_domain_blocks: add_new: Add new - created_msg: Email domain block successfully created + created_msg: Successfully added e-mail domain to blacklist delete: Delete - destroyed_msg: Email domain block successfully deleted + destroyed_msg: Successfully deleted e-mail domain from blacklist domain: Domain new: - create: Create block - title: New email domain block - title: Email Domain Block + create: Add domain + title: New e-mail blacklist entry + title: E-mail blacklist instances: account_count: Known accounts domain_name: Domain reset: Reset search: Search - title: Known Instances + title: Known instances reports: action_taken_by: Action taken by are_you_sure: Are you sure? @@ -265,7 +291,7 @@ en: timeline_preview: desc_html: Display public timeline on landing page title: Timeline preview - title: Site Settings + title: Site settings statuses: back_to_account: Back to account page batch: @@ -404,6 +430,8 @@ en: validations: images_and_video: Cannot attach a video to a status that already contains images too_many: Cannot attach more than 4 files + moderation: + title: Moderation notification_mailer: digest: body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:' diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 047d3df9b..a738fcea1 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -49,6 +49,7 @@ pl: reserved_username: Ta nazwa użytkownika jest zarezerwowana. roles: admin: Administrator + moderator: Moderator unfollow: Przestań śledzić admin: account_moderation_notes: @@ -132,6 +133,32 @@ pl: unsubscribe: Przestań subskrybować username: Nazwa użytkownika web: Sieć + action_logs: + actions: + confirm_user: "%{name} potwierdził adres e-mail użytkownika %{target}" + create_custom_emoji: "%{name} dodał nowe emoji %{target}" + create_domain_block: "%{name} zablokował domenę %{target}" + create_email_domain_block: "%{name} dodał domenę e-mail %{target} na czarną listę" + demote_user: "%{name} zdegradował użytkownika %{target}" + destroy_domain_block: "%{name} odblokował domenę %{target}" + destroy_email_domain_block: "%{name} usunął domenę e-mail %{target} z czarnej listy" + destroy_status: "%{name} usunął wpis użytkownika %{target}" + disable_2fa_user: "%{name} wyłączył uwierzytelnianie dwustopniowe użytkownikowi %{target}" + disable_custom_emoji: "%{name} wyłączył emoji %{target}" + disable_user: "%{name} zablokował możliwość logowania użytkownikowi %{target}" + enable_custom_emoji: "%{name} włączył emoji %{target}" + enable_user: "%{name} przywrócił możliwość logowania użytkownikowi %{target}" + memorialize_account: "%{name} nadał kontu %{target} status in memoriam" + promote_user: "%{name} podniósł uprawnienia użytkownikowi %{target}" + reset_password_user: "%{name} przywrócił hasło użytkownikowi %{target}" + resolve_report: "%{name} odrzucił zgłoszenie %{target}" + silence_account: "%{name} wyciszył konto %{target}" + suspend_account: "%{name} zawiesił konto %{target}" + unsilence_account: "%{name} cofnął wyciszenie konta %{target}" + unsuspend_account: "%{name} cofnął zawieszenie konta %{target}" + update_custom_emoji: "%{name} zaktualizował emoji %{target}" + update_status: "%{name} zaktualizował wpis użytkownika %{target}" + title: Dziennik działań administracyjnych custom_emojis: copied_msg: Pomyślnie utworzono lokalną kopię emoji copy: Kopiuj @@ -148,6 +175,7 @@ pl: listed: Widoczne new: title: Dodaj nowe niestandardowe emoji + overwrite: Zastąp shortcode: Shortcode shortcode_hint: Co najmniej 2 znaki, tylko znaki alfanumeryczne i podkreślniki title: Niestandardowe emoji @@ -403,6 +431,8 @@ pl: validations: images_and_video: Nie możesz załączyć pliku wideo do wpisu, który zawiera już zdjęcia too_many: Nie możesz załączyć więcej niż 4 plików + moderation: + title: Moderacja notification_mailer: digest: body: 'Oto krótkie podsumowanie co Cię ominęło na %{instance} od Twojej ostatniej wizyty (%{since}):' diff --git a/config/navigation.rb b/config/navigation.rb index 5b4800f07..d2432ba2a 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -20,17 +20,21 @@ SimpleNavigation::Configuration.run do |navigation| development.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url, highlights_on: %r{/settings/applications} end - primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_reports_url, if: proc { current_user.staff? } do |admin| + primary.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |admin| + admin.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts} admin.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances}, if: -> { current_user.admin? } - admin.item :subscriptions, safe_join([fa_icon('paper-plane-o fw'), t('admin.subscriptions.title')]), admin_subscriptions_url, if: -> { current_user.admin? } admin.item :domain_blocks, safe_join([fa_icon('lock fw'), t('admin.domain_blocks.title')]), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks}, if: -> { current_user.admin? } admin.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } - admin.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? } - admin.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? } + end + + primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), edit_admin_settings_url, if: proc { current_user.staff? } do |admin| admin.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? } admin.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis} + admin.item :subscriptions, safe_join([fa_icon('paper-plane-o fw'), t('admin.subscriptions.title')]), admin_subscriptions_url, if: -> { current_user.admin? } + admin.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? } + admin.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? } end primary.item :logout, safe_join([fa_icon('sign-out fw'), t('auth.logout')]), destroy_user_session_url, link_html: { 'data-method' => 'delete' } diff --git a/config/routes.rb b/config/routes.rb index cf0ba59d5..d675fa846 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -110,6 +110,7 @@ Rails.application.routes.draw do resources :subscriptions, only: [:index] resources :domain_blocks, only: [:index, :new, :create, :show, :destroy] resources :email_domain_blocks, only: [:index, :new, :create, :destroy] + resources :action_logs, only: [:index] resource :settings, only: [:edit, :update] resources :instances, only: [:index] do diff --git a/db/migrate/20171119172437_create_admin_action_logs.rb b/db/migrate/20171119172437_create_admin_action_logs.rb new file mode 100644 index 000000000..0c2b6c623 --- /dev/null +++ b/db/migrate/20171119172437_create_admin_action_logs.rb @@ -0,0 +1,12 @@ +class CreateAdminActionLogs < ActiveRecord::Migration[5.1] + def change + create_table :admin_action_logs do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.string :action, null: false, default: '' + t.references :target, polymorphic: true + t.text :recorded_changes, null: false, default: '' + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 16df5d7c9..77f6a2d10 100644 --- a/db/schema.rb +++ b/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: 20171118012443) do +ActiveRecord::Schema.define(version: 20171119172437) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -80,6 +80,18 @@ ActiveRecord::Schema.define(version: 20171118012443) do t.index ["username", "domain"], name: "index_accounts_on_username_and_domain", unique: true end + create_table "admin_action_logs", force: :cascade do |t| + t.bigint "account_id" + t.string "action", default: "", null: false + t.string "target_type" + t.bigint "target_id" + t.text "recorded_changes", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_admin_action_logs_on_account_id" + t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id" + end + create_table "blocks", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -488,6 +500,7 @@ ActiveRecord::Schema.define(version: 20171118012443) do add_foreign_key "account_moderation_notes", "accounts" add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify + add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade diff --git a/spec/fabricators/admin_action_log_fabricator.rb b/spec/fabricators/admin_action_log_fabricator.rb new file mode 100644 index 000000000..2f44e953d --- /dev/null +++ b/spec/fabricators/admin_action_log_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator('Admin::ActionLog') do + account nil + action "MyString" + target nil +end diff --git a/spec/models/admin/action_log_spec.rb b/spec/models/admin/action_log_spec.rb new file mode 100644 index 000000000..59206a36b --- /dev/null +++ b/spec/models/admin/action_log_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Admin::ActionLog, type: :model do + +end -- cgit From 740f8a95a905e949b6a74bc69dcaf638d2d46248 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 27 Nov 2017 16:07:59 +0100 Subject: Add consumable invites (#5814) * Add consumable invites * Add UI for generating invite codes * Add tests * Display max uses and expiration in invites table, delete invite * Remove unused column and redundant validator - Default follows not used, probably bad idea - InviteCodeValidator is redundant because RegistrationsController checks invite code validity * Add admin setting to disable invites * Add admin UI for invites, configurable role for invite creation - Admin UI that lists everyone's invites, always available - Admin setting min_invite_role to control who can invite people - Non-admin invite UI only visible if users are allowed to * Do not remove invites from database, expire them instantly --- app/controllers/admin/invites_controller.rb | 33 ++++++++++++++++ app/controllers/admin/settings_controller.rb | 1 + app/controllers/auth/registrations_controller.rb | 21 ++++++++-- app/controllers/invites_controller.rb | 43 +++++++++++++++++++++ app/javascript/styles/mastodon/admin.scss | 16 ++++++++ app/models/form/admin_settings.rb | 2 + app/models/invite.rb | 45 ++++++++++++++++++++++ app/models/user.rb | 22 +++++++++++ app/policies/invite_policy.rb | 25 ++++++++++++ app/views/admin/action_logs/_action_log.html.haml | 2 +- app/views/admin/invites/_invite.html.haml | 15 ++++++++ app/views/admin/invites/index.html.haml | 22 +++++++++++ app/views/admin/settings/edit.html.haml | 5 +++ app/views/auth/registrations/new.html.haml | 1 + app/views/invites/_form.html.haml | 9 +++++ app/views/invites/_invite.html.haml | 11 ++++++ app/views/invites/index.html.haml | 19 +++++++++ config/locales/en.yml | 24 ++++++++++++ config/locales/simple_form.en.yml | 2 + config/navigation.rb | 3 ++ config/routes.rb | 6 +++ config/settings.yml | 1 + db/migrate/20171125024930_create_invites.rb | 15 ++++++++ .../20171125031751_add_invite_id_to_users.rb | 5 +++ db/schema.rb | 17 +++++++- spec/fabricators/invite_fabricator.rb | 6 +++ spec/models/invite_spec.rb | 30 +++++++++++++++ spec/models/user_spec.rb | 43 +++++++++++++++++++++ 28 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 app/controllers/admin/invites_controller.rb create mode 100644 app/controllers/invites_controller.rb create mode 100644 app/models/invite.rb create mode 100644 app/policies/invite_policy.rb create mode 100644 app/views/admin/invites/_invite.html.haml create mode 100644 app/views/admin/invites/index.html.haml create mode 100644 app/views/invites/_form.html.haml create mode 100644 app/views/invites/_invite.html.haml create mode 100644 app/views/invites/index.html.haml create mode 100644 db/migrate/20171125024930_create_invites.rb create mode 100644 db/migrate/20171125031751_add_invite_id_to_users.rb create mode 100644 spec/fabricators/invite_fabricator.rb create mode 100644 spec/models/invite_spec.rb (limited to 'app/controllers') diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb new file mode 100644 index 000000000..f4207e3e2 --- /dev/null +++ b/app/controllers/admin/invites_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Admin + class InvitesController < BaseController + def index + authorize :invite, :index? + + @invites = Invite.includes(user: :account).page(params[:page]) + @invite = Invite.new + end + + def create + authorize :invite, :create? + + @invite = Invite.new(resource_params) + @invite.user = current_user + + if @invite.save + redirect_to admin_invites_path + else + @invites = Invite.page(params[:page]) + render :index + end + end + + def destroy + @invite = Invite.find(params[:id]) + authorize @invite, :destroy? + @invite.expire! + redirect_to admin_invites_path + end + end +end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index d9199b3d5..eed5fb6b5 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -16,6 +16,7 @@ module Admin show_staff_badge bootstrap_timeline_accounts thumbnail + min_invite_role ).freeze BOOLEAN_SETTINGS = %w( diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 223db96ff..da0b6512f 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -16,13 +16,16 @@ class Auth::RegistrationsController < Devise::RegistrationsController def build_resource(hash = nil) super(hash) - resource.locale = I18n.locale + + resource.locale = I18n.locale + resource.invite_code = params[:invite_code] if resource.invite_code.blank? + resource.build_account if resource.account.nil? end def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |u| - u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation) + u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation, :invite_code) end end @@ -35,7 +38,19 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def check_enabled_registrations - redirect_to root_path if single_user_mode? || !Setting.open_registrations + redirect_to root_path if single_user_mode? || !allowed_registrations? + end + + def allowed_registrations? + Setting.open_registrations || (invite_code.present? && Invite.find_by(code: invite_code)&.valid_for_use?) + end + + def invite_code + if params[:user] + params[:user][:invite_code] + else + params[:invite_code] + end end private diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb new file mode 100644 index 000000000..38d6c8d73 --- /dev/null +++ b/app/controllers/invites_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class InvitesController < ApplicationController + include Authorization + + layout 'admin' + + before_action :authenticate_user! + + def index + authorize :invite, :create? + + @invites = Invite.where(user: current_user) + @invite = Invite.new(expires_in: 1.day.to_i) + end + + def create + authorize :invite, :create? + + @invite = Invite.new(resource_params) + @invite.user = current_user + + if @invite.save + redirect_to invites_path + else + @invites = Invite.where(user: current_user) + render :index + end + end + + def destroy + @invite = Invite.where(user: current_user).find(params[:id]) + authorize @invite, :destroy? + @invite.expire! + redirect_to invites_path + end + + private + + def resource_params + params.require(:invite).permit(:max_uses, :expires_in) + end +end diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index d4d62336f..7f078470e 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -448,3 +448,19 @@ color: $success-green; } } + +.name-tag { + display: flex; + align-items: center; + + .avatar { + display: block; + margin: 0; + margin-right: 5px; + border-radius: 50%; + } + + .username { + font-weight: 500; + } +} diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 6e9a2cd4b..c1d2cf420 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -28,6 +28,8 @@ class Form::AdminSettings :show_staff_badge=, :bootstrap_timeline_accounts, :bootstrap_timeline_accounts=, + :min_invite_role, + :min_invite_role=, to: Setting ) end diff --git a/app/models/invite.rb b/app/models/invite.rb new file mode 100644 index 000000000..ceca04686 --- /dev/null +++ b/app/models/invite.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: invites +# +# id :integer not null, primary key +# user_id :integer +# code :string default(""), not null +# expires_at :datetime +# max_uses :integer +# uses :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Invite < ApplicationRecord + belongs_to :user, required: true + has_many :users, inverse_of: :invite + + before_validation :set_code + + attr_reader :expires_in + + def expires_in=(interval) + self.expires_at = interval.to_i.seconds.from_now unless interval.blank? + @expires_in = interval + end + + def valid_for_use? + (max_uses.nil? || uses < max_uses) && (expires_at.nil? || expires_at >= Time.now.utc) + end + + def expire! + touch(:expires_at) + end + + private + + def set_code + loop do + self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join + break if Invite.find_by(code: code).nil? + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b9b228c00..578622fdf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,6 +33,7 @@ # account_id :integer not null # disabled :boolean default(FALSE), not null # moderator :boolean default(FALSE), not null +# invite_id :integer # class User < ApplicationRecord @@ -47,6 +48,7 @@ class User < ApplicationRecord otp_number_of_backup_codes: 10 belongs_to :account, inverse_of: :user, required: true + belongs_to :invite, counter_cache: :uses accepts_nested_attributes_for :account has_many :applications, class_name: 'Doorkeeper::Application', as: :owner @@ -77,6 +79,8 @@ class User < ApplicationRecord :reduce_motion, :system_font_ui, :noindex, :theme, to: :settings, prefix: :setting, allow_nil: false + attr_accessor :invite_code + def confirmed? confirmed_at.present? end @@ -95,6 +99,19 @@ class User < ApplicationRecord end end + def role?(role) + case role + when 'user' + true + when 'moderator' + staff? + when 'admin' + admin? + else + false + end + end + def disable! update!(disabled: true, last_sign_in_at: current_sign_in_at, @@ -169,6 +186,11 @@ class User < ApplicationRecord session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload end + def invite_code=(code) + self.invite = Invite.find_by(code: code) unless code.blank? + @invite_code = code + end + protected def send_devise_notification(notification, *args) diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb new file mode 100644 index 000000000..e5c68af19 --- /dev/null +++ b/app/policies/invite_policy.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class InvitePolicy < ApplicationPolicy + def index? + staff? + end + + def create? + min_required_role? + end + + def destroy? + owner? || staff? + end + + private + + def owner? + record.user_id == current_user&.id + end + + def min_required_role? + current_user&.role?(Setting.min_invite_role) + end +end diff --git a/app/views/admin/action_logs/_action_log.html.haml b/app/views/admin/action_logs/_action_log.html.haml index 72816d731..ec90961cb 100644 --- a/app/views/admin/action_logs/_action_log.html.haml +++ b/app/views/admin/action_logs/_action_log.html.haml @@ -1,7 +1,7 @@ %li.log-entry .log-entry__header .log-entry__avatar - = image_tag action_log.account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar' + = image_tag action_log.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar' .log-entry__content .log-entry__title = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe diff --git a/app/views/admin/invites/_invite.html.haml b/app/views/admin/invites/_invite.html.haml new file mode 100644 index 000000000..81edfd912 --- /dev/null +++ b/app/views/admin/invites/_invite.html.haml @@ -0,0 +1,15 @@ +%tr + %td + .name-tag + = image_tag invite.user.account.avatar.url(:original), alt: '', width: 16, height: 16, class: 'avatar' + %span.username= invite.user.account.username + %td + = invite.uses + = " / #{invite.max_uses}" unless invite.max_uses.nil? + %td + - if invite.expires_at.nil? + ∞ + - else + = l invite.expires_at + %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code) + %td= table_link_to 'times', t('invites.delete'), invite_path(invite), method: :delete if policy(invite).destroy? diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml new file mode 100644 index 000000000..52a748fe0 --- /dev/null +++ b/app/views/admin/invites/index.html.haml @@ -0,0 +1,22 @@ +- content_for :page_title do + = t('admin.invites.title') + +- if policy(:invite).create? + %p= t('invites.prompt') + + = render 'invites/form' + + %hr/ + +%table.table + %thead + %tr + %th + %th= t('invites.table.uses') + %th= t('invites.table.expires_at') + %th + %th + %tbody + = render @invites + += paginate @invites diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index b07718315..c7c25f528 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -32,6 +32,11 @@ %hr/ + .fields-group + = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, as: :radio_buttons, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + + %hr/ + .fields-group = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index f71675df0..2d4c0f5ac 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -16,6 +16,7 @@ = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' } = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } + = f.input :invite_code, as: :hidden .actions = f.button :button, t('auth.register'), type: :submit diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml new file mode 100644 index 000000000..99647f597 --- /dev/null +++ b/app/views/invites/_form.html.haml @@ -0,0 +1,9 @@ += simple_form_for(@invite) do |f| + = render 'shared/error_messages', object: @invite + + .fields-group + = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') + + .actions + = f.button :button, t('invites.generate'), type: :submit diff --git a/app/views/invites/_invite.html.haml b/app/views/invites/_invite.html.haml new file mode 100644 index 000000000..d794d72e4 --- /dev/null +++ b/app/views/invites/_invite.html.haml @@ -0,0 +1,11 @@ +%tr + %td + = invite.uses + = " / #{invite.max_uses}" unless invite.max_uses.nil? + %td + - if invite.expires_at.nil? + ∞ + - else + = l invite.expires_at + %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code) + %td= table_link_to 'times', t('invites.delete'), invite_path(invite), method: :delete if policy(invite).destroy? diff --git a/app/views/invites/index.html.haml b/app/views/invites/index.html.haml new file mode 100644 index 000000000..f4c5047fa --- /dev/null +++ b/app/views/invites/index.html.haml @@ -0,0 +1,19 @@ +- content_for :page_title do + = t('invites.title') + +- if policy(:invite).create? + %p= t('invites.prompt') + + = render 'form' + + %hr/ + +%table.table + %thead + %tr + %th= t('invites.table.uses') + %th= t('invites.table.expires_at') + %th + %th + %tbody + = render @invites diff --git a/config/locales/en.yml b/config/locales/en.yml index 13b90cf0f..36b6981cb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -231,6 +231,8 @@ en: reset: Reset search: Search title: Known instances + invites: + title: Invites reports: action_taken_by: Action taken by are_you_sure: Are you sure? @@ -269,6 +271,9 @@ en: deletion: desc_html: Allow anyone to delete their account title: Open account deletion + min_invite_role: + disabled: No one + title: Allow invitations by open: desc_html: Allow anyone to create an account title: Open registration @@ -424,6 +429,25 @@ en: muting: Muting list upload: Upload in_memoriam_html: In Memoriam. + invites: + delete: Delete + expires_in: + '1800': 30 minutes + '21600': 6 hours + '3600': 1 hour + '43200': 12 hours + '86400': 1 day + expires_in_prompt: Never + generate: Generate + max_uses: + one: 1 use + other: "%{count} uses" + max_uses_prompt: No limit + prompt: Generate and share links with others to grant access to this instance + table: + expires_at: Expires + uses: Uses + title: Invite people landing_strip_html: "%{name} is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse." landing_strip_signup_html: If you don't, you can sign up here. media_attachments: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index faf41f316..ff1a40ccd 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -30,10 +30,12 @@ en: data: Data display_name: Display name email: E-mail address + expires_in: Expire after filtered_languages: Filtered languages header: Header locale: Language locked: Lock account + max_uses: Max number of uses new_password: New password note: Bio otp_attempt: Two-factor code diff --git a/config/navigation.rb b/config/navigation.rb index 26e6d386a..fdfd72b4c 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -16,6 +16,8 @@ SimpleNavigation::Configuration.run do |navigation| settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url end + primary.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' } + primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development| development.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url, highlights_on: %r{/settings/applications} end @@ -24,6 +26,7 @@ SimpleNavigation::Configuration.run do |navigation| admin.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts} + admin.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path admin.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances}, if: -> { current_user.admin? } admin.item :domain_blocks, safe_join([fa_icon('lock fw'), t('admin.domain_blocks.title')]), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks}, if: -> { current_user.admin? } admin.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } diff --git a/config/routes.rb b/config/routes.rb index d675fa846..59c3d4fdb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,10 @@ Rails.application.routes.draw do get 'manifest', to: 'manifests#show', defaults: { format: 'json' } get 'intent', to: 'intents#show' + devise_scope :user do + get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite + end + devise_for :users, path: 'auth', controllers: { sessions: 'auth/sessions', registrations: 'auth/registrations', @@ -99,6 +103,7 @@ Rails.application.routes.draw do resources :media, only: [:show] resources :tags, only: [:show] resources :emojis, only: [:show] + resources :invites, only: [:index, :create, :destroy] get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy @@ -112,6 +117,7 @@ Rails.application.routes.draw do resources :email_domain_blocks, only: [:index, :new, :create, :destroy] resources :action_logs, only: [:index] resource :settings, only: [:edit, :update] + resources :invites, only: [:index, :create, :destroy] resources :instances, only: [:index] do collection do diff --git a/config/settings.yml b/config/settings.yml index 5a0170fb4..f03a32e50 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -16,6 +16,7 @@ defaults: &defaults open_registrations: true closed_registrations_message: '' open_deletion: true + min_invite_role: 'admin' timeline_preview: true show_staff_badge: true default_sensitive: false diff --git a/db/migrate/20171125024930_create_invites.rb b/db/migrate/20171125024930_create_invites.rb new file mode 100644 index 000000000..bcf03bd72 --- /dev/null +++ b/db/migrate/20171125024930_create_invites.rb @@ -0,0 +1,15 @@ +class CreateInvites < ActiveRecord::Migration[5.1] + def change + create_table :invites do |t| + t.belongs_to :user, foreign_key: { on_delete: :cascade } + t.string :code, null: false, default: '' + t.datetime :expires_at, null: true, default: nil + t.integer :max_uses, null: true, default: nil + t.integer :uses, null: false, default: 0 + + t.timestamps + end + + add_index :invites, :code, unique: true + end +end diff --git a/db/migrate/20171125031751_add_invite_id_to_users.rb b/db/migrate/20171125031751_add_invite_id_to_users.rb new file mode 100644 index 000000000..16829f866 --- /dev/null +++ b/db/migrate/20171125031751_add_invite_id_to_users.rb @@ -0,0 +1,5 @@ +class AddInviteIdToUsers < ActiveRecord::Migration[5.1] + def change + add_reference :users, :invite, null: true, default: nil, foreign_key: { on_delete: :nullify }, index: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 110a19845..7422bd127 100644 --- a/db/schema.rb +++ b/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: 20171122120436) do +ActiveRecord::Schema.define(version: 20171125031751) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -183,6 +183,18 @@ ActiveRecord::Schema.define(version: 20171122120436) do t.bigint "account_id", null: false end + create_table "invites", force: :cascade do |t| + t.bigint "user_id" + t.string "code", default: "", null: false + t.datetime "expires_at" + t.integer "max_uses" + t.integer "uses", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_invites_on_code", unique: true + t.index ["user_id"], name: "index_invites_on_user_id" + end + create_table "list_accounts", force: :cascade do |t| t.bigint "list_id", null: false t.bigint "account_id", null: false @@ -473,6 +485,7 @@ ActiveRecord::Schema.define(version: 20171122120436) do t.bigint "account_id", null: false t.boolean "disabled", default: false, null: false t.boolean "moderator", default: false, null: false + t.bigint "invite_id" t.index ["account_id"], name: "index_users_on_account_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true @@ -513,6 +526,7 @@ ActiveRecord::Schema.define(version: 20171122120436) do add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade + add_foreign_key "invites", "users", on_delete: :cascade add_foreign_key "list_accounts", "accounts", on_delete: :cascade add_foreign_key "list_accounts", "follows", on_delete: :cascade add_foreign_key "list_accounts", "lists", on_delete: :cascade @@ -546,5 +560,6 @@ ActiveRecord::Schema.define(version: 20171122120436) do add_foreign_key "stream_entries", "accounts", name: "fk_5659b17554", on_delete: :cascade add_foreign_key "subscriptions", "accounts", name: "fk_9847d1cbb5", on_delete: :cascade add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade + add_foreign_key "users", "invites", on_delete: :nullify add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade end diff --git a/spec/fabricators/invite_fabricator.rb b/spec/fabricators/invite_fabricator.rb new file mode 100644 index 000000000..62b9b3904 --- /dev/null +++ b/spec/fabricators/invite_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:invite) do + user + expires_at nil + max_uses nil + uses 0 +end diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb new file mode 100644 index 000000000..0ba1dccb3 --- /dev/null +++ b/spec/models/invite_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe Invite, type: :model do + describe '#valid_for_use?' do + it 'returns true when there are no limitations' do + invite = Invite.new(max_uses: nil, expires_at: nil) + expect(invite.valid_for_use?).to be true + end + + it 'returns true when not expired' do + invite = Invite.new(max_uses: nil, expires_at: 1.hour.from_now) + expect(invite.valid_for_use?).to be true + end + + it 'returns false when expired' do + invite = Invite.new(max_uses: nil, expires_at: 1.hour.ago) + expect(invite.valid_for_use?).to be false + end + + it 'returns true when uses still available' do + invite = Invite.new(max_uses: 250, uses: 249, expires_at: nil) + expect(invite.valid_for_use?).to be true + end + + it 'returns false when maximum uses reached' do + invite = Invite.new(max_uses: 250, uses: 250, expires_at: nil) + expect(invite.valid_for_use?).to be false + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 77a12c26d..5ed7ed88b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -273,4 +273,47 @@ RSpec.describe User, type: :model do expect(user.token_for_app(app)).to be_nil end end + + describe '#role' do + it 'returns admin for admin' do + user = User.new(admin: true) + expect(user.role).to eq 'admin' + end + + it 'returns moderator for moderator' do + user = User.new(moderator: true) + expect(user.role).to eq 'moderator' + end + + it 'returns user otherwise' do + user = User.new + expect(user.role).to eq 'user' + end + end + + describe '#role?' do + it 'returns false when invalid role requested' do + user = User.new(admin: true) + expect(user.role?('disabled')).to be false + end + + it 'returns true when exact role match' do + user = User.new + mod = User.new(moderator: true) + admin = User.new(admin: true) + + expect(user.role?('user')).to be true + expect(mod.role?('moderator')).to be true + expect(admin.role?('admin')).to be true + end + + it 'returns true when role higher than needed' do + mod = User.new(moderator: true) + admin = User.new(admin: true) + + expect(mod.role?('user')).to be true + expect(admin.role?('user')).to be true + expect(admin.role?('moderator')).to be true + end + end end -- cgit From 706e534455e2137c324c0ebb30435e7630ab7c1f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 27 Nov 2017 22:47:06 +0100 Subject: Add UI for setting up account migration (#5832) --- app/controllers/settings/migrations_controller.rb | 32 +++++++++++++++++++++++ app/models/form/migration.rb | 27 +++++++++++++++++++ app/views/settings/migrations/show.html.haml | 17 ++++++++++++ app/views/settings/profiles/show.html.haml | 5 ++++ config/locales/en.yml | 7 +++++ config/navigation.rb | 2 +- config/routes.rb | 1 + 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/controllers/settings/migrations_controller.rb create mode 100644 app/models/form/migration.rb create mode 100644 app/views/settings/migrations/show.html.haml (limited to 'app/controllers') diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb new file mode 100644 index 000000000..711ba338f --- /dev/null +++ b/app/controllers/settings/migrations_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Settings::MigrationsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + + def show + @migration = Form::Migration.new(account: current_account.moved_to_account) + end + + def update + @migration = Form::Migration.new(resource_params) + + if @migration.valid? + if current_account.moved_to_account_id != @migration.account&.id + current_account.update!(moved_to_account: @migration.account) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + end + + redirect_to settings_migration_path + else + render :show + end + end + + private + + def resource_params + params.require(:migration).permit(:acct) + end +end diff --git a/app/models/form/migration.rb b/app/models/form/migration.rb new file mode 100644 index 000000000..53cee7ee1 --- /dev/null +++ b/app/models/form/migration.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Form::Migration + include ActiveModel::Validations + + attr_accessor :acct, :account + + validates :acct, presence: true + + def initialize(attrs = {}) + @account = attrs[:account] + @acct = attrs[:account].acct unless @account.nil? + @acct = attrs[:acct].gsub(/\A@/, '').strip unless attrs[:acct].nil? + end + + def valid? + return false unless super + set_account + errors.empty? + end + + private + + def set_account + self.account = ResolveRemoteAccountService.new.call(acct) if account.nil? && acct.present? + end +end diff --git a/app/views/settings/migrations/show.html.haml b/app/views/settings/migrations/show.html.haml new file mode 100644 index 000000000..b7c34761f --- /dev/null +++ b/app/views/settings/migrations/show.html.haml @@ -0,0 +1,17 @@ +- content_for :page_title do + = t('settings.migrate') + += simple_form_for @migration, as: :migration, url: settings_migration_path, html: { method: :put } do |f| + - if @migration.account + %p.hint= t('migrations.currently_redirecting') + + .fields-group + = render partial: 'authorize_follows/card', locals: { account: @migration.account } + + = render 'shared/error_messages', object: @migration + + .fields-group + = f.input :acct, placeholder: t('migrations.acct') + + .actions + = f.button :button, t('migrations.proceed'), type: :submit, class: 'negative' diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 7a06cd014..0fc9db2b9 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -21,3 +21,8 @@ .actions = f.button :button, t('generic.save_changes'), type: :submit + +%hr/ + +%h6= t('auth.migrate_account') +%p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path) diff --git a/config/locales/en.yml b/config/locales/en.yml index 36b6981cb..2b7e9f2e2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -347,6 +347,8 @@ en: invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one. login: Log in logout: Logout + migrate_account: Move to a different account + migrate_account_html: If you wish to redirect this account to a different one, you can configure it here. register: Sign up resend_confirmation: Resend confirmation instructions reset_password: Reset password @@ -454,6 +456,10 @@ en: validations: images_and_video: Cannot attach a video to a status that already contains images too_many: Cannot attach more than 4 files + migrations: + acct: username@domain of the new account + currently_redirecting: 'Your profile is set to redirect to:' + proceed: Save moderation: title: Moderation notification_mailer: @@ -568,6 +574,7 @@ en: export: Data export followers: Authorized followers import: Import + migrate: Account migration notifications: Notifications preferences: Preferences settings: Settings diff --git a/config/navigation.rb b/config/navigation.rb index fdfd72b4c..9c7a7d2ec 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -5,7 +5,7 @@ SimpleNavigation::Configuration.run do |navigation| primary.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_url primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings| - settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url + settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url, highlights_on: %r{/settings/profile|/settings/migration} settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete} diff --git a/config/routes.rb b/config/routes.rb index 59c3d4fdb..6313a355d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,7 @@ Rails.application.routes.draw do end resource :delete, only: [:show, :destroy] + resource :migration, only: [:show, :update] resources :sessions, only: [:destroy] end -- cgit From 1b57d4dd3a64cdafd74a12ff67502795b06a4eb2 Mon Sep 17 00:00:00 2001 From: nullkal Date: Tue, 28 Nov 2017 22:31:23 +0900 Subject: Fix account migration feature (#5837) * Make removable account migration * Fix error during update of account migration setting * Add notice when update account migration setting --- app/controllers/settings/migrations_controller.rb | 15 ++++++++------- app/models/form/migration.rb | 4 +--- config/locales/en.yml | 1 + 3 files changed, 10 insertions(+), 10 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb index 711ba338f..b18403a7f 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -12,13 +12,10 @@ class Settings::MigrationsController < ApplicationController def update @migration = Form::Migration.new(resource_params) - if @migration.valid? - if current_account.moved_to_account_id != @migration.account&.id - current_account.update!(moved_to_account: @migration.account) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) - end - - redirect_to settings_migration_path + if @migration.valid? && migration_account_changed? + current_account.update!(moved_to_account: @migration.account) + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg') else render :show end @@ -29,4 +26,8 @@ class Settings::MigrationsController < ApplicationController def resource_params params.require(:migration).permit(:acct) end + + def migration_account_changed? + current_account.moved_to_account_id != @migration.account&.id + end end diff --git a/app/models/form/migration.rb b/app/models/form/migration.rb index 53cee7ee1..b74987337 100644 --- a/app/models/form/migration.rb +++ b/app/models/form/migration.rb @@ -5,8 +5,6 @@ class Form::Migration attr_accessor :acct, :account - validates :acct, presence: true - def initialize(attrs = {}) @account = attrs[:account] @acct = attrs[:account].acct unless @account.nil? @@ -22,6 +20,6 @@ class Form::Migration private def set_account - self.account = ResolveRemoteAccountService.new.call(acct) if account.nil? && acct.present? + self.account = (ResolveRemoteAccountService.new.call(acct) if account.nil? && acct.present?) end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2b7e9f2e2..d6f18e859 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -460,6 +460,7 @@ en: acct: username@domain of the new account currently_redirecting: 'Your profile is set to redirect to:' proceed: Save + updated_msg: Your account migration setting successfully updated! moderation: title: Moderation notification_mailer: -- cgit From eeaec39888f66bf312ac9a4c58f315ffd8f874f2 Mon Sep 17 00:00:00 2001 From: aschmitz Date: Tue, 28 Nov 2017 08:00:35 -0600 Subject: Allow hiding of reblogs from followed users (#5762) * Allow hiding of reblogs from followed users This adds a new entry to the account menu to allow users to hide future reblogs from a user (and then if they've done that, to show future reblogs instead). This does not remove or add historical reblogs from/to the user's timeline; it only affects new statuses. The API for this operates by sending a "reblogs" key to the follow endpoint. If this is sent when starting a new follow, it will be respected from the beginning of the follow relationship (even if the follow request must be approved by the followee). If this is sent when a follow relationship already exists, it will simply update the existing follow relationship. As with the notification muting, this will now return an object ({reblogs: [true|false]}) or false for each follow relationship when requesting relationship information for an account. This should cause few issues due to an object being truthy in many languages, but some modifications may need to be made in pickier languages. Database changes: adds a show_reblogs column (default true, non-nullable) to the follows and follow_requests tables. Because these are non-nullable, we use the existing MigrationHelpers to perform this change without locking those tables, although the tables are likely to be small anyway. Tests included. See also . * Rubocop fixes * Code review changes * Test fixes This patchset closes #648 and resolves #3271. * Rubocop fix * Revert reblogs defaulting in argument, fix tests It turns out we needed this for the same reason we needed it in muting: if nil gets passed in somehow (most usually by an API client not passing any value), we need to detect and handle it. We could specify a default in the parameter and then also catch nil, but there's no great reason to duplicate the default value. --- app/controllers/api/v1/accounts_controller.rb | 4 +- app/javascript/mastodon/actions/accounts.js | 10 ++-- app/javascript/mastodon/components/account.js | 2 +- .../features/account/components/action_bar.js | 12 +++++ .../features/account_timeline/components/header.js | 6 +++ .../containers/header_container.js | 8 +++ .../mastodon/reducers/accounts_counters.js | 3 +- app/lib/feed_manager.rb | 15 +++--- app/models/concerns/account_interactions.rb | 24 +++++++-- app/models/follow.rb | 1 + app/models/follow_request.rb | 3 +- app/services/follow_service.rb | 29 ++++++++--- app/services/notify_service.rb | 2 +- .../20171028221157_add_reblogs_to_follows.rb | 19 +++++++ db/schema.rb | 2 + .../v1/accounts/relationships_controller_spec.rb | 4 +- .../controllers/api/v1/accounts_controller_spec.rb | 8 +-- spec/lib/feed_manager_spec.rb | 7 +++ spec/models/concerns/account_interactions_spec.rb | 39 ++++++++++++++- spec/models/follow_request_spec.rb | 16 +++++- spec/services/follow_service_spec.rb | 58 ++++++++++++++++++++-- spec/services/notify_service_spec.rb | 20 ++++++++ 22 files changed, 252 insertions(+), 40 deletions(-) create mode 100644 db/migrate/20171028221157_add_reblogs_to_follows.rb (limited to 'app/controllers') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4676f60de..b1a2ed573 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,9 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account.acct) + FollowService.new.call(current_user.account, @account.acct, reblogs: params[:reblogs]) - options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: params[:reblogs] } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index fbaebf786..f63325658 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -105,12 +105,13 @@ export function fetchAccountFail(id, error) { }; }; -export function followAccount(id) { +export function followAccount(id, reblogs = true) { return (dispatch, getState) => { + const alreadyFollowing = getState().getIn(['relationships', id, 'following']); dispatch(followAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { - dispatch(followAccountSuccess(response.data)); + api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { + dispatch(followAccountSuccess(response.data, alreadyFollowing)); }).catch(error => { dispatch(followAccountFail(error)); }); @@ -136,10 +137,11 @@ export function followAccountRequest(id) { }; }; -export function followAccountSuccess(relationship) { +export function followAccountSuccess(relationship, alreadyFollowing) { return { type: ACCOUNT_FOLLOW_SUCCESS, relationship, + alreadyFollowing, }; }; diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 724b10980..1f2d7690f 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent { ); } else { - buttons = ; + buttons = ; } } diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index e375131d4..389296c42 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -20,6 +20,8 @@ const messages = defineMessages({ media: { id: 'account.media', defaultMessage: 'Media' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, + showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, }); @injectIntl @@ -30,6 +32,7 @@ export default class ActionBar extends React.PureComponent { onFollow: PropTypes.func, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, @@ -60,6 +63,15 @@ export default class ActionBar extends React.PureComponent { if (account.get('id') === me) { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); } else { + const following = account.getIn(['relationship', 'following']); + if (following) { + if (following.get('reblogs')) { + menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } else { + menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } + } + if (account.getIn(['relationship', 'muting'])) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); } else { diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 5e251c0e5..0ddb6b6c1 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -14,6 +14,7 @@ export default class Header extends ImmutablePureComponent { onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, @@ -40,6 +41,10 @@ export default class Header extends ImmutablePureComponent { this.props.onReport(this.props.account); } + handleReblogToggle = () => { + this.props.onReblogToggle(this.props.account); + } + handleMute = () => { this.props.onMute(this.props.account); } @@ -80,6 +85,7 @@ export default class Header extends ImmutablePureComponent { account={account} onBlock={this.handleBlock} onMention={this.handleMention} + onReblogToggle={this.handleReblogToggle} onReport={this.handleReport} onMute={this.handleMute} onBlockDomain={this.handleBlockDomain} diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 8e50ec405..b41eb19d4 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -67,6 +67,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(mentionCompose(account, router)); }, + onReblogToggle (account) { + if (account.getIn(['relationship', 'following', 'reblogs'])) { + dispatch(followAccount(account.get('id'), false)); + } else { + dispatch(followAccount(account.get('id'), true)); + } + }, + onReport (account) { dispatch(initReport(account)); }, diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js index 1ed0fe3e3..2a78a9f34 100644 --- a/app/javascript/mastodon/reducers/accounts_counters.js +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -126,7 +126,8 @@ export default function accountsCounters(state = initialState, action) { case STATUS_FETCH_SUCCESS: return normalizeAccountFromStatus(state, action.status); case ACCOUNT_FOLLOW_SUCCESS: - return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); + return action.alreadyFollowing ? state : + state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); case ACCOUNT_UNFOLLOW_SUCCESS: return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); default: diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 79fae6e96..20b10e113 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -159,14 +159,15 @@ class FeedManager return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? - if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply - should_filter = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to - should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me - should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply + if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply + should_filter = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to + should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me + should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply return should_filter - elsif status.reblog? # Filter out a reblog - should_filter = Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me - should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists? # or the author's domain is blocked + elsif status.reblog? # Filter out a reblog + should_filter = Follow.where(account_id: receiver_id, target_account_id: status.account_id, show_reblogs: false).exists? # if the reblogger's reblogs are suppressed + should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me + should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists? # or the author's domain is blocked return should_filter end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 55ad812b2..fdf35a4e3 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -5,7 +5,11 @@ module AccountInteractions class_methods do def following_map(target_account_ids, account_id) - follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping| + mapping[follow.target_account_id] = { + reblogs: follow.show_reblogs?, + } + end end def followed_by_map(target_account_ids, account_id) @@ -25,7 +29,11 @@ module AccountInteractions end def requested_map(target_account_ids, account_id) - follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping| + mapping[follow_request.target_account_id] = { + reblogs: follow_request.show_reblogs?, + } + end end def domain_blocking_map(target_account_ids, account_id) @@ -66,8 +74,12 @@ module AccountInteractions has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy end - def follow!(other_account) - active_relationships.find_or_create_by!(target_account: other_account) + def follow!(other_account, reblogs: nil) + reblogs = true if reblogs.nil? + rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account) + rel.update!(show_reblogs: reblogs) + + rel end def block!(other_account) @@ -140,6 +152,10 @@ module AccountInteractions mute_relationships.where(target_account: other_account, hide_notifications: true).exists? end + def muting_reblogs?(other_account) + active_relationships.where(target_account: other_account, show_reblogs: false).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/follow.rb b/app/models/follow.rb index 795ecf55a..3fb665afc 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -8,6 +8,7 @@ # updated_at :datetime not null # account_id :integer not null # target_account_id :integer not null +# show_reblogs :boolean default(TRUE), not null # class Follow < ApplicationRecord diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index fac91b513..ebf6959ce 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -8,6 +8,7 @@ # updated_at :datetime not null # account_id :integer not null # target_account_id :integer not null +# show_reblogs :boolean default(TRUE), not null # class FollowRequest < ApplicationRecord @@ -21,7 +22,7 @@ class FollowRequest < ApplicationRecord validates :account_id, uniqueness: { scope: :target_account_id } def authorize! - account.follow!(target_account) + account.follow!(target_account, reblogs: show_reblogs) MergeWorker.perform_async(target_account.id, account.id) destroy! diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 791773f25..20579ca63 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -6,25 +6,38 @@ class FollowService < BaseService # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) - def call(source_account, uri) + # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true + def call(source_account, uri, reblogs: nil) + reblogs = true if reblogs.nil? target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) - return if source_account.following?(target_account) || source_account.requested?(target_account) + if source_account.following?(target_account) + # We're already following this account, but we'll call follow! again to + # make sure the reblogs status is set correctly. + source_account.follow!(target_account, reblogs: reblogs) + return + elsif source_account.requested?(target_account) + # This isn't managed by a method in AccountInteractions, so we modify it + # ourselves if necessary. + req = follow_requests.find_by(target_account: other_account) + req.update!(show_reblogs: reblogs) + return + end if target_account.locked? || target_account.activitypub? - request_follow(source_account, target_account) + request_follow(source_account, target_account, reblogs: reblogs) else - direct_follow(source_account, target_account) + direct_follow(source_account, target_account, reblogs: reblogs) end end private - def request_follow(source_account, target_account) - follow_request = FollowRequest.create!(account: source_account, target_account: target_account) + def request_follow(source_account, target_account, reblogs: true) + follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow_request) @@ -38,8 +51,8 @@ class FollowService < BaseService follow_request end - def direct_follow(source_account, target_account) - follow = source_account.follow!(target_account) + def direct_follow(source_account, target_account, reblogs: true) + follow = source_account.follow!(target_account, reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow) diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 8a77f2f38..d5960c3ad 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -29,7 +29,7 @@ class NotifyService < BaseService end def blocked_reblog? - false + @recipient.muting_reblogs?(@notification.from_account) end def blocked_follow_request? diff --git a/db/migrate/20171028221157_add_reblogs_to_follows.rb b/db/migrate/20171028221157_add_reblogs_to_follows.rb new file mode 100644 index 000000000..4b5d5b7ff --- /dev/null +++ b/db/migrate/20171028221157_add_reblogs_to_follows.rb @@ -0,0 +1,19 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddReblogsToFollows < ActiveRecord::Migration[5.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + add_column_with_default :follows, :show_reblogs, :boolean, default: true, allow_null: false + add_column_with_default :follow_requests, :show_reblogs, :boolean, default: true, allow_null: false + end + end + + def down + remove_column :follows, :show_reblogs + remove_column :follow_requests, :show_reblogs + end +end diff --git a/db/schema.rb b/db/schema.rb index 120b15950..f2ef60375 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -160,6 +160,7 @@ ActiveRecord::Schema.define(version: 20171125190735) do t.datetime "updated_at", null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "show_reblogs", default: true, null: false t.index ["account_id", "target_account_id"], name: "index_follow_requests_on_account_id_and_target_account_id", unique: true end @@ -168,6 +169,7 @@ ActiveRecord::Schema.define(version: 20171125190735) do t.datetime "updated_at", null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "show_reblogs", default: true, null: false t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true end diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index 431fc2194..f25b86ac1 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -32,7 +32,7 @@ describe Api::V1::Accounts::RelationshipsController do json = body_as_json expect(json).to be_a Enumerable - expect(json.first[:following]).to be true + expect(json.first[:following]).to be_truthy expect(json.first[:followed_by]).to be false end end @@ -51,7 +51,7 @@ describe Api::V1::Accounts::RelationshipsController do expect(json).to be_a Enumerable expect(json.first[:id]).to eq simon.id.to_s - expect(json.first[:following]).to be true + expect(json.first[:following]).to be_truthy expect(json.first[:followed_by]).to be false expect(json.first[:muting]).to be false expect(json.first[:requested]).to be false diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 053c53e5a..f3b879421 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -31,10 +31,10 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=true and requested=false' do + it 'returns JSON with following=truthy and requested=false' do json = body_as_json - expect(json[:following]).to be true + expect(json[:following]).to be_truthy expect(json[:requested]).to be false end @@ -50,11 +50,11 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=false and requested=true' do + it 'returns JSON with following=false and requested=truthy' do json = body_as_json expect(json[:following]).to be false - expect(json[:requested]).to be true + expect(json[:requested]).to be_truthy end it 'creates a follow request relation between user and target user' do diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 9e2305eca..f2103a4b1 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -56,6 +56,13 @@ RSpec.describe FeedManager do expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true end + it 'returns true for reblog from account with reblogs disabled' do + status = Fabricate(:status, text: 'Hello world', account: jeff) + reblog = Fabricate(:status, reblog: status, account: alice) + bob.follow!(alice, reblogs: false) + expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true + end + it 'returns false for reply by followee to another followee' do status = Fabricate(:status, text: 'Hello world', account: jeff) reply = Fabricate(:status, text: 'Nay', thread: status, account: alice) diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb index 2b8c95d70..d08bdc8b9 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account_interactions_spec.rb @@ -14,7 +14,7 @@ describe AccountInteractions do context 'account with Follow' do it 'returns { target_account_id => true }' do Fabricate(:follow, account: account, target_account: target_account) - is_expected.to eq(target_account_id => true) + is_expected.to eq(target_account_id => { reblogs: true }) end end @@ -583,4 +583,41 @@ describe AccountInteractions do end end end + + describe 'ignoring reblogs from an account' do + before do + @me = Fabricate(:account, username: 'Me') + @you = Fabricate(:account, username: 'You') + end + + context 'with the reblogs option unspecified' do + before do + @me.follow!(@you) + end + + it 'defaults to showing reblogs' do + expect(@me.muting_reblogs?(@you)).to be(false) + end + end + + context 'with the reblogs option set to false' do + before do + @me.follow!(@you, reblogs: false) + end + + it 'does mute reblogs' do + expect(@me.muting_reblogs?(@you)).to be(true) + end + end + + context 'with the reblogs option set to true' do + before do + @me.follow!(@you, reblogs: true) + end + + it 'does not mute reblogs' do + expect(@me.muting_reblogs?(@you)).to be(false) + end + end + end end diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb index 1436501e9..59893a14f 100644 --- a/spec/models/follow_request_spec.rb +++ b/spec/models/follow_request_spec.rb @@ -7,10 +7,24 @@ RSpec.describe FollowRequest, type: :model do let(:target_account) { Fabricate(:account) } it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do - expect(account).to receive(:follow!).with(target_account) + expect(account).to receive(:follow!).with(target_account, reblogs: true) expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id) expect(follow_request).to receive(:destroy!) follow_request.authorize! end + + it 'correctly passes show_reblogs when true' do + follow_request = Fabricate.create(:follow_request, show_reblogs: true) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.muting_reblogs?(target)).to be false + end + + it 'correctly passes show_reblogs when false' do + follow_request = Fabricate.create(:follow_request, show_reblogs: false) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.muting_reblogs?(target)).to be true + end end end diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index ceb39e5e6..e59a2f1a6 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -13,8 +13,20 @@ RSpec.describe FollowService do subject.call(sender, bob.acct) end - it 'creates a follow request' do - expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil + it 'creates a follow request with reblogs' do + expect(FollowRequest.find_by(account: sender, target_account: bob, show_reblogs: true)).to_not be_nil + end + end + + describe 'locked account, no reblogs' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob')).account } + + before do + subject.call(sender, bob.acct, reblogs: false) + end + + it 'creates a follow request without reblogs' do + expect(FollowRequest.find_by(account: sender, target_account: bob, show_reblogs: false)).to_not be_nil end end @@ -25,8 +37,22 @@ RSpec.describe FollowService do subject.call(sender, bob.acct) end - it 'creates a following relation' do + it 'creates a following relation with reblogs' do + expect(sender.following?(bob)).to be true + expect(sender.muting_reblogs?(bob)).to be false + end + end + + describe 'unlocked account, no reblogs' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + subject.call(sender, bob.acct, reblogs: false) + end + + it 'creates a following relation without reblogs' do expect(sender.following?(bob)).to be true + expect(sender.muting_reblogs?(bob)).to be true end end @@ -42,6 +68,32 @@ RSpec.describe FollowService do expect(sender.following?(bob)).to be true end end + + describe 'already followed account, turning reblogs off' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + sender.follow!(bob, reblogs: true) + subject.call(sender, bob.acct, reblogs: false) + end + + it 'disables reblogs' do + expect(sender.muting_reblogs?(bob)).to be true + end + end + + describe 'already followed account, turning reblogs on' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + sender.follow!(bob, reblogs: false) + subject.call(sender, bob.acct, reblogs: true) + end + + it 'disables reblogs' do + expect(sender.muting_reblogs?(bob)).to be false + end + end end context 'remote OStatus account' do diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index fad0dd369..eb6210a1e 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -81,6 +81,26 @@ RSpec.describe NotifyService do end end + describe 'reblogs' do + let(:status) { Fabricate(:status, account: Fabricate(:account)) } + let(:activity) { Fabricate(:status, account: sender, reblog: status) } + + it 'shows reblogs by default' do + recipient.follow!(sender) + is_expected.to change(Notification, :count) + end + + it 'shows reblogs when explicitly enabled' do + recipient.follow!(sender, reblogs: true) + is_expected.to change(Notification, :count) + end + + it 'hides reblogs when disabled' do + recipient.follow!(sender, reblogs: false) + is_expected.to_not change(Notification, :count) + end + end + context do let(:asshole) { Fabricate(:account, username: 'asshole') } let(:reply_to) { Fabricate(:status, account: asshole) } -- cgit From eee3b32b7714a302ae384c92ef13601167ec892a Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Fri, 1 Dec 2017 20:26:57 +0900 Subject: Fix invites form path (#5861) --- app/controllers/admin/invites_controller.rb | 6 ++++++ app/views/invites/_form.html.haml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) (limited to 'app/controllers') diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb index f4207e3e2..607588d90 100644 --- a/app/controllers/admin/invites_controller.rb +++ b/app/controllers/admin/invites_controller.rb @@ -29,5 +29,11 @@ module Admin @invite.expire! redirect_to admin_invites_path end + + private + + def resource_params + params.require(:invite).permit(:max_uses, :expires_in) + end end end diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml index 99647f597..a01cf5946 100644 --- a/app/views/invites/_form.html.haml +++ b/app/views/invites/_form.html.haml @@ -1,4 +1,4 @@ -= simple_form_for(@invite) do |f| += simple_form_for(@invite, url: controller.is_a?(Admin::InvitesController) ? admin_invites_path : invites_path) do |f| = render 'shared/error_messages', object: @invite .fields-group -- cgit From 74320971e2cc9f605dbcc23c52ac36e18b80716f Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Sat, 2 Dec 2017 00:40:02 +0900 Subject: Add invite filter (#5862) --- app/controllers/admin/invites_controller.rb | 10 ++++++++- app/helpers/admin/filter_helper.rb | 3 ++- app/models/invite.rb | 3 +++ app/models/invite_filter.rb | 32 +++++++++++++++++++++++++++++ app/views/admin/invites/index.html.haml | 8 ++++++++ config/locales/en.yml | 5 +++++ 6 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 app/models/invite_filter.rb (limited to 'app/controllers') diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb index 607588d90..faccaa7c8 100644 --- a/app/controllers/admin/invites_controller.rb +++ b/app/controllers/admin/invites_controller.rb @@ -5,7 +5,7 @@ module Admin def index authorize :invite, :index? - @invites = Invite.includes(user: :account).page(params[:page]) + @invites = filtered_invites.includes(user: :account).page(params[:page]) @invite = Invite.new end @@ -35,5 +35,13 @@ module Admin def resource_params params.require(:invite).permit(:max_uses, :expires_in) end + + def filtered_invites + InviteFilter.new(filter_params).results + end + + def filter_params + params.permit(:available, :expired) + end end end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index e0fae9d9a..73250cbf5 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -3,8 +3,9 @@ module Admin::FilterHelper ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip).freeze REPORT_FILTERS = %i(resolved account_id target_account_id).freeze + INVITE_FILTER = %i(available expired).freeze - FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER def filter_link_to(text, link_to_params, link_class_params = link_to_params) new_url = filtered_url_for(link_to_params) diff --git a/app/models/invite.rb b/app/models/invite.rb index 7626f4cfa..6907c1f1d 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -17,6 +17,9 @@ class Invite < ApplicationRecord belongs_to :user, required: true has_many :users, inverse_of: :invite + scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) } + scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } + before_validation :set_code attr_reader :expires_in diff --git a/app/models/invite_filter.rb b/app/models/invite_filter.rb new file mode 100644 index 000000000..7d89bad4a --- /dev/null +++ b/app/models/invite_filter.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class InviteFilter + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = Invite.order(created_at: :desc) + + params.each do |key, value| + scope.merge!(scope_for(key, value)) if value.present? + end + + scope + end + + private + + def scope_for(key, _value) + case key.to_s + when 'available' + Invite.available + when 'expired' + Invite.expired + else + raise "Unknown filter: #{key}" + end + end +end diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml index 52a748fe0..944a60471 100644 --- a/app/views/admin/invites/index.html.haml +++ b/app/views/admin/invites/index.html.haml @@ -1,6 +1,14 @@ - content_for :page_title do = t('admin.invites.title') +.filters + .filter-subset + %strong= t('admin.invites.filter.title') + %ul + %li= filter_link_to t('admin.invites.filter.all'), available: nil, expired: nil + %li= filter_link_to t('admin.invites.filter.available'), available: 1, expired: nil + %li= filter_link_to t('admin.invites.filter.expired'), available: nil, expired: 1 + - if policy(:invite).create? %p= t('invites.prompt') diff --git a/config/locales/en.yml b/config/locales/en.yml index 2719a4f8c..5b9d43b9f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -232,6 +232,11 @@ en: search: Search title: Known instances invites: + filter: + all: All + available: Available + expired: Expired + title: Filter title: Invites reports: action_taken_by: Action taken by -- cgit