From 7e2b6da57f7689757a50fa261c480445b1846703 Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 17 Jul 2019 21:09:15 +0200 Subject: Add setting to disable the anti-spam (#11296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add environment variable to disable the anti-spam * Move antispam setting to admin settings * Fix typo * antispam → spam_check --- app/models/form/admin_settings.rb | 2 ++ 1 file changed, 2 insertions(+) (limited to 'app/models/form') diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 86a86ec66..2c03c88a8 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -28,6 +28,7 @@ class Form::AdminSettings thumbnail hero mascot + spam_check_enabled ).freeze BOOLEAN_KEYS = %i( @@ -39,6 +40,7 @@ class Form::AdminSettings show_known_fediverse_at_about_page preview_sensitive_media profile_directory + spam_check_enabled ).freeze UPLOAD_KEYS = %i( -- cgit From 9072fe5ab6464cc9c7a871d388464c7afcf41cd0 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 6 Aug 2019 17:57:52 +0200 Subject: Add trends UI with admin and user settings (#11502) --- app/controllers/settings/preferences_controller.rb | 1 + app/javascript/mastodon/actions/trends.js | 32 ++++++++++++++++ .../features/getting_started/components/trends.js | 43 ++++++++++++++++++++++ .../getting_started/containers/trends_container.js | 13 +++++++ .../mastodon/features/getting_started/index.js | 5 ++- .../features/ui/components/navigation_panel.js | 6 ++- app/javascript/mastodon/initial_state.js | 1 + app/javascript/mastodon/reducers/index.js | 2 + app/javascript/mastodon/reducers/settings.js | 4 ++ app/javascript/mastodon/reducers/trends.js | 23 ++++++++++++ app/javascript/styles/mastodon/components.scss | 38 +++++++++++++++---- app/lib/user_settings_decorator.rb | 5 +++ app/models/form/admin_settings.rb | 2 + app/models/trending_tags.rb | 4 ++ app/models/user.rb | 3 +- app/serializers/initial_state_serializer.rb | 2 + app/views/admin/settings/edit.html.haml | 3 ++ .../settings/preferences/appearance/show.html.haml | 5 +++ config/locales/en.yml | 8 +++- config/locales/simple_form.en.yml | 1 + config/settings.yml | 1 + 21 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 app/javascript/mastodon/actions/trends.js create mode 100644 app/javascript/mastodon/features/getting_started/components/trends.js create mode 100644 app/javascript/mastodon/features/getting_started/containers/trends_container.js create mode 100644 app/javascript/mastodon/reducers/trends.js (limited to 'app/models/form') diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index d548072a8..edf29947b 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -56,6 +56,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_advanced_layout, :setting_use_blurhash, :setting_use_pending_items, + :setting_trends, notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js new file mode 100644 index 000000000..853e4f60a --- /dev/null +++ b/app/javascript/mastodon/actions/trends.js @@ -0,0 +1,32 @@ +import api from '../api'; + +export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; +export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; +export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; + +export const fetchTrends = () => (dispatch, getState) => { + dispatch(fetchTrendsRequest()); + + api(getState) + .get('/api/v1/trends') + .then(({ data }) => dispatch(fetchTrendsSuccess(data))) + .catch(err => dispatch(fetchTrendsFail(err))); +}; + +export const fetchTrendsRequest = () => ({ + type: TRENDS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendsSuccess = trends => ({ + type: TRENDS_FETCH_SUCCESS, + trends, + skipLoading: true, +}); + +export const fetchTrendsFail = error => ({ + type: TRENDS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js new file mode 100644 index 000000000..1dcacc8b3 --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -0,0 +1,43 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Hashtag from 'mastodon/components/hashtag'; + +export default class Trends extends ImmutablePureComponent { + + static defaultProps = { + loading: false, + }; + + static propTypes = { + trends: ImmutablePropTypes.list, + fetchTrends: PropTypes.func.isRequired, + }; + + componentDidMount () { + this.props.fetchTrends(); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000); + } + + componentWillUnmount () { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + } + + render () { + const { trends } = this.props; + + if (!trends || trends.isEmpty()) { + return null; + } + + return ( +
+ {trends.take(3).map(hashtag => )} +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js new file mode 100644 index 000000000..1df3fb4fe --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { fetchTrends } from '../../../actions/trends'; +import Trends from '../components/trends'; + +const mapStateToProps = state => ({ + trends: state.getIn(['trends', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchTrends: () => dispatch(fetchTrends()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Trends); diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 791f22d47..6a122a750 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -7,12 +7,13 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me, profile_directory } from '../../initial_state'; +import { me, profile_directory, showTrends } from '../../initial_state'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { List as ImmutableList } from 'immutable'; import NavigationBar from '../compose/components/navigation_bar'; import Icon from 'mastodon/components/icon'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import TrendsContainer from './containers/trends_container'; const messages = defineMessages({ home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, @@ -168,6 +169,8 @@ class GettingStarted extends ImmutablePureComponent { + + {multiColumn && showTrends && } ); } diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index ef3ad2e09..64a40a9da 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -2,10 +2,11 @@ import React from 'react'; import { NavLink, withRouter } from 'react-router-dom'; import { FormattedMessage } from 'react-intl'; import Icon from 'mastodon/components/icon'; -import { profile_directory } from 'mastodon/initial_state'; +import { profile_directory, showTrends } from 'mastodon/initial_state'; import NotificationsCounterIcon from './notifications_counter_icon'; import FollowRequestsNavLink from './follow_requests_nav_link'; import ListPanel from './list_panel'; +import TrendsContainer from 'mastodon/features/getting_started/containers/trends_container'; const NavigationPanel = () => (
@@ -25,6 +26,9 @@ const NavigationPanel = () => ( {!!profile_directory && } + + {showTrends &&
} + {showTrends && }
); diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index cb2ccc7c4..38e7b0595 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -22,5 +22,6 @@ export const isStaff = getMeta('is_staff'); export const forceSingleColumn = !getMeta('advanced_layout'); export const useBlurhash = getMeta('use_blurhash'); export const usePendingItems = getMeta('use_pending_items'); +export const showTrends = getMeta('trends'); export default initialState; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 981ad8e64..3b60878eb 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -31,6 +31,7 @@ import conversations from './conversations'; import suggestions from './suggestions'; import polls from './polls'; import identity_proofs from './identity_proofs'; +import trends from './trends'; const reducers = { dropdown_menu, @@ -65,6 +66,7 @@ const reducers = { conversations, suggestions, polls, + trends, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 033bfc999..793a99f8f 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -12,6 +12,10 @@ const initialState = ImmutableMap({ skinTone: 1, + trends: ImmutableMap({ + show: true, + }), + home: ImmutableMap({ shows: ImmutableMap({ reblog: true, diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js new file mode 100644 index 000000000..5cecc8fca --- /dev/null +++ b/app/javascript/mastodon/reducers/trends.js @@ -0,0 +1,23 @@ +import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, +}); + +export default function trendsReducer(state = initialState, action) { + switch(action.type) { + case TRENDS_FETCH_REQUEST: + return state.set('isLoading', true); + case TRENDS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', fromJS(action.trends)); + map.set('isLoading', false); + }); + case TRENDS_FETCH_FAIL: + return state.set('isLoading', false); + default: + return state; + } +}; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index f02458ded..8de72d72e 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2212,7 +2212,6 @@ a.account__display-name { } .getting-started__wrapper, - .getting-started__trends, .search { margin-bottom: 10px; } @@ -2319,13 +2318,24 @@ a.account__display-name { margin-bottom: 10px; height: calc(100% - 20px); overflow-y: auto; + display: flex; + flex-direction: column; + + & > a { + flex: 0 0 auto; + } hr { + flex: 0 0 auto; border: 0; background: transparent; border-top: 1px solid lighten($ui-base-color, 4%); margin: 10px 0; } + + .flex-spacer { + background: transparent; + } } .drawer__pager { @@ -2717,8 +2727,10 @@ a.account__display-name { } &__trends { - background: $ui-base-color; flex: 0 1 auto; + opacity: 1; + animation: fade 150ms linear; + margin-top: 10px; @media screen and (max-height: 810px) { .trends__item:nth-child(3) { @@ -2735,11 +2747,15 @@ a.account__display-name { @media screen and (max-height: 670px) { display: none; } - } - &__scrollable { - max-height: 100%; - overflow-y: auto; + .trends__item { + border-bottom: 0; + padding: 10px; + + &__current { + color: $darker-text-color; + } + } } } @@ -5968,7 +5984,8 @@ noscript { font-size: 24px; line-height: 36px; font-weight: 500; - text-align: center; + text-align: right; + padding-right: 15px; color: $secondary-text-color; } @@ -5976,7 +5993,12 @@ noscript { flex: 0 0 auto; width: 50px; - path { + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { stroke: lighten($highlight-text-color, 6%) !important; } } diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 9ae9986c2..3568a3e11 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -36,6 +36,7 @@ class UserSettingsDecorator user.settings['advanced_layout'] = advanced_layout_preference if change?('setting_advanced_layout') user.settings['use_blurhash'] = use_blurhash_preference if change?('setting_use_blurhash') user.settings['use_pending_items'] = use_pending_items_preference if change?('setting_use_pending_items') + user.settings['trends'] = trends_preference if change?('setting_trends') end def merged_notification_emails @@ -122,6 +123,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_use_pending_items' end + def trends_preference + boolean_cast_setting 'setting_trends' + end + def boolean_cast_setting(key) ActiveModel::Type::Boolean.new.cast(settings[key]) end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 2c03c88a8..051268375 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -29,6 +29,7 @@ class Form::AdminSettings hero mascot spam_check_enabled + trends ).freeze BOOLEAN_KEYS = %i( @@ -41,6 +42,7 @@ class Form::AdminSettings preview_sensitive_media profile_directory spam_check_enabled + trends ).freeze UPLOAD_KEYS = %i( diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb index e9b9b25e3..0a7e2feac 100644 --- a/app/models/trending_tags.rb +++ b/app/models/trending_tags.rb @@ -66,6 +66,10 @@ class TrendingTags end def request_review!(tag) + return unless Setting.trends + + tag.touch(:requested_review_at) + User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? } end end diff --git a/app/models/user.rb b/app/models/user.rb index b83e26af3..a4a20d975 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -107,7 +107,8 @@ class User < ApplicationRecord delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, - :advanced_layout, :use_blurhash, :use_pending_items, to: :settings, prefix: :setting, allow_nil: false + :advanced_layout, :use_blurhash, :use_pending_items, :trends, + to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code attr_writer :external diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 7e5d3eda9..c92c5e606 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -20,6 +20,7 @@ class InitialStateSerializer < ActiveModel::Serializer invites_enabled: Setting.min_invite_role == 'user', mascot: instance_presenter.mascot&.file&.url, profile_directory: Setting.profile_directory, + trends: Setting.trends, } if object.current_account @@ -35,6 +36,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:use_blurhash] = object.current_account.user.setting_use_blurhash store[:use_pending_items] = object.current_account.user.setting_use_pending_items store[:is_staff] = object.current_account.user.staff? + store[:trends] = Setting.trends && object.current_account.user.setting_trends end store diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 1e2ed3f77..28c0ece15 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -68,6 +68,9 @@ .fields-group = f.input :profile_directory, as: :boolean, wrapper: :with_label, label: t('admin.settings.profile_directory.title'), hint: t('admin.settings.profile_directory.desc_html') + .fields-group + = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') + .fields-group = f.input :spam_check_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.spam_check_enabled.title'), hint: t('admin.settings.spam_check_enabled.desc_html') diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index e279a61c4..d6ee1933f 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -25,6 +25,11 @@ = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label + %h4= t 'appearance.discovery' + + .fields-group + = f.input :setting_trends, as: :boolean, wrapper: :with_label + %h4= t 'appearance.confirmation_dialogs' .fields-group diff --git a/config/locales/en.yml b/config/locales/en.yml index 9b62aac3a..67c392662 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -460,8 +460,8 @@ en: title: Custom terms of service site_title: Server name spam_check_enabled: - desc_html: Mastodon can auto-silence and auto-report accounts based on measures such as detecting accounts who send repeated unsolicited messages. There may be false positives. - title: Anti-spam + desc_html: Mastodon can auto-silence and auto-report accounts that send repeated unsolicited messages. There may be false positives. + title: Anti-spam automation thumbnail: desc_html: Used for previews via OpenGraph and API. 1200x630px recommended title: Server thumbnail @@ -469,6 +469,9 @@ en: desc_html: Display public timeline on landing page title: Timeline preview title: Site settings + trends: + desc_html: Publicly display previously reviewed hashtags that are currently trending + title: Trending hashtags statuses: back_to_account: Back to account page batch: @@ -514,6 +517,7 @@ en: advanced_web_interface_hint: 'If you want to make use of your entire screen width, the advanced web interface allows you to configure many different columns to see as much information at the same time as you want: Home, notifications, federated timeline, any number of lists and hashtags.' animations_and_accessibility: Animations and accessibility confirmation_dialogs: Confirmation dialogs + discovery: Discovery sensitive_content: Sensitive content application_mailer: notification_preferences: Change e-mail preferences diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 6fdfc9d7b..e15d5904f 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -114,6 +114,7 @@ en: setting_show_application: Disclose application used to send toots setting_system_font_ui: Use system's default font setting_theme: Site theme + setting_trends: Show today's trends setting_unfollow_modal: Show confirmation dialog before unfollowing someone setting_use_blurhash: Show colorful gradients for hidden media setting_use_pending_items: Slow mode diff --git a/config/settings.yml b/config/settings.yml index 10180201f..4e5eefb59 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -34,6 +34,7 @@ defaults: &defaults advanced_layout: false use_blurhash: true use_pending_items: false + trends: true notification_emails: follow: false reblog: false -- cgit From 9b6a5ed109e1986149c1f15a41d4f442ae8ae39c Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 19 Aug 2019 11:35:48 +0200 Subject: Add public blocks to /about/blocks (#11298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add automatic blocklist display in /about/blocks Inspired by https://github.com/Gargron/mastodon.social-misc * Add admin option to set who can see instance blocks * Normalize locales files * Rename “Sandbox” to “Silence” for consistency * Disable /about/blocks when in whitelist mode * Optionally display rationale for domain blocks * Only display domain blocks that have user-facing limitations, and order them * Redesign table of blocked domains to better handle long domain names and rationales * Change domain blocks ordering now that rationales aren't displayed right away * Only show explanation for block severities actually in use * Reword instance block explanations and add disclaimer for public fetch mode --- app/controllers/about_controller.rb | 34 ++++++++++++++- app/javascript/packs/public.js | 9 ++++ app/javascript/styles/mastodon/tables.scss | 67 ++++++++++++++++++++++++++++++ app/models/domain_block.rb | 1 + app/models/form/admin_settings.rb | 4 ++ app/views/about/blocks.html.haml | 48 +++++++++++++++++++++ app/views/admin/settings/edit.html.haml | 6 +++ config/locales/en.yml | 24 +++++++++++ config/routes.rb | 7 ++-- config/settings.yml | 2 + 10 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 app/views/about/blocks.html.haml (limited to 'app/models/form') diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index d276e8fe5..5e942e5c0 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -3,10 +3,12 @@ class AboutController < ApplicationController layout 'public' - before_action :require_open_federation!, only: [:show, :more] + before_action :require_open_federation!, only: [:show, :more, :blocks] + before_action :check_blocklist_enabled, only: [:blocks] + before_action :authenticate_user!, only: [:blocks], if: :blocklist_account_required? before_action :set_body_classes, only: :show before_action :set_instance_presenter - before_action :set_expires_in + before_action :set_expires_in, only: [:show, :more, :terms] skip_before_action :require_functional!, only: [:more, :terms] @@ -18,12 +20,40 @@ class AboutController < ApplicationController def terms; end + def blocks + @show_rationale = Setting.show_domain_blocks_rationale == 'all' + @show_rationale |= Setting.show_domain_blocks_rationale == 'users' && !current_user.nil? && current_user.functional? + @blocks = DomainBlock.with_user_facing_limitations.order('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain').to_a + end + private def require_open_federation! not_found if whitelist_mode? end + def check_blocklist_enabled + not_found if Setting.show_domain_blocks == 'disabled' + end + + def blocklist_account_required? + Setting.show_domain_blocks == 'users' + end + + def block_severity_text(block) + if block.severity == 'suspend' + I18n.t('domain_blocks.suspension') + else + limitations = [] + limitations << I18n.t('domain_blocks.media_block') if block.reject_media? + limitations << I18n.t('domain_blocks.silence') if block.severity == 'silence' + limitations.join(', ') + end + end + + helper_method :block_severity_text + helper_method :public_fetch_mode? + def new_user User.new.tap do |user| user.build_account diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index b58622a8d..c5cd7129f 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -141,6 +141,15 @@ function main() { return false; }); + delegate(document, '.blocks-table button.icon-button', 'click', function(e) { + e.preventDefault(); + + const classList = this.firstElementChild.classList; + classList.toggle('fa-chevron-down'); + classList.toggle('fa-chevron-up'); + this.parentElement.parentElement.nextElementSibling.classList.toggle('hidden'); + }); + delegate(document, '.modal-button', 'click', e => { e.preventDefault(); diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index 11ac6dfeb..fe6beba5d 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -241,3 +241,70 @@ a.table-action-link { } } } + +.blocks-table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + table-layout: fixed; + border: 1px solid darken($ui-base-color, 8%); + + thead { + border: 1px solid darken($ui-base-color, 8%); + background: darken($ui-base-color, 4%); + font-weight: 500; + + th.severity-column { + width: 120px; + } + + th.button-column { + width: 23px; + } + } + + tbody > tr { + border: 1px solid darken($ui-base-color, 8%); + border-bottom: 0; + background: darken($ui-base-color, 4%); + + &:hover { + background: darken($ui-base-color, 2%); + } + + &.even { + background: $ui-base-color; + + &:hover { + background: lighten($ui-base-color, 2%); + } + } + + &.rationale { + background: lighten($ui-base-color, 4%); + border-top: 0; + + &:hover { + background: lighten($ui-base-color, 6%); + } + + &.hidden { + display: none; + } + } + + td:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + } + + th, + td { + padding: 8px; + line-height: 18px; + vertical-align: top; + text-align: left; + } +} diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 37b8d98c6..4383cbd05 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -25,6 +25,7 @@ class DomainBlock < ApplicationRecord delegate :count, to: :accounts, prefix: true scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } + scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) } class << self def suspend?(domain) diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 051268375..6bc3ca9f5 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -30,6 +30,8 @@ class Form::AdminSettings mascot spam_check_enabled trends + show_domain_blocks + show_domain_blocks_rationale ).freeze BOOLEAN_KEYS = %i( @@ -60,6 +62,8 @@ class Form::AdminSettings validates :site_contact_email, :site_contact_username, presence: true validates :site_contact_username, existing_username: true validates :bootstrap_timeline_accounts, existing_username: { multiple: true } + validates :show_domain_blocks, inclusion: { in: %w(disabled users all) } + validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) } def initialize(_attributes = {}) super diff --git a/app/views/about/blocks.html.haml b/app/views/about/blocks.html.haml new file mode 100644 index 000000000..a81a4d1eb --- /dev/null +++ b/app/views/about/blocks.html.haml @@ -0,0 +1,48 @@ +- content_for :page_title do + = t('domain_blocks.title', instance: site_hostname) + +.grid + .column-0 + .box-widget.rich-formatting + %h2= t('domain_blocks.blocked_domains') + %p= t('domain_blocks.description', instance: site_hostname) + .table-wrapper + %table.blocks-table + %thead + %tr + %th= t('domain_blocks.domain') + %th.severity-column= t('domain_blocks.severity') + - if @show_rationale + %th.button-column + %tbody + - if @blocks.empty? + %tr + %td{ colspan: @show_rationale ? 3 : 2 }= t('domain_blocks.no_domain_blocks') + - else + - @blocks.each_with_index do |block, i| + %tr{ class: i % 2 == 0 ? 'even': nil } + %td{ title: block.domain }= block.domain + %td= block_severity_text(block) + - if @show_rationale + %td + - if block.public_comment.present? + %button.icon-button{ title: t('domain_blocks.show_rationale'), 'aria-label' => t('domain_blocks.show_rationale') } + = fa_icon 'chevron-down fw', 'aria-hidden' => true + - if @show_rationale + - if block.public_comment.present? + %tr.rationale.hidden + %td{ colspan: 3 }= block.public_comment.presence + %h2= t('domain_blocks.severity_legend.title') + - if @blocks.any? { |block| block.reject_media? } + %h3= t('domain_blocks.media_block') + %p= t('domain_blocks.severity_legend.media_block') + - if @blocks.any? { |block| block.severity == 'silence' } + %h3= t('domain_blocks.silence') + %p= t('domain_blocks.severity_legend.silence') + - if @blocks.any? { |block| block.severity == 'suspend' } + %h3= t('domain_blocks.suspension') + %p= t('domain_blocks.severity_legend.suspension') + - if public_fetch_mode? + %p= t('domain_blocks.severity_legend.suspension_disclaimer') + .column-1 + = render 'application/sidebar' diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 28c0ece15..28880c087 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -79,6 +79,12 @@ .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}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :show_domain_blocks, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + .fields-row__column.fields-row__column-6.fields-group + = f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks_rationale.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + .fields-group = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } = 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 } unless whitelist_mode? diff --git a/config/locales/en.yml b/config/locales/en.yml index 4696dc11b..8d267065c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -423,6 +423,13 @@ en: custom_css: desc_html: Modify the look with CSS loaded on every page title: Custom CSS + domain_blocks: + all: To everyone + disabled: To no one + title: Show domain blocks + users: To logged-in local users + domain_blocks_rationale: + title: Show rationale hero: desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to server thumbnail title: Hero image @@ -630,6 +637,23 @@ en: people: one: "%{count} person" other: "%{count} people" + domain_blocks: + blocked_domains: List of limited and blocked domains + description: This is the list of servers that %{instance} limits or reject federation with. + domain: Domain + media_block: Media block + no_domain_blocks: "(No domain blocks)" + severity: Severity + severity_legend: + media_block: Media files coming from the server are neither fetched, stored, or displayed to the user. + silence: Accounts from silenced servers can be found, followed and interacted with, but their toots will not appear in the public timelines, and notifications from them will not reach local users who are not following them. + suspension: No content from suspended servers is stored or displayed, nor is any content sent to them. Interactions from suspended servers are ignored. + suspension_disclaimer: Suspended servers may occasionally retrieve public content from this server. + title: Severities + show_rationale: Show rationale + silence: Silence + suspension: Suspension + title: "%{instance} List of blocked instances" domain_validator: invalid_domain: is not a valid domain name errors: diff --git a/config/routes.rb b/config/routes.rb index 9c33b8190..9ae24b0cd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -423,9 +423,10 @@ Rails.application.routes.draw do get '/web/(*any)', to: 'home#index', as: :web - get '/about', to: 'about#show' - get '/about/more', to: 'about#more' - get '/terms', to: 'about#terms' + get '/about', to: 'about#show' + get '/about/more', to: 'about#more' + get '/about/blocks', to: 'about#blocks' + get '/terms', to: 'about#terms' root 'home#index' diff --git a/config/settings.yml b/config/settings.yml index 4e5eefb59..6dbc46706 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -64,6 +64,8 @@ defaults: &defaults peers_api_enabled: true show_known_fediverse_at_about_page: true spam_check_enabled: true + show_domain_blocks: 'disabled' + show_domain_blocks_rationale: 'disabled' development: <<: *defaults -- cgit From 97192d9a77c0b4b68afe50d6a94d87110a8adbcd Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 22 Aug 2019 04:17:12 +0200 Subject: Fix remote and staff-removed statuses leaving media behind for a day (#11638) The reason for unattaching media instead of removing it is to support delete & redraft functionality, but remote or staff-removed statuses will never be redrafted, so the media should be deleted immediately --- app/controllers/api/v1/statuses_controller.rb | 2 +- app/lib/activitypub/activity/delete.rb | 2 +- app/models/form/status_batch.rb | 2 +- app/services/batched_remove_status_service.rb | 2 +- app/services/remove_status_service.rb | 12 ++++++++++++ app/workers/removal_worker.rb | 4 ++-- spec/controllers/admin/reported_statuses_controller_spec.rb | 2 +- spec/controllers/admin/statuses_controller_spec.rb | 2 +- spec/models/form/status_batch_spec.rb | 4 ++-- 9 files changed, 22 insertions(+), 10 deletions(-) (limited to 'app/models/form') diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 71a505c26..39ca56482 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -53,7 +53,7 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? - RemovalWorker.perform_async(@status.id) + RemovalWorker.perform_async(@status.id, redraft: true) render json: @status, serializer: REST::StatusSerializer, source_requested: true end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 1f2b40c15..345060462 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -70,7 +70,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity end def delete_now! - RemoveStatusService.new.call(@status) + RemoveStatusService.new.call(@status, redraft: false) end def payload diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb index 933dfdaca..831d8b7c5 100644 --- a/app/models/form/status_batch.rb +++ b/app/models/form/status_batch.rb @@ -34,7 +34,7 @@ class Form::StatusBatch def delete_statuses Status.where(id: status_ids).reorder(nil).find_each do |status| - RemovalWorker.perform_async(status.id) + RemovalWorker.perform_async(status.id, redraft: false) Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) log_action :destroy, status end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 6df8d4769..3638134be 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -8,7 +8,7 @@ class BatchedRemoveStatusService < BaseService # Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones # Remove statuses from home feeds # Push delete events to streaming API for home feeds and public feeds - # @param [Status] statuses A preferably batched array of statuses + # @param [Enumerable] statuses A preferably batched array of statuses # @param [Hash] options # @option [Boolean] :skip_side_effects def call(statuses, **options) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 91c934181..685c1d4bf 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -4,6 +4,11 @@ class RemoveStatusService < BaseService include Redisable include Payloadable + # Delete a status + # @param [Status] status + # @param [Hash] options + # @option [Boolean] :redraft + # @options [Boolean] :original_removed def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @status = status @@ -24,6 +29,7 @@ class RemoveStatusService < BaseService remove_from_public remove_from_media if status.media_attachments.any? remove_from_spam_check + remove_media @status.destroy! else @@ -143,6 +149,12 @@ class RemoveStatusService < BaseService redis.publish('timeline:public:local:media', @payload) if @status.local? end + def remove_media + return if @options[:redraft] + + @status.media_attachments.destroy_all + end + def remove_from_spam_check redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id) end diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb index 19a660dd3..14423a4fb 100644 --- a/app/workers/removal_worker.rb +++ b/app/workers/removal_worker.rb @@ -3,8 +3,8 @@ class RemovalWorker include Sidekiq::Worker - def perform(status_id) - RemoveStatusService.new.call(Status.find(status_id)) + def perform(status_id, options = {}) + RemoveStatusService.new.call(Status.find(status_id), **options.symbolize_keys) rescue ActiveRecord::RecordNotFound true end diff --git a/spec/controllers/admin/reported_statuses_controller_spec.rb b/spec/controllers/admin/reported_statuses_controller_spec.rb index c358506d6..bd146b795 100644 --- a/spec/controllers/admin/reported_statuses_controller_spec.rb +++ b/spec/controllers/admin/reported_statuses_controller_spec.rb @@ -47,7 +47,7 @@ describe Admin::ReportedStatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) end end diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb index 1a08c10b7..6b06343ef 100644 --- a/spec/controllers/admin/statuses_controller_spec.rb +++ b/spec/controllers/admin/statuses_controller_spec.rb @@ -65,7 +65,7 @@ describe Admin::StatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) end end diff --git a/spec/models/form/status_batch_spec.rb b/spec/models/form/status_batch_spec.rb index 00c790a11..f9c58c90f 100644 --- a/spec/models/form/status_batch_spec.rb +++ b/spec/models/form/status_batch_spec.rb @@ -41,12 +41,12 @@ describe Form::StatusBatch do it 'call RemovalWorker' do form.save - expect(RemovalWorker).to have_received(:perform_async).with(status.id) + expect(RemovalWorker).to have_received(:perform_async).with(status.id, redraft: false) end it 'do not call RemovalWorker' do form.save - expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id) + expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, redraft: false) end end end -- cgit From 282ea170782e4ce1ed5251a1b94857a512412397 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 22 Aug 2019 21:55:56 +0200 Subject: Add soft delete for statuses for instant deletes through API (#11623) * Add soft delete for statuses to allow them to appear instant * Allow reporting soft-deleted statuses and show them in the admin UI * Change index for getting an account's statuses --- Gemfile | 1 + Gemfile.lock | 3 +++ app/controllers/api/v1/reports_controller.rb | 2 +- app/controllers/api/v1/statuses/reblogs_controller.rb | 3 ++- app/controllers/api/v1/statuses_controller.rb | 1 + app/models/form/status_batch.rb | 1 + app/models/report.rb | 2 +- app/models/status.rb | 6 +++++- app/views/admin/reports/_status.html.haml | 5 ++++- app/workers/removal_worker.rb | 2 +- config/locales/en.yml | 1 + db/migrate/20190819134503_add_deleted_at_to_statuses.rb | 5 +++++ db/migrate/20190820003045_update_statuses_index.rb | 13 +++++++++++++ db/schema.rb | 5 +++-- 14 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20190819134503_add_deleted_at_to_statuses.rb create mode 100644 db/migrate/20190820003045_update_statuses_index.rb (limited to 'app/models/form') diff --git a/Gemfile b/Gemfile index 250a28a3a..86dab965a 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' +gem 'discard', '~> 1.1' gem 'doorkeeper', '~> 5.1' gem 'fast_blank', '~> 1.0' gem 'fastimage' diff --git a/Gemfile.lock b/Gemfile.lock index 1da6d73a6..b896909a4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -204,6 +204,8 @@ GEM devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.3) + discard (1.1.0) + activerecord (>= 4.2, < 7) docile (1.3.2) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) @@ -692,6 +694,7 @@ DEPENDENCIES devise (~> 4.6) devise-two-factor (~> 3.1) devise_pam_authenticatable2 (~> 9.2) + discard (~> 1.1) doorkeeper (~> 5.1) dotenv-rails (~> 2.7) fabrication (~> 2.20) diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index e182a9c6c..1b0b4b05b 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController private def reported_status_ids - reported_account.statuses.find(status_ids).pluck(:id) + reported_account.statuses.with_discarded.find(status_ids).pluck(:id) end def status_ids diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index ed4f55100..42381a37f 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController @reblogs_map = { @status.id => false } authorize status_for_destroy, :unreblog? + status_for_destroy.discard RemovalWorker.perform_async(status_for_destroy.id) render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) @@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController end def status_for_destroy - current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! + @status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! end def reblog_params diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 39ca56482..bba3c0651 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -53,6 +53,7 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? + @status.discard RemovalWorker.perform_async(@status.id, redraft: true) render json: @status, serializer: REST::StatusSerializer, source_requested: true diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb index 831d8b7c5..e09cc2594 100644 --- a/app/models/form/status_batch.rb +++ b/app/models/form/status_batch.rb @@ -34,6 +34,7 @@ class Form::StatusBatch def delete_statuses Status.where(id: status_ids).reorder(nil).find_each do |status| + status.discard RemovalWorker.perform_async(status.id, redraft: false) Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) log_action :destroy, status diff --git a/app/models/report.rb b/app/models/report.rb index 5192ceef7..1e707ff1c 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -43,7 +43,7 @@ class Report < ApplicationRecord end def statuses - Status.where(id: status_ids).includes(:account, :media_attachments, :mentions) + Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions) end def media_attachments diff --git a/app/models/status.rb b/app/models/status.rb index 0538c4e9e..9cfaddcec 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -22,15 +22,19 @@ # application_id :bigint(8) # in_reply_to_account_id :bigint(8) # poll_id :bigint(8) +# deleted_at :datetime # class Status < ApplicationRecord before_destroy :unlink_from_conversations + include Discard::Model include Paginable include Cacheable include StatusThreadingConcern + self.discard_column = :deleted_at + # If `override_timestamps` is set at creation time, Snowflake ID creation # will be based on current time instead of `created_at` attr_accessor :override_timestamps @@ -72,7 +76,7 @@ class Status < ApplicationRecord accepts_nested_attributes_for :poll - default_scope { recent } + default_scope { recent.kept } scope :recent, -> { reorder(id: :desc) } scope :remote, -> { where(local: false).where.not(uri: nil) } diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml index 9376db7ff..6facc0a56 100644 --- a/app/views/admin/reports/_status.html.haml +++ b/app/views/admin/reports/_status.html.haml @@ -16,11 +16,14 @@ - video = status.proper.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description - else - = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } + = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } .detailed-status__meta = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) + - if status.discarded? + · + %span.negative-hint= t('admin.statuses.deleted') · - if status.reblog? = fa_icon('retweet fw') diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb index 14423a4fb..2a1eaa89b 100644 --- a/app/workers/removal_worker.rb +++ b/app/workers/removal_worker.rb @@ -4,7 +4,7 @@ class RemovalWorker include Sidekiq::Worker def perform(status_id, options = {}) - RemoveStatusService.new.call(Status.find(status_id), **options.symbolize_keys) + RemoveStatusService.new.call(Status.with_discarded.find(status_id), **options.symbolize_keys) rescue ActiveRecord::RecordNotFound true end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8d267065c..a50dcb8a5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -499,6 +499,7 @@ en: delete: Delete nsfw_off: Mark as not sensitive nsfw_on: Mark as sensitive + deleted: Deleted failed_to_execute: Failed to execute media: title: Media diff --git a/db/migrate/20190819134503_add_deleted_at_to_statuses.rb b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb new file mode 100644 index 000000000..5af109097 --- /dev/null +++ b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :deleted_at, :datetime + end +end diff --git a/db/migrate/20190820003045_update_statuses_index.rb b/db/migrate/20190820003045_update_statuses_index.rb new file mode 100644 index 000000000..5c2ea1f6a --- /dev/null +++ b/db/migrate/20190820003045_update_statuses_index.rb @@ -0,0 +1,13 @@ +class UpdateStatusesIndex < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], where: 'deleted_at IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20190820 } + remove_index :statuses, name: :index_statuses_20180106 + end + + def down + safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20180106 } + remove_index :statuses, name: :index_statuses_20190820 + end +end diff --git a/db/schema.rb b/db/schema.rb index 18f615d61..afa6d724c 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: 2019_08_15_225426) do +ActiveRecord::Schema.define(version: 2019_08_20_003045) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -644,7 +644,8 @@ ActiveRecord::Schema.define(version: 2019_08_15_225426) do t.bigint "application_id" t.bigint "in_reply_to_account_id" t.bigint "poll_id" - t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc } + t.datetime "deleted_at" + t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id" -- cgit From 261e52268c05d2da4459a23e2898555dd5db5771 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 9 Sep 2019 12:50:09 +0200 Subject: Add batch approve/reject for pending hashtags in admin UI (#11791) --- app/controllers/admin/tags_controller.rb | 41 +++++++++++++++++++++++++++--- app/javascript/styles/mastodon/tables.scss | 10 ++++++++ app/models/form/tag_batch.rb | 33 ++++++++++++++++++++++++ app/views/admin/tags/_tag.html.haml | 30 ++++++++++++---------- app/views/admin/tags/index.html.haml | 37 ++++++++++++++++++++++++++- config/locales/en.yml | 1 + config/routes.rb | 9 ++++++- 7 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 app/models/form/tag_batch.rb (limited to 'app/models/form') diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 8bd4e5f8b..376ebe44d 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -3,12 +3,33 @@ module Admin class TagsController < BaseController before_action :set_tags, only: :index - before_action :set_tag, except: :index - before_action :set_usage_by_domain, except: :index - before_action :set_counters, except: :index + before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all] + before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all] + before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all] def index authorize :tag, :index? + + @form = Form::TagBatch.new + end + + def batch + @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + ensure + redirect_to admin_tags_path(filter_params) + end + + def approve_all + Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'approve').save + redirect_to admin_tags_path(filter_params) + end + + def reject_all + Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'reject').save + redirect_to admin_tags_path(filter_params) end def show @@ -61,7 +82,7 @@ module Admin end def filter_params - params.slice(:context, :review).permit(:context, :review) + params.slice(:context, :review, :page).permit(:context, :review, :page) end def tag_params @@ -75,5 +96,17 @@ module Admin date.to_time(:utc).beginning_of_day.to_i end end + + def form_tag_batch_params + params.require(:form_tag_batch).permit(:action, tag_ids: []) + end + + def action_from_button + if params[:approve] + 'approve' + elsif params[:reject] + 'reject' + end + end end end diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index fe6beba5d..2aef099e6 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -211,6 +211,16 @@ a.table-action-link { padding: 0; } } + + .directory__tag { + margin: 0; + width: 100%; + + a { + background: transparent; + border-radius: 0; + } + } } .status__content { diff --git a/app/models/form/tag_batch.rb b/app/models/form/tag_batch.rb new file mode 100644 index 000000000..fd517a1a6 --- /dev/null +++ b/app/models/form/tag_batch.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Form::TagBatch + include ActiveModel::Model + include Authorization + + attr_accessor :tag_ids, :action, :current_account + + def save + case action + when 'approve' + approve! + when 'reject' + reject! + end + end + + private + + def tags + Tag.where(id: tag_ids) + end + + def approve! + tags.each { |tag| authorize(tag, :update?) } + tags.update_all(trendable: true, reviewed_at: Time.now.utc) + end + + def reject! + tags.each { |tag| authorize(tag, :update?) } + tags.update_all(trendable: false, reviewed_at: Time.now.utc) + end +end diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml index 91af8e492..670f3bc05 100644 --- a/app/views/admin/tags/_tag.html.haml +++ b/app/views/admin/tags/_tag.html.haml @@ -1,16 +1,20 @@ -.directory__tag - = link_to admin_tag_path(tag.id) do - %h4 - = fa_icon 'hashtag' - = tag.name +.batch-table__row + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :tag_ids, { multiple: true, include_hidden: false }, tag.id - %small - = t('admin.tags.in_directory', count: tag.accounts_count) - • - = t('admin.tags.unique_uses_today', count: tag.history.first[:accounts]) + .directory__tag + = link_to admin_tag_path(tag.id) do + %h4 + = fa_icon 'hashtag' + = tag.name - - if tag.trending? - = fa_icon 'fire fw' - = t('admin.tags.trending_right_now') + %small + = t('admin.tags.in_directory', count: tag.accounts_count) + • + = t('admin.tags.unique_uses_today', count: tag.history.first[:accounts]) - .trends__item__current= number_to_human tag.history.first[:uses], strip_insignificant_zeros: true + - if tag.trending? + = fa_icon 'fire fw' + = t('admin.tags.trending_right_now') + + .trends__item__current= number_to_human tag.history.first[:uses], strip_insignificant_zeros: true diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml index d994955ef..324d13d3e 100644 --- a/app/views/admin/tags/index.html.haml +++ b/app/views/admin/tags/index.html.haml @@ -1,6 +1,9 @@ - content_for :page_title do = t('admin.tags.title') +- content_for :header_tags do + = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' + .filters .filter-subset %strong= t('admin.tags.context') @@ -18,5 +21,37 @@ %hr.spacer/ -= render @tags += form_for(@form, url: batch_admin_tags_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + = hidden_field_tag :context, params[:context] + = hidden_field_tag :review, params[:review] + + .batch-table + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + - if params[:review] == 'pending_review' + = f.button safe_join([fa_icon('check'), t('admin.accounts.approve')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + = f.button safe_join([fa_icon('times'), t('admin.accounts.reject')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + - else + %span.neutral-hint= t('generic.no_batch_actions_available') + + .batch-table__body + - if @tags.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'tag', collection: @tags, locals: { f: f } + = paginate @tags + +- if params[:review] == 'pending_review' + %hr.spacer/ + + %div{ style: 'overflow: hidden' } + %div{ style: 'float: right' } + = link_to t('admin.accounts.reject_all'), reject_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' + + %div + = link_to t('admin.accounts.approve_all'), approve_all_admin_tags_path, method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' diff --git a/config/locales/en.yml b/config/locales/en.yml index 687f5f2a0..42d8e0eb8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -727,6 +727,7 @@ en: all: All changes_saved_msg: Changes successfully saved! copy: Copy + no_batch_actions_available: No batch actions available on this page order_by: Order by save_changes: Save changes validation_errors: diff --git a/config/routes.rb b/config/routes.rb index 1ebf9e066..534e68814 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -251,7 +251,14 @@ Rails.application.routes.draw do end resources :account_moderation_notes, only: [:create, :destroy] - resources :tags, only: [:index, :show, :update] + + resources :tags, only: [:index, :show, :update] do + collection do + post :approve_all + post :reject_all + post :batch + end + end end get '/admin', to: redirect('/admin/dashboard', status: 302) -- cgit From 1110ea1a9162d5488e1ed5dbccd0803618e713f8 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 9 Sep 2019 22:44:17 +0200 Subject: Add batch actions and categories to admin UI for custom emojis (#11793) --- app/controllers/admin/custom_emojis_controller.rb | 102 +++++++------------- app/javascript/styles/mastodon/tables.scss | 41 ++++++++ app/models/custom_emoji.rb | 6 ++ app/models/custom_emoji_category.rb | 2 + app/models/custom_emoji_filter.rb | 8 +- app/models/form/custom_emoji_batch.rb | 106 +++++++++++++++++++++ .../admin/custom_emojis/_custom_emoji.html.haml | 55 ++++++----- app/views/admin/custom_emojis/index.html.haml | 66 ++++++++++--- config/locales/en.yml | 3 + config/routes.rb | 8 +- .../admin/custom_emojis_controller_spec.rb | 60 ------------ 11 files changed, 281 insertions(+), 176 deletions(-) create mode 100644 app/models/form/custom_emoji_batch.rb (limited to 'app/models/form') diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index f77699166..2af90f051 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -2,19 +2,20 @@ module Admin class CustomEmojisController < BaseController - before_action :set_custom_emoji, except: [:index, :new, :create] - before_action :set_filter_params - include ObfuscateFilename + obfuscate_filename [:custom_emoji, :image] def index authorize :custom_emoji, :index? + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) + @form = Form::CustomEmojiBatch.new end def new authorize :custom_emoji, :create? + @custom_emoji = CustomEmoji.new end @@ -31,69 +32,17 @@ module Admin end end - def update - authorize @custom_emoji, :update? - - if @custom_emoji.update(resource_params) - log_action :update, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.updated_msg') - else - flash[:alert] = I18n.t('admin.custom_emojis.update_failed_msg') - end - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def destroy - authorize @custom_emoji, :destroy? - @custom_emoji.destroy! - log_action :destroy, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.destroyed_msg') - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def copy - authorize @custom_emoji, :copy? - - emoji = CustomEmoji.find_or_initialize_by(domain: nil, - shortcode: @custom_emoji.shortcode) - 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') - end - - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def enable - authorize @custom_emoji, :enable? - @custom_emoji.update!(disabled: false) - log_action :enable, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.enabled_msg') - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def disable - authorize @custom_emoji, :disable? - @custom_emoji.update!(disabled: true) - log_action :disable, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.disabled_msg') - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) + def batch + @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + ensure + redirect_to admin_custom_emojis_path(filter_params) end private - def set_custom_emoji - @custom_emoji = CustomEmoji.find(params[:id]) - end - - def set_filter_params - @filter_params = filter_params.to_hash.symbolize_keys - end - def resource_params params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) end @@ -103,12 +52,29 @@ module Admin end def filter_params - params.permit( - :local, - :remote, - :by_domain, - :shortcode - ) + params.slice(:local, :remote, :by_domain, :shortcode, :page).permit(:local, :remote, :by_domain, :shortcode, :page) + end + + def action_from_button + if params[:update] + 'update' + elsif params[:list] + 'list' + elsif params[:unlist] + 'unlist' + elsif params[:enable] + 'enable' + elsif params[:disable] + 'disable' + elsif params[:copy] + 'copy' + elsif params[:delete] + 'delete' + end + end + + def form_custom_emoji_batch_params + params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: []) end end end diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index 2aef099e6..d6403986f 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -180,6 +180,18 @@ a.table-action-link { } } + &__form { + padding: 16px; + border: 1px solid darken($ui-base-color, 8%); + border-top: 0; + background: $ui-base-color; + + .fields-row { + padding-top: 0; + margin-bottom: 0; + } + } + &__row { border: 1px solid darken($ui-base-color, 8%); border-top: 0; @@ -210,6 +222,35 @@ a.table-action-link { &--unpadded { padding: 0; } + + &--with-image { + display: flex; + align-items: center; + } + + &__image { + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + + .emojione { + width: 32px; + height: 32px; + } + } + + &__text { + flex: 1 1 auto; + } + + &__extra { + flex: 0 0 auto; + text-align: right; + color: $darker-text-color; + font-weight: 500; + } } .directory__tag { diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index b21ad9042..0a4201a14 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -59,6 +59,12 @@ class CustomEmoji < ApplicationRecord :emoji end + def copy! + copy = self.class.find_or_initialize_by(domain: nil, shortcode: shortcode) + copy.image = image + copy.save! + end + class << self def from_text(text, domain) return [] if text.blank? diff --git a/app/models/custom_emoji_category.rb b/app/models/custom_emoji_category.rb index 7d8c0ee2d..3c87f2b2e 100644 --- a/app/models/custom_emoji_category.rb +++ b/app/models/custom_emoji_category.rb @@ -12,4 +12,6 @@ class CustomEmojiCategory < ApplicationRecord has_many :emojis, class_name: 'CustomEmoji', foreign_key: 'category_id', inverse_of: :category + + validates :name, presence: true, uniqueness: true end diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb index 7649055d2..15b8da1d1 100644 --- a/app/models/custom_emoji_filter.rb +++ b/app/models/custom_emoji_filter.rb @@ -11,6 +11,8 @@ class CustomEmojiFilter scope = CustomEmoji.alphabetic params.each do |key, value| + next if key.to_s == 'page' + scope.merge!(scope_for(key, value)) if value.present? end @@ -22,13 +24,13 @@ class CustomEmojiFilter def scope_for(key, value) case key.to_s when 'local' - CustomEmoji.local + CustomEmoji.local.left_joins(:category).reorder(Arel.sql('custom_emoji_categories.name ASC NULLS FIRST, custom_emojis.shortcode ASC')) when 'remote' CustomEmoji.remote when 'by_domain' - CustomEmoji.where(domain: value.downcase) + CustomEmoji.where(domain: value.strip.downcase) when 'shortcode' - CustomEmoji.search(value) + CustomEmoji.search(value.strip) else raise "Unknown filter: #{key}" end diff --git a/app/models/form/custom_emoji_batch.rb b/app/models/form/custom_emoji_batch.rb new file mode 100644 index 000000000..076e8c9e3 --- /dev/null +++ b/app/models/form/custom_emoji_batch.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +class Form::CustomEmojiBatch + include ActiveModel::Model + include Authorization + include AccountableConcern + + attr_accessor :custom_emoji_ids, :action, :current_account, + :category_id, :category_name, :visible_in_picker + + def save + case action + when 'update' + update! + when 'list' + list! + when 'unlist' + unlist! + when 'enable' + enable! + when 'disable' + disable! + when 'copy' + copy! + when 'delete' + delete! + end + end + + private + + def custom_emojis + CustomEmoji.where(id: custom_emoji_ids) + end + + def update! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :update?) } + + category = begin + if category_id.present? + CustomEmojiCategory.find(category_id) + elsif category_name.present? + CustomEmojiCategory.create!(name: category_name) + end + end + + custom_emojis.each do |custom_emoji| + custom_emoji.update(category_id: category&.id) + log_action :update, custom_emoji + end + end + + def list! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :update?) } + + custom_emojis.each do |custom_emoji| + custom_emoji.update(visible_in_picker: true) + log_action :update, custom_emoji + end + end + + def unlist! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :update?) } + + custom_emojis.each do |custom_emoji| + custom_emoji.update(visible_in_picker: false) + log_action :update, custom_emoji + end + end + + def enable! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :enable?) } + + custom_emojis.each do |custom_emoji| + custom_emoji.update(disabled: false) + log_action :enable, custom_emoji + end + end + + def disable! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :disable?) } + + custom_emojis.each do |custom_emoji| + custom_emoji.update(disabled: true) + log_action :disable, custom_emoji + end + end + + def copy! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :copy?) } + + custom_emojis.each do |custom_emoji| + copied_custom_emoji = custom_emoji.copy! + log_action :create, copied_custom_emoji + end + end + + def delete! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :destroy?) } + + custom_emojis.each do |custom_emoji| + custom_emoji.destroy + log_action :destroy, custom_emoji + end + end +end diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index fbaa9a174..9e06a3b42 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -1,28 +1,31 @@ -%tr - %td - = custom_emoji_tag(custom_emoji) - %td - %samp= ":#{custom_emoji.shortcode}:" - %td - - if custom_emoji.local? - = t('admin.accounts.location.local') - - else - = link_to custom_emoji.domain, admin_custom_emojis_path(by_domain: custom_emoji.domain) - %td - - if custom_emoji.local? - - if custom_emoji.visible_in_picker - = table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }, page: params[:page], **@filter_params), method: :patch +.batch-table__row + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :custom_emoji_ids, { multiple: true, include_hidden: false }, custom_emoji.id + .batch-table__row__content.batch-table__row__content--with-image + .batch-table__row__content__image + = custom_emoji_tag(custom_emoji) + + .batch-table__row__content__text + %samp= ":#{custom_emoji.shortcode}:" + + - if custom_emoji.local? + %span.account-role.bot= custom_emoji.category&.name || t('admin.custom_emojis.uncategorized') + + .batch-table__row__content__extra + - if custom_emoji.local? + = t('admin.accounts.location.local') - else - = table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }, page: params[:page], **@filter_params), method: :patch - - else - - if custom_emoji.local_counterpart.present? - = link_to safe_join([custom_emoji_tag(custom_emoji.local_counterpart), t('admin.custom_emojis.overwrite')]), copy_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post, class: 'table-action-link' + = custom_emoji.domain + + %br/ + + - if custom_emoji.disabled? + = t('admin.custom_emojis.disabled') - else - = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post - %td - - if custom_emoji.disabled? - = table_link_to 'power-off', t('admin.custom_emojis.enable'), enable_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } - - else - = table_link_to 'power-off', t('admin.custom_emojis.disable'), disable_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } - %td - = table_link_to 'times', t('admin.custom_emojis.delete'), admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } + = t('admin.custom_emojis.enabled') + - if custom_emoji.local? + • + - if custom_emoji.visible_in_picker? + = t('admin.custom_emojis.listed') + - else + = t('admin.custom_emojis.unlisted') diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml index 3a119276c..7320ce1bb 100644 --- a/app/views/admin/custom_emojis/index.html.haml +++ b/app/views/admin/custom_emojis/index.html.haml @@ -1,6 +1,9 @@ - content_for :page_title do = t('admin.custom_emojis.title') +- content_for :header_tags do + = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' + .filters .filter-subset %strong= t('admin.accounts.location.title') @@ -20,8 +23,7 @@ = form_tag admin_custom_emojis_url, method: 'GET', class: 'simple_form' do .fields-group - Admin::FilterHelper::CUSTOM_EMOJI_FILTERS.each do |key| - - if params[key].present? - = hidden_field_tag key, params[key] + = hidden_field_tag key, params[key] if params[key].present? - %i(shortcode by_domain).each do |key| .input.string.optional @@ -31,18 +33,54 @@ %button= t('admin.accounts.search') = link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative' -.table-wrapper - %table.table - %thead - %tr - %th= t('admin.custom_emojis.emoji') - %th= t('admin.custom_emojis.shortcode') - %th= t('admin.accounts.domain') - %th - %th - %th - %tbody - = render @custom_emojis += form_for(@form, url: batch_admin_custom_emojis_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - Admin::FilterHelper::CUSTOM_EMOJI_FILTERS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .batch-table + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + - if params[:local] == '1' + = f.button safe_join([fa_icon('save'), t('generic.save_changes')]), name: :update, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + = f.button safe_join([fa_icon('eye'), t('admin.custom_emojis.list')]), name: :list, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + = f.button safe_join([fa_icon('eye-slash'), t('admin.custom_emojis.unlist')]), name: :unlist, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + = f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.enable')]), name: :enable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + = f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.disable')]), name: :disable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + - unless params[:local] == '1' + = f.button safe_join([fa_icon('copy'), t('admin.custom_emojis.copy')]), name: :copy, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + + - if params[:local] == '1' + .batch-table__form.simple_form + .fields-row + .fields-group.fields-row__column.fields-row__column-6 + .input.select.optional + .label_input + = f.select :category_id, options_from_collection_for_select(CustomEmojiCategory.all, 'id', 'name'), prompt: t('admin.custom_emojis.assign_category'), class: 'select optional', 'aria-label': t('admin.custom_emojis.assign_category') + + .fields-group.fields-row__column.fields-row__column-6 + .input.string.optional + .label_input + = f.text_field :category_name, class: 'string optional', placeholder: t('admin.custom_emojis.create_new_category'), 'aria-label': t('admin.custom_emojis.create_new_category') + + .batch-table__body + - if @custom_emojis.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'custom_emoji', collection: @custom_emojis, locals: { f: f } = paginate @custom_emojis + +%hr.spacer/ + = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' diff --git a/config/locales/en.yml b/config/locales/en.yml index 42d8e0eb8..52cb4a269 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -225,10 +225,12 @@ en: deleted_status: "(deleted status)" title: Audit log custom_emojis: + assign_category: Assign category by_domain: Domain copied_msg: Successfully created local copy of the emoji copy: Copy copy_failed_msg: Could not make a local copy of that emoji + create_new_category: Create new category created_msg: Emoji successfully created! delete: Delete destroyed_msg: Emojo successfully destroyed! @@ -245,6 +247,7 @@ en: shortcode: Shortcode shortcode_hint: At least 2 characters, only alphanumeric characters and underscores title: Custom emojis + uncategorized: Uncategorized unlisted: Unlisted update_failed_msg: Could not update that emoji updated_msg: Emoji successfully updated! diff --git a/config/routes.rb b/config/routes.rb index 534e68814..d22a9e56a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -242,11 +242,9 @@ Rails.application.routes.draw do resource :two_factor_authentication, only: [:destroy] end - resources :custom_emojis, only: [:index, :new, :create, :update, :destroy] do - member do - post :copy - post :enable - post :disable + resources :custom_emojis, only: [:index, :new, :create] do + collection do + post :batch end end diff --git a/spec/controllers/admin/custom_emojis_controller_spec.rb b/spec/controllers/admin/custom_emojis_controller_spec.rb index b7e2894e9..a8d96948c 100644 --- a/spec/controllers/admin/custom_emojis_controller_spec.rb +++ b/spec/controllers/admin/custom_emojis_controller_spec.rb @@ -52,64 +52,4 @@ describe Admin::CustomEmojisController do end end end - - describe 'PUT #update' do - let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: 'test') } - let(:image) { fixture_file_upload(Rails.root.join('spec', 'fixtures', 'files', 'emojo.png'), 'image/png') } - - before do - put :update, params: { id: custom_emoji.id, custom_emoji: params } - end - - context 'when parameter is valid' do - let(:params) { { shortcode: 'updated', image: image } } - - it 'succeeds in updating custom emoji' do - expect(flash[:notice]).to eq I18n.t('admin.custom_emojis.updated_msg') - expect(custom_emoji.reload).to have_attributes(shortcode: 'updated') - end - end - - context 'when parameter is invalid' do - let(:params) { { shortcode: 'u', image: image } } - - it 'fails to update custom emoji' do - expect(flash[:alert]).to eq I18n.t('admin.custom_emojis.update_failed_msg') - expect(custom_emoji.reload).to have_attributes(shortcode: 'test') - end - end - end - - describe 'POST #copy' do - subject { post :copy, params: { id: custom_emoji.id } } - - let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: 'test') } - - it 'copies custom emoji' do - expect { subject }.to change { CustomEmoji.where(shortcode: 'test').count }.by(1) - expect(flash[:notice]).to eq I18n.t('admin.custom_emojis.copied_msg') - end - end - - describe 'POST #enable' do - let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: 'test', disabled: true) } - - before { post :enable, params: { id: custom_emoji.id } } - - it 'enables custom emoji' do - expect(response).to redirect_to admin_custom_emojis_path - expect(custom_emoji.reload).to have_attributes(disabled: false) - end - end - - describe 'POST #disable' do - let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: 'test', disabled: false) } - - before { post :disable, params: { id: custom_emoji.id } } - - it 'enables custom emoji' do - expect(response).to redirect_to admin_custom_emojis_path - expect(custom_emoji.reload).to have_attributes(disabled: true) - end - end end -- cgit From 4fe127664b0ae22a528b4a4467ab2de92e3da3ef Mon Sep 17 00:00:00 2001 From: Tao Bror Bojlén Date: Wed, 11 Sep 2019 07:44:58 +0100 Subject: add admin setting for default search engine indexing (fix #11750) (#11804) --- app/lib/settings/scoped_settings.rb | 1 + app/models/form/admin_settings.rb | 2 ++ app/views/admin/settings/edit.html.haml | 3 +++ config/locales/en.yml | 3 +++ spec/controllers/application_controller_spec.rb | 1 + 5 files changed, 10 insertions(+) (limited to 'app/models/form') diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb index 3653ab114..9ca39510a 100644 --- a/app/lib/settings/scoped_settings.rb +++ b/app/lib/settings/scoped_settings.rb @@ -4,6 +4,7 @@ module Settings class ScopedSettings DEFAULTING_TO_UNSCOPED = %w( theme + noindex ).freeze def initialize(object) diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 6bc3ca9f5..24196e182 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -32,6 +32,7 @@ class Form::AdminSettings trends show_domain_blocks show_domain_blocks_rationale + noindex ).freeze BOOLEAN_KEYS = %i( @@ -45,6 +46,7 @@ class Form::AdminSettings profile_directory spam_check_enabled trends + noindex ).freeze UPLOAD_KEYS = %i( diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 28880c087..752386b3c 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -71,6 +71,9 @@ .fields-group = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') + .fields-group + = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') + .fields-group = f.input :spam_check_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.spam_check_enabled.title'), hint: t('admin.settings.spam_check_enabled.desc_html') diff --git a/config/locales/en.yml b/config/locales/en.yml index 52cb4a269..0a5ca31c1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -427,6 +427,9 @@ en: custom_css: desc_html: Modify the look with CSS loaded on every page title: Custom CSS + default_noindex: + desc_html: Affects all users who have not changed this setting themselves + title: Opt users out of search engine indexing by default domain_blocks: all: To everyone disabled: To no one diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 1811500df..da4a794cd 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -110,6 +110,7 @@ describe ApplicationController, type: :controller do sign_in current_user allow(Setting).to receive(:[]).with('theme').and_return 'contrast' + allow(Setting).to receive(:[]).with('noindex').and_return false expect(controller.view_context.current_theme).to eq 'contrast' end -- cgit From c5d37f18cb3f4d6212fb8f3e1c4e1e027f677ec5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 11 Sep 2019 16:32:44 +0200 Subject: Change deletes to preserve soft-deleted statuses in unresolved reports (#11805) Change all account actions except "none" to resolve all unresolved reports Refactor `SuspendAccountService` to be more readable --- app/controllers/admin/accounts_controller.rb | 2 +- app/controllers/admin/report_notes_controller.rb | 9 ++-- .../api/v1/admin/accounts_controller.rb | 2 +- app/lib/activitypub/activity/delete.rb | 3 +- app/models/account.rb | 1 + app/models/admin/account_action.rb | 24 +++++++-- app/models/form/account_batch.rb | 2 +- app/models/form/status_batch.rb | 2 +- app/models/report.rb | 1 + app/models/status.rb | 4 ++ app/models/user.rb | 4 ++ app/services/block_domain_service.rb | 2 +- app/services/remove_status_service.rb | 7 +-- app/services/suspend_account_service.rb | 62 ++++++++++++++++------ app/services/unallow_domain_service.rb | 2 +- app/workers/admin/suspension_worker.rb | 2 +- lib/mastodon/accounts_cli.rb | 4 +- lib/mastodon/domains_cli.rb | 2 +- .../admin/reported_statuses_controller_spec.rb | 2 +- spec/controllers/admin/statuses_controller_spec.rb | 2 +- spec/models/form/status_batch_spec.rb | 4 +- 21 files changed, 98 insertions(+), 45 deletions(-) (limited to 'app/models/form') diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 2fa1dfe5f..68b6352f8 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -41,7 +41,7 @@ module Admin def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true) + SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) redirect_to admin_pending_accounts_path end diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index bcb3f2026..b816c5b5d 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -5,10 +5,10 @@ module Admin before_action :set_report_note, only: [:destroy] def create - authorize ReportNote, :create? + authorize :report_note, :create? @report_note = current_account.report_notes.new(resource_params) - @report = @report_note.report + @report = @report_note.report if @report_note.save if params[:create_and_resolve] @@ -26,9 +26,8 @@ module Admin redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg') else - @report_notes = @report.notes.latest - @report_history = @report.history - @form = Form::StatusBatch.new + @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at) + @form = Form::StatusBatch.new render template: 'admin/reports/show' end diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index c306180ca..c35ea5ab2 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -58,7 +58,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true) + SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) render json: @account, serializer: REST::Admin::AccountSerializer end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 345060462..dc9ff580c 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -13,8 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def delete_person lock_or_return("delete_in_progress:#{@account.id}") do - SuspendAccountService.new.call(@account) - @account.destroy! + SuspendAccountService.new.call(@account, reserve_username: false) end end diff --git a/app/models/account.rb b/app/models/account.rb index 8c9388b95..55fe53fae 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -115,6 +115,7 @@ class Account < ApplicationRecord :approved?, :pending?, :disabled?, + :unconfirmed_or_pending?, :role, :admin?, :moderator?, diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb index c7da8b52c..b30a82369 100644 --- a/app/models/admin/account_action.rb +++ b/app/models/admin/account_action.rb @@ -83,19 +83,23 @@ class Admin::AccountAction # A log entry is only interesting if the warning contains # custom text from someone. Otherwise it's just noise. + log_action(:create, warning) if warning.text.present? end def process_reports! - return if report_id.blank? + # If we're doing "mark as resolved" on a single report, + # then we want to keep other reports open in case they + # contain new actionable information. + # + # Otherwise, we will mark all unresolved reports about + # the account as resolved. - authorize(report, :update?) + reports.each { |report| authorize(report, :update?) } - if type == 'none' + reports.each do |report| log_action(:resolve, report) report.resolve!(current_account) - else - Report.where(target_account: target_account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id) end end @@ -141,6 +145,16 @@ class Admin::AccountAction @report.status_ids if @report && include_statuses end + def reports + @reports ||= begin + if type == 'none' && with_report? + [report] + else + Report.where(target_account: target_account).unresolved + end + end + end + def warning_preset @warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present? end diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb index f1b7a4566..0b285fde9 100644 --- a/app/models/form/account_batch.rb +++ b/app/models/form/account_batch.rb @@ -69,6 +69,6 @@ class Form::AccountBatch records = accounts.includes(:user) records.each { |account| authorize(account.user, :reject?) } - .each { |account| SuspendAccountService.new.call(account, including_user: true, destroy: true, skip_distribution: true) } + .each { |account| SuspendAccountService.new.call(account, reserve_email: false, reserve_username: false) } end end diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb index e09cc2594..c4943a7ea 100644 --- a/app/models/form/status_batch.rb +++ b/app/models/form/status_batch.rb @@ -35,7 +35,7 @@ class Form::StatusBatch def delete_statuses Status.where(id: status_ids).reorder(nil).find_each do |status| status.discard - RemovalWorker.perform_async(status.id, redraft: false) + RemovalWorker.perform_async(status.id, immediate: true) Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) log_action :destroy, status end diff --git a/app/models/report.rb b/app/models/report.rb index 1e707ff1c..fb2e040ee 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -59,6 +59,7 @@ class Report < ApplicationRecord end def resolve!(acting_account) + RemovalWorker.push_bulk(Status.with_discarded.discarded.where(id: status_ids).pluck(:id)) { |status_id| [status_id, { immediate: true }] } update!(action_taken: true, action_taken_by_account_id: acting_account.id) end diff --git a/app/models/status.rb b/app/models/status.rb index 9cfaddcec..471bb03b4 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -214,6 +214,10 @@ class Status < ApplicationRecord !sensitive? && with_media? end + def reported? + @reported ||= Report.where(target_account: account).unresolved.where('? = ANY(status_ids)', id).exists? + end + def emojis return @emojis if defined?(@emojis) diff --git a/app/models/user.rb b/app/models/user.rb index 95f1d8fc5..78b82a68f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -171,6 +171,10 @@ class User < ApplicationRecord confirmed? && approved? && !disabled? && !account.suspended? end + def unconfirmed_or_pending? + !(confirmed? && approved?) + end + def inactive_message !approved? ? :pending : super end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index 0ec6be503..ae461abf2 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -53,7 +53,7 @@ class BlockDomainService < BaseService def suspend_accounts! blocked_domain_accounts.without_suspended.reorder(nil).find_each do |account| - SuspendAccountService.new.call(account, suspended_at: @domain_block.created_at) + SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at) end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 685c1d4bf..f9352ed3d 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -8,7 +8,8 @@ class RemoveStatusService < BaseService # @param [Status] status # @param [Hash] options # @option [Boolean] :redraft - # @options [Boolean] :original_removed + # @option [Boolean] :immediate + # @option [Boolean] :original_removed def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @status = status @@ -31,7 +32,7 @@ class RemoveStatusService < BaseService remove_from_spam_check remove_media - @status.destroy! + @status.destroy! if @options[:immediate] || !@status.reported? else raise Mastodon::RaceConditionError end @@ -150,7 +151,7 @@ class RemoveStatusService < BaseService end def remove_media - return if @options[:redraft] + return if @options[:redraft] || (!@options[:immediate] && @status.reported?) @status.media_attachments.destroy_all end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 85da7e921..ecc893931 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -15,7 +15,6 @@ class SuspendAccountService < BaseService favourites follow_requests list_accounts - media_attachments mute_relationships muted_by_relationships notifications @@ -32,14 +31,26 @@ class SuspendAccountService < BaseService targeted_reports ).freeze - # Suspend an account and remove as much of its data as possible + # Suspend or remove an account and remove as much of its data + # as possible. If it's a local account and it has not been confirmed + # or never been approved, then side effects are skipped and both + # the user and account records are removed fully. Otherwise, + # it is controlled by options. # @param [Account] # @param [Hash] options - # @option [Boolean] :including_user Remove the user record as well - # @option [Boolean] :destroy Remove the account record instead of suspending + # @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts + # @option [Boolean] :reserve_username Keep account record + # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads + # @option [Time] :suspended_at Only applicable when :reserve_username is true def call(account, **options) @account = account - @options = options + @options = { reserve_username: true, reserve_email: true }.merge(options) + + if @account.local? && @account.user_unconfirmed_or_pending? + @options[:reserve_email] = false + @options[:reserve_username] = false + @options[:skip_side_effects] = true + end reject_follows! purge_user! @@ -60,27 +71,39 @@ class SuspendAccountService < BaseService def purge_user! return if !@account.local? || @account.user.nil? - if @options[:including_user] - @options[:destroy] = true if !@account.user_confirmed? || @account.user_pending? - @account.user.destroy - else + if @options[:reserve_email] @account.user.disable! @account.user.invites.where(uses: 0).destroy_all + else + @account.user.destroy end end def purge_content! - distribute_delete_actor! if @account.local? && !@options[:skip_distribution] + distribute_delete_actor! if @account.local? && !@options[:skip_side_effects] @account.statuses.reorder(nil).find_in_batches do |statuses| - BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:destroy]) + statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username] + BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects]) + end + + @account.media_attachments.reorder(nil).find_each do |media_attachment| + next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id) + + media_attachment.destroy + end + + @account.polls.reorder(nil).find_each do |poll| + next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id) + + poll.destroy end associations_for_destruction.each do |association_name| destroy_all(@account.public_send(association_name)) end - @account.destroy if @options[:destroy] + @account.destroy unless @options[:reserve_username] end def purge_profile! @@ -88,11 +111,13 @@ class SuspendAccountService < BaseService # there is no point wasting time updating # its values first - return if @options[:destroy] + return unless @options[:reserve_username] @account.silenced_at = nil @account.suspended_at = @options[:suspended_at] || Time.now.utc @account.locked = false + @account.memorial = false + @account.discoverable = false @account.display_name = '' @account.note = '' @account.fields = [] @@ -100,6 +125,7 @@ class SuspendAccountService < BaseService @account.followers_count = 0 @account.following_count = 0 @account.moved_to_account = nil + @account.trust_level = :untrusted @account.avatar.destroy @account.header.destroy @account.save! @@ -135,11 +161,15 @@ class SuspendAccountService < BaseService Account.inboxes - delivery_inboxes end + def reported_status_ids + @reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq + end + def associations_for_destruction - if @options[:destroy] - ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY - else + if @options[:reserve_username] ASSOCIATIONS_ON_SUSPEND + else + ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY end end end diff --git a/app/services/unallow_domain_service.rb b/app/services/unallow_domain_service.rb index d4387c1a1..bd1ad328d 100644 --- a/app/services/unallow_domain_service.rb +++ b/app/services/unallow_domain_service.rb @@ -3,7 +3,7 @@ class UnallowDomainService < BaseService def call(domain_allow) Account.where(domain: domain_allow.domain).find_each do |account| - SuspendAccountService.new.call(account, destroy: true) + SuspendAccountService.new.call(account, reserve_username: false) end domain_allow.destroy diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb index ae8b24d8c..83c815efd 100644 --- a/app/workers/admin/suspension_worker.rb +++ b/app/workers/admin/suspension_worker.rb @@ -6,6 +6,6 @@ class Admin::SuspensionWorker sidekiq_options queue: 'pull' def perform(account_id, remove_user = false) - SuspendAccountService.new.call(Account.find(account_id), including_user: remove_user) + SuspendAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: !remove_user) end end diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb index b16bf2e38..a09a6ab04 100644 --- a/lib/mastodon/accounts_cli.rb +++ b/lib/mastodon/accounts_cli.rb @@ -185,7 +185,7 @@ module Mastodon end say("Deleting user with #{account.statuses_count} statuses, this might take a while...") - SuspendAccountService.new.call(account, including_user: true) + SuspendAccountService.new.call(account, reserve_email: false) say('OK', :green) end @@ -239,7 +239,7 @@ module Mastodon end if [404, 410].include?(code) - SuspendAccountService.new.call(account, destroy: true) unless options[:dry_run] + SuspendAccountService.new.call(account, reserve_username: false) unless options[:dry_run] 1 else # Touch account even during dry run to avoid getting the account into the window again diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb index c612c2d72..8e52de1c3 100644 --- a/lib/mastodon/domains_cli.rb +++ b/lib/mastodon/domains_cli.rb @@ -42,7 +42,7 @@ module Mastodon end processed, = parallelize_with_progress(scope) do |account| - SuspendAccountService.new.call(account, destroy: true) unless options[:dry_run] + SuspendAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run] end DomainBlock.where(domain: domain).destroy_all unless options[:dry_run] diff --git a/spec/controllers/admin/reported_statuses_controller_spec.rb b/spec/controllers/admin/reported_statuses_controller_spec.rb index bd146b795..2a1598123 100644 --- a/spec/controllers/admin/reported_statuses_controller_spec.rb +++ b/spec/controllers/admin/reported_statuses_controller_spec.rb @@ -47,7 +47,7 @@ describe Admin::ReportedStatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true) end end diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb index 6b06343ef..d9690d83f 100644 --- a/spec/controllers/admin/statuses_controller_spec.rb +++ b/spec/controllers/admin/statuses_controller_spec.rb @@ -65,7 +65,7 @@ describe Admin::StatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true) end end diff --git a/spec/models/form/status_batch_spec.rb b/spec/models/form/status_batch_spec.rb index f9c58c90f..68d84a737 100644 --- a/spec/models/form/status_batch_spec.rb +++ b/spec/models/form/status_batch_spec.rb @@ -41,12 +41,12 @@ describe Form::StatusBatch do it 'call RemovalWorker' do form.save - expect(RemovalWorker).to have_received(:perform_async).with(status.id, redraft: false) + expect(RemovalWorker).to have_received(:perform_async).with(status.id, immediate: true) end it 'do not call RemovalWorker' do form.save - expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, redraft: false) + expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, immediate: true) end end end -- cgit From c707ef49d9b13932f4d98c127ec3148a5cdc3479 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 15 Sep 2019 21:08:39 +0200 Subject: Fix 2FA challenge and password challenge for non-database users (#11831) * Fix 2FA challenge not appearing for non-database users Fix #11685 * Fix account deletion not working when using external login Fix #11691 --- app/controllers/auth/sessions_controller.rb | 61 ++++++++++------------- app/controllers/settings/deletes_controller.rb | 25 +++++++--- app/models/form/delete_confirmation.rb | 2 +- app/views/settings/deletes/show.html.haml | 5 +- config/initializers/devise.rb | 7 ++- config/locales/en.yml | 3 +- spec/controllers/auth/sessions_controller_spec.rb | 24 +++------ 7 files changed, 66 insertions(+), 61 deletions(-) (limited to 'app/models/form') diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 7e6dbf19e..3e93b2e68 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -8,8 +8,6 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_functional! - prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] - before_action :set_instance_presenter, only: [:new] before_action :set_body_classes @@ -22,9 +20,22 @@ class Auth::SessionsController < Devise::SessionsController end def create - super do |resource| - remember_me(resource) - flash.delete(:notice) + self.resource = begin + if user_params[:email].blank? && session[:otp_user_id].present? + User.find(session[:otp_user_id]) + else + warden.authenticate!(auth_options) + end + end + + if resource.otp_required_for_login? + if user_params[:otp_attempt].present? && session[:otp_user_id].present? + authenticate_with_two_factor_via_otp(resource) + else + prompt_for_two_factor(resource) + end + else + authenticate_and_respond(resource) end end @@ -37,18 +48,6 @@ class Auth::SessionsController < Devise::SessionsController protected - def find_user - if session[:otp_user_id] - User.find(session[:otp_user_id]) - elsif user_params[:email] - if use_seamless_external_login? && Devise.check_at_sign && user_params[:email].index('@').nil? - User.joins(:account).find_by(accounts: { username: user_params[:email] }) - else - User.find_for_authentication(email: user_params[:email]) - end - end - end - def user_params params.require(:user).permit(:email, :password, :otp_attempt) end @@ -71,32 +70,17 @@ class Auth::SessionsController < Devise::SessionsController super end - def two_factor_enabled? - find_user.try(:otp_required_for_login?) - end - def valid_otp_attempt?(user) user.validate_and_consume_otp!(user_params[:otp_attempt]) || user.invalidate_otp_backup_code!(user_params[:otp_attempt]) - rescue OpenSSL::Cipher::CipherError => _error + rescue OpenSSL::Cipher::CipherError false end - def authenticate_with_two_factor - user = self.resource = find_user - - if user_params[:otp_attempt].present? && session[:otp_user_id] - authenticate_with_two_factor_via_otp(user) - elsif user&.valid_password?(user_params[:password]) - prompt_for_two_factor(user) - end - end - def authenticate_with_two_factor_via_otp(user) if valid_otp_attempt?(user) session.delete(:otp_user_id) - remember_me(user) - sign_in(user) + authenticate_and_respond(user) else flash.now[:alert] = I18n.t('users.invalid_otp_token') prompt_for_two_factor(user) @@ -108,6 +92,13 @@ class Auth::SessionsController < Devise::SessionsController render :two_factor end + def authenticate_and_respond(user) + sign_in(user) + remember_me(user) + + respond_with user, location: after_sign_in_path_for(user) + end + private def set_instance_presenter @@ -120,9 +111,11 @@ class Auth::SessionsController < Devise::SessionsController def home_paths(resource) paths = [about_path] + if single_user_mode? && resource.is_a?(User) paths << short_account_path(username: resource.account) end + paths end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index 97fe4d328..15a59c999 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -14,12 +14,11 @@ class Settings::DeletesController < Settings::BaseController end def destroy - if current_user.valid_password?(delete_params[:password]) - Admin::SuspensionWorker.perform_async(current_user.account_id, true) - sign_out + if challenge_passed? + destroy_account! redirect_to new_user_session_path, notice: I18n.t('deletes.success_msg') else - redirect_to settings_delete_path, alert: I18n.t('deletes.bad_password_msg') + redirect_to settings_delete_path, alert: I18n.t('deletes.challenge_not_passed') end end @@ -29,11 +28,25 @@ class Settings::DeletesController < Settings::BaseController redirect_to root_path unless Setting.open_deletion end - def delete_params - params.require(:form_delete_confirmation).permit(:password) + def resource_params + params.require(:form_delete_confirmation).permit(:password, :username) end def require_not_suspended! forbidden if current_account.suspended? end + + def challenge_passed? + if current_user.encrypted_password.blank? + current_account.username == resource_params[:username] + else + current_user.valid_password?(resource_params[:password]) + end + end + + def destroy_account! + current_account.suspend! + Admin::SuspensionWorker.perform_async(current_user.account_id, true) + sign_out + end end diff --git a/app/models/form/delete_confirmation.rb b/app/models/form/delete_confirmation.rb index 0884a09b8..99d04b331 100644 --- a/app/models/form/delete_confirmation.rb +++ b/app/models/form/delete_confirmation.rb @@ -3,5 +3,5 @@ class Form::DeleteConfirmation include ActiveModel::Model - attr_accessor :password + attr_accessor :password, :username end diff --git a/app/views/settings/deletes/show.html.haml b/app/views/settings/deletes/show.html.haml index 6e2ff31c5..08792e0af 100644 --- a/app/views/settings/deletes/show.html.haml +++ b/app/views/settings/deletes/show.html.haml @@ -20,7 +20,10 @@ %hr.spacer/ - = f.input :password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_password') + - if current_user.encrypted_password.present? + = f.input :password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_password') + - else + = f.input :username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_username') .actions = f.button :button, t('deletes.proceed'), type: :submit, class: 'negative' diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index cd9bacf68..311583820 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -71,10 +71,13 @@ end Devise.setup do |config| config.warden do |manager| + manager.default_strategies(scope: :user).unshift :database_authenticatable manager.default_strategies(scope: :user).unshift :ldap_authenticatable if Devise.ldap_authentication manager.default_strategies(scope: :user).unshift :pam_authenticatable if Devise.pam_authentication - manager.default_strategies(scope: :user).unshift :two_factor_authenticatable - manager.default_strategies(scope: :user).unshift :two_factor_backupable + + # We handle 2FA in our own sessions controller so this gets in the way + manager.default_strategies(scope: :user).delete :two_factor_backupable + manager.default_strategies(scope: :user).delete :two_factor_authenticatable end # The secret key used by Devise. Devise uses this key to generate diff --git a/config/locales/en.yml b/config/locales/en.yml index 0a5ca31c1..8c9fe89f8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -632,8 +632,9 @@ en: x_months: "%{count}mo" x_seconds: "%{count}s" deletes: - bad_password_msg: The password you entered was incorrect + challenge_not_passed: The information you entered was not correct confirm_password: Enter your current password to verify your identity + confirm_username: Enter your username to confirm the procedure proceed: Delete account success_msg: Your account was successfully deleted warning: diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index 87ef4f2bb..7ed5edde0 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -5,11 +5,11 @@ require 'rails_helper' RSpec.describe Auth::SessionsController, type: :controller do render_views - describe 'GET #new' do - before do - request.env['devise.mapping'] = Devise.mappings[:user] - end + before do + request.env['devise.mapping'] = Devise.mappings[:user] + end + describe 'GET #new' do it 'returns http success' do get :new expect(response).to have_http_status(200) @@ -19,10 +19,6 @@ RSpec.describe Auth::SessionsController, type: :controller do describe 'DELETE #destroy' do let(:user) { Fabricate(:user) } - before do - request.env['devise.mapping'] = Devise.mappings[:user] - end - context 'with a regular user' do it 'redirects to home after sign out' do sign_in(user, scope: :user) @@ -51,10 +47,6 @@ RSpec.describe Auth::SessionsController, type: :controller do end describe 'POST #create' do - before do - request.env['devise.mapping'] = Devise.mappings[:user] - end - context 'using PAM authentication', if: ENV['PAM_ENABLED'] == 'true' do context 'using a valid password' do before do @@ -191,11 +183,11 @@ RSpec.describe Auth::SessionsController, type: :controller do end context 'using two-factor authentication' do - let(:user) do - Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', - otp_required_for_login: true, otp_secret: User.generate_otp_secret(32)) + let!(:user) do + Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32)) end - let(:recovery_codes) do + + let!(:recovery_codes) do codes = user.generate_otp_backup_codes! user.save return codes -- cgit From a4b60e9ba4874b9ab427bec41d8b2cd252ec4782 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 18 Sep 2019 02:48:40 +0200 Subject: Fix TOTP codes not being filtered from logs during enabling/disabling (#11877) Not a serious issue because they are meaningless past single use --- .../settings/two_factor_authentication/confirmations_controller.rb | 4 ++-- app/controllers/settings/two_factor_authentications_controller.rb | 6 +++--- app/models/form/two_factor_confirmation.rb | 2 +- .../settings/two_factor_authentication/confirmations/new.html.haml | 2 +- app/views/settings/two_factor_authentications/show.html.haml | 2 +- .../two_factor_authentication/confirmations_controller_spec.rb | 6 +++--- .../settings/two_factor_authentications_controller_spec.rb | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) (limited to 'app/models/form') diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index 3145e092d..46c90bf74 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -15,7 +15,7 @@ module Settings end def create - if current_user.validate_and_consume_otp!(confirmation_params[:code]) + if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success') current_user.otp_required_for_login = true @@ -33,7 +33,7 @@ module Settings private def confirmation_params - params.require(:form_two_factor_confirmation).permit(:code) + params.require(:form_two_factor_confirmation).permit(:otp_attempt) end def prepare_two_factor_form diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb index 6904076e4..c93b17577 100644 --- a/app/controllers/settings/two_factor_authentications_controller.rb +++ b/app/controllers/settings/two_factor_authentications_controller.rb @@ -34,7 +34,7 @@ module Settings private def confirmation_params - params.require(:form_two_factor_confirmation).permit(:code) + params.require(:form_two_factor_confirmation).permit(:otp_attempt) end def verify_otp_required @@ -42,8 +42,8 @@ module Settings end def acceptable_code? - current_user.validate_and_consume_otp!(confirmation_params[:code]) || - current_user.invalidate_otp_backup_code!(confirmation_params[:code]) + current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) || + current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt]) end end end diff --git a/app/models/form/two_factor_confirmation.rb b/app/models/form/two_factor_confirmation.rb index b8cf76d05..27ada6533 100644 --- a/app/models/form/two_factor_confirmation.rb +++ b/app/models/form/two_factor_confirmation.rb @@ -3,5 +3,5 @@ class Form::TwoFactorConfirmation include ActiveModel::Model - attr_accessor :code + attr_accessor :otp_attempt end diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml index e64155299..86cf1f695 100644 --- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml +++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml @@ -12,7 +12,7 @@ %samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ') .fields-group - = f.input :code, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true + = f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true .actions = f.button :button, t('two_factor_authentication.enable'), type: :submit diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml index 259bcd1ef..93509e022 100644 --- a/app/views/settings/two_factor_authentications/show.html.haml +++ b/app/views/settings/two_factor_authentications/show.html.haml @@ -10,7 +10,7 @@ %hr/ = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f| - = f.input :code, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true + = f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true .actions = f.button :button, t('two_factor_authentication.disable'), type: :submit diff --git a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb index 2222a7559..2e5a9325c 100644 --- a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb @@ -68,7 +68,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do true end - post :create, params: { form_two_factor_confirmation: { code: '123456' } } + post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } } expect(assigns(:recovery_codes)).to eq otp_backup_codes expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled' @@ -85,7 +85,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do false end - post :create, params: { form_two_factor_confirmation: { code: '123456' } } + post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } } end it 'renders the new view' do @@ -99,7 +99,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do context 'when not signed in' do it 'redirects if not signed in' do - post :create, params: { form_two_factor_confirmation: { code: '123456' } } + post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } } expect(response).to redirect_to('/auth/sign_in') end end diff --git a/spec/controllers/settings/two_factor_authentications_controller_spec.rb b/spec/controllers/settings/two_factor_authentications_controller_spec.rb index f7c628756..922231ded 100644 --- a/spec/controllers/settings/two_factor_authentications_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentications_controller_spec.rb @@ -91,7 +91,7 @@ describe Settings::TwoFactorAuthenticationsController do true end - post :destroy, params: { form_two_factor_confirmation: { code: '123456' } } + post :destroy, params: { form_two_factor_confirmation: { otp_attempt: '123456' } } expect(response).to redirect_to(settings_two_factor_authentication_path) user.reload @@ -105,7 +105,7 @@ describe Settings::TwoFactorAuthenticationsController do false end - post :destroy, params: { form_two_factor_confirmation: { code: '057772' } } + post :destroy, params: { form_two_factor_confirmation: { otp_attempt: '057772' } } user.reload expect(user.otp_required_for_login).to eq(true) -- cgit From e1066cd4319a220d5be16e51ffaf5236a2f6e866 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 18 Sep 2019 16:37:27 +0200 Subject: Add password challenge to 2FA settings, e-mail notifications (#11878) Fix #3961 --- .../admin/two_factor_authentications_controller.rb | 1 + app/controllers/auth/challenges_controller.rb | 22 ++++ app/controllers/auth/sessions_controller.rb | 1 + app/controllers/concerns/challengable_concern.rb | 65 ++++++++++++ .../confirmations_controller.rb | 5 + .../recovery_codes_controller.rb | 6 ++ .../two_factor_authentications_controller.rb | 4 + app/javascript/styles/mastodon/admin.scss | 43 ++++---- app/javascript/styles/mastodon/forms.scss | 4 + app/mailers/user_mailer.rb | 33 ++++++ app/models/form/challenge.rb | 8 ++ app/models/user.rb | 9 +- app/views/auth/challenges/new.html.haml | 15 +++ app/views/auth/shared/_links.html.haml | 2 +- .../two_factor_authentications/show.html.haml | 38 +++---- .../user_mailer/two_factor_disabled.html.haml | 43 ++++++++ app/views/user_mailer/two_factor_disabled.text.erb | 7 ++ app/views/user_mailer/two_factor_enabled.html.haml | 43 ++++++++ app/views/user_mailer/two_factor_enabled.text.erb | 7 ++ .../two_factor_recovery_codes_changed.html.haml | 43 ++++++++ .../two_factor_recovery_codes_changed.text.erb | 7 ++ config/locales/devise.en.yml | 12 +++ config/locales/en.yml | 5 + config/locales/simple_form.en.yml | 2 + config/routes.rb | 1 + .../controllers/auth/challenges_controller_spec.rb | 46 +++++++++ spec/controllers/auth/sessions_controller_spec.rb | 2 +- .../concerns/challengable_concern_spec.rb | 114 +++++++++++++++++++++ .../confirmations_controller_spec.rb | 10 +- .../recovery_codes_controller_spec.rb | 2 +- .../two_factor_authentications_controller_spec.rb | 2 +- spec/mailers/previews/user_mailer_preview.rb | 15 +++ 32 files changed, 567 insertions(+), 50 deletions(-) create mode 100644 app/controllers/auth/challenges_controller.rb create mode 100644 app/controllers/concerns/challengable_concern.rb create mode 100644 app/models/form/challenge.rb create mode 100644 app/views/auth/challenges/new.html.haml create mode 100644 app/views/user_mailer/two_factor_disabled.html.haml create mode 100644 app/views/user_mailer/two_factor_disabled.text.erb create mode 100644 app/views/user_mailer/two_factor_enabled.html.haml create mode 100644 app/views/user_mailer/two_factor_enabled.text.erb create mode 100644 app/views/user_mailer/two_factor_recovery_codes_changed.html.haml create mode 100644 app/views/user_mailer/two_factor_recovery_codes_changed.text.erb create mode 100644 spec/controllers/auth/challenges_controller_spec.rb create mode 100644 spec/controllers/concerns/challengable_concern_spec.rb (limited to 'app/models/form') diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb index 2577a4b17..0652c3a7a 100644 --- a/app/controllers/admin/two_factor_authentications_controller.rb +++ b/app/controllers/admin/two_factor_authentications_controller.rb @@ -8,6 +8,7 @@ module Admin authorize @user, :disable_2fa? @user.disable_two_factor! log_action :disable_2fa, @user + UserMailer.two_factor_disabled(@user).deliver_later! redirect_to admin_accounts_path end diff --git a/app/controllers/auth/challenges_controller.rb b/app/controllers/auth/challenges_controller.rb new file mode 100644 index 000000000..060944240 --- /dev/null +++ b/app/controllers/auth/challenges_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Auth::ChallengesController < ApplicationController + include ChallengableConcern + + layout 'auth' + + before_action :authenticate_user! + + skip_before_action :require_functional! + + def create + if challenge_passed? + session[:challenge_passed_at] = Time.now.utc + redirect_to challenge_params[:return_to] + else + @challenge = Form::Challenge.new(return_to: challenge_params[:return_to]) + flash.now[:alert] = I18n.t('challenge.invalid_password') + render_challenge + end + end +end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 3e93b2e68..b3113bbef 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -42,6 +42,7 @@ class Auth::SessionsController < Devise::SessionsController def destroy tmp_stored_location = stored_location_for(:user) super + session.delete(:challenge_passed_at) flash.delete(:notice) store_location_for(:user, tmp_stored_location) if continue_after? end diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb new file mode 100644 index 000000000..b29d90b3c --- /dev/null +++ b/app/controllers/concerns/challengable_concern.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# This concern is inspired by "sudo mode" on GitHub. It +# is a way to re-authenticate a user before allowing them +# to see or perform an action. +# +# Add `before_action :require_challenge!` to actions you +# want to protect. +# +# The user will be shown a page to enter the challenge (which +# is either the password, or just the username when no +# password exists). Upon passing, there is a grace period +# during which no challenge will be asked from the user. +# +# Accessing challenge-protected resources during the grace +# period will refresh the grace period. +module ChallengableConcern + extend ActiveSupport::Concern + + CHALLENGE_TIMEOUT = 1.hour.freeze + + def require_challenge! + return if skip_challenge? + + if challenge_passed_recently? + session[:challenge_passed_at] = Time.now.utc + return + end + + @challenge = Form::Challenge.new(return_to: request.url) + + if params.key?(:form_challenge) + if challenge_passed? + session[:challenge_passed_at] = Time.now.utc + return + else + flash.now[:alert] = I18n.t('challenge.invalid_password') + render_challenge + end + else + render_challenge + end + end + + def render_challenge + @body_classes = 'lighter' + render template: 'auth/challenges/new', layout: 'auth' + end + + def challenge_passed? + current_user.valid_password?(challenge_params[:current_password]) + end + + def skip_challenge? + current_user.encrypted_password.blank? + end + + def challenge_passed_recently? + session[:challenge_passed_at].present? && session[:challenge_passed_at] >= CHALLENGE_TIMEOUT.ago + end + + def challenge_params + params.require(:form_challenge).permit(:current_password, :return_to) + end +end diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index 46c90bf74..ef4df3339 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -3,9 +3,12 @@ module Settings module TwoFactorAuthentication class ConfirmationsController < BaseController + include ChallengableConcern + layout 'admin' before_action :authenticate_user! + before_action :require_challenge! before_action :ensure_otp_secret skip_before_action :require_functional! @@ -22,6 +25,8 @@ module Settings @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! + UserMailer.two_factor_enabled(current_user).deliver_later! + render 'settings/two_factor_authentication/recovery_codes/index' else flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb index 09a759860..0c4f5bff7 100644 --- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb +++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb @@ -3,16 +3,22 @@ module Settings module TwoFactorAuthentication class RecoveryCodesController < BaseController + include ChallengableConcern + layout 'admin' before_action :authenticate_user! + before_action :require_challenge!, on: :create skip_before_action :require_functional! def create @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! + + UserMailer.two_factor_recovery_codes_changed(current_user).deliver_later! flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated') + render :index end end diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb index c93b17577..9118a7933 100644 --- a/app/controllers/settings/two_factor_authentications_controller.rb +++ b/app/controllers/settings/two_factor_authentications_controller.rb @@ -2,10 +2,13 @@ module Settings class TwoFactorAuthenticationsController < BaseController + include ChallengableConcern + layout 'admin' before_action :authenticate_user! before_action :verify_otp_required, only: [:create] + before_action :require_challenge!, only: [:create] skip_before_action :require_functional! @@ -23,6 +26,7 @@ module Settings if acceptable_code? current_user.otp_required_for_login = false current_user.save! + UserMailer.two_factor_disabled(current_user).deliver_later! redirect_to settings_two_factor_authentication_path else flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 5d4fe4ef8..074eee2cd 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -233,32 +233,35 @@ hr.spacer { height: 1px; } -.muted-hint { - color: $darker-text-color; +body, +.admin-wrapper .content { + .muted-hint { + color: $darker-text-color; - a { - color: $highlight-text-color; + a { + color: $highlight-text-color; + } } -} -.positive-hint { - color: $valid-value-color; - font-weight: 500; -} + .positive-hint { + color: $valid-value-color; + font-weight: 500; + } -.negative-hint { - color: $error-value-color; - font-weight: 500; -} + .negative-hint { + color: $error-value-color; + font-weight: 500; + } -.neutral-hint { - color: $dark-text-color; - font-weight: 500; -} + .neutral-hint { + color: $dark-text-color; + font-weight: 500; + } -.warning-hint { - color: $gold-star; - font-weight: 500; + .warning-hint { + color: $gold-star; + font-weight: 500; + } } .filters { diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 16352340b..80ef8797d 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -254,6 +254,10 @@ code { &-6 { max-width: 50%; } + + .actions { + margin-top: 27px; + } } .fields-group:last-child, diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index b41004acc..6b81f6873 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -57,6 +57,39 @@ class UserMailer < Devise::Mailer end end + def two_factor_enabled(user, **) + @resource = user + @instance = Rails.configuration.x.local_domain + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject') + end + end + + def two_factor_disabled(user, **) + @resource = user + @instance = Rails.configuration.x.local_domain + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject') + end + end + + def two_factor_recovery_codes_changed(user, **) + @resource = user + @instance = Rails.configuration.x.local_domain + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject') + end + end + def welcome(user) @resource = user @instance = Rails.configuration.x.local_domain diff --git a/app/models/form/challenge.rb b/app/models/form/challenge.rb new file mode 100644 index 000000000..40c99649c --- /dev/null +++ b/app/models/form/challenge.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Form::Challenge + include ActiveModel::Model + + attr_accessor :current_password, :current_username, + :return_to +end diff --git a/app/models/user.rb b/app/models/user.rb index 78b82a68f..b48455802 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -264,17 +264,20 @@ class User < ApplicationRecord end def password_required? - return false if Devise.pam_authentication || Devise.ldap_authentication + return false if external? + super end def send_reset_password_instructions - return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication) + return false if encrypted_password.blank? + super end def reset_password!(new_password, new_password_confirmation) - return false if encrypted_password.blank? && (Devise.pam_authentication || Devise.ldap_authentication) + return false if encrypted_password.blank? + super end diff --git a/app/views/auth/challenges/new.html.haml b/app/views/auth/challenges/new.html.haml new file mode 100644 index 000000000..9aef2c35d --- /dev/null +++ b/app/views/auth/challenges/new.html.haml @@ -0,0 +1,15 @@ +- content_for :page_title do + = t('challenge.prompt') + += simple_form_for @challenge, url: request.get? ? auth_challenge_path : '' do |f| + = f.input :return_to, as: :hidden + + .field-group + = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'off', :autofocus => true }, label: t('challenge.prompt'), required: true + + .actions + = f.button :button, t('challenge.confirm'), type: :submit + + %p.hint.subtle-hint= t('challenge.hint_html') + +.form-footer= render 'auth/shared/links' diff --git a/app/views/auth/shared/_links.html.haml b/app/views/auth/shared/_links.html.haml index e6c3f7cca..66ed5b93f 100644 --- a/app/views/auth/shared/_links.html.haml +++ b/app/views/auth/shared/_links.html.haml @@ -11,7 +11,7 @@ - if controller_name != 'passwords' && controller_name != 'registrations' %li= link_to t('auth.forgot_password'), new_user_password_path - - if controller_name != 'confirmations' + - if controller_name != 'confirmations' && (!user_signed_in? || !current_user.confirmed? || current_user.unconfirmed_email.present?) %li= link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path - if user_signed_in? && controller_name != 'setup' diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml index 93509e022..f1eecd000 100644 --- a/app/views/settings/two_factor_authentications/show.html.haml +++ b/app/views/settings/two_factor_authentications/show.html.haml @@ -2,33 +2,35 @@ = t('settings.two_factor_authentication') - if current_user.otp_required_for_login - %p.positive-hint - = fa_icon 'check' - = ' ' - = t 'two_factor_authentication.enabled' + %p.hint + %span.positive-hint + = fa_icon 'check' + = ' ' + = t 'two_factor_authentication.enabled' - %hr/ + %hr.spacer/ = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f| - = f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true + .fields-group + = f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true .actions - = f.button :button, t('two_factor_authentication.disable'), type: :submit + = f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative' - %hr/ + %hr.spacer/ - %h6= t('two_factor_authentication.recovery_codes') - %p.muted-hint - = t('two_factor_authentication.lost_recovery_codes') - = link_to t('two_factor_authentication.generate_recovery_codes'), - settings_two_factor_authentication_recovery_codes_path, - data: { method: :post } + %h3= t('two_factor_authentication.recovery_codes') + %p.muted-hint= t('two_factor_authentication.lost_recovery_codes') + + %hr.spacer/ + + .simple_form + = link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button' - else .simple_form %p.hint= t('two_factor_authentication.description_html') - = link_to t('two_factor_authentication.setup'), - settings_two_factor_authentication_path, - data: { method: :post }, - class: 'block-button' + %hr.spacer/ + + = link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button' diff --git a/app/views/user_mailer/two_factor_disabled.html.haml b/app/views/user_mailer/two_factor_disabled.html.haml new file mode 100644 index 000000000..651c6f940 --- /dev/null +++ b/app/views/user_mailer/two_factor_disabled.html.haml @@ -0,0 +1,43 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: '' + + %h1= t 'devise.mailer.two_factor_disabled.title' + %p.lead= t 'devise.mailer.two_factor_disabled.explanation' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t('settings.account_settings') diff --git a/app/views/user_mailer/two_factor_disabled.text.erb b/app/views/user_mailer/two_factor_disabled.text.erb new file mode 100644 index 000000000..73be1ddc2 --- /dev/null +++ b/app/views/user_mailer/two_factor_disabled.text.erb @@ -0,0 +1,7 @@ +<%= t 'devise.mailer.two_factor_disabled.title' %> + +=== + +<%= t 'devise.mailer.two_factor_disabled.explanation' %> + +=> <%= edit_user_registration_url %> diff --git a/app/views/user_mailer/two_factor_enabled.html.haml b/app/views/user_mailer/two_factor_enabled.html.haml new file mode 100644 index 000000000..fc31bd979 --- /dev/null +++ b/app/views/user_mailer/two_factor_enabled.html.haml @@ -0,0 +1,43 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: '' + + %h1= t 'devise.mailer.two_factor_enabled.title' + %p.lead= t 'devise.mailer.two_factor_enabled.explanation' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t('settings.account_settings') diff --git a/app/views/user_mailer/two_factor_enabled.text.erb b/app/views/user_mailer/two_factor_enabled.text.erb new file mode 100644 index 000000000..4319dddbf --- /dev/null +++ b/app/views/user_mailer/two_factor_enabled.text.erb @@ -0,0 +1,7 @@ +<%= t 'devise.mailer.two_factor_enabled.title' %> + +=== + +<%= t 'devise.mailer.two_factor_enabled.explanation' %> + +=> <%= edit_user_registration_url %> diff --git a/app/views/user_mailer/two_factor_recovery_codes_changed.html.haml b/app/views/user_mailer/two_factor_recovery_codes_changed.html.haml new file mode 100644 index 000000000..833708868 --- /dev/null +++ b/app/views/user_mailer/two_factor_recovery_codes_changed.html.haml @@ -0,0 +1,43 @@ +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.hero + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center.padded + %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td + = image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: '' + + %h1= t 'devise.mailer.two_factor_recovery_codes_changed.title' + %p.lead= t 'devise.mailer.two_factor_recovery_codes_changed.explanation' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell.content-start + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.button-cell + %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.button-primary + = link_to edit_user_registration_url do + %span= t('settings.account_settings') diff --git a/app/views/user_mailer/two_factor_recovery_codes_changed.text.erb b/app/views/user_mailer/two_factor_recovery_codes_changed.text.erb new file mode 100644 index 000000000..6ed12fc08 --- /dev/null +++ b/app/views/user_mailer/two_factor_recovery_codes_changed.text.erb @@ -0,0 +1,7 @@ +<%= t 'devise.mailer.two_factor_recovery_codes_changed.title' %> + +=== + +<%= t 'devise.mailer.two_factor_recovery_codes_changed.explanation' %> + +=> <%= edit_user_registration_url %> diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index 5defa6624..726d2426a 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -46,6 +46,18 @@ en: extra: If you didn't request this, please ignore this email. Your password won't change until you access the link above and create a new one. subject: 'Mastodon: Reset password instructions' title: Password reset + two_factor_disabled: + explanation: Two-factor authentication for your account has been disabled. Login is now possible using only e-mail address and password. + subject: 'Mastodon: Two-factor authentication disabled' + title: 2FA disabled + two_factor_enabled: + explanation: Two-factor authentication has been enabled for your account. A token generated by the paired TOTP app will be required for login. + subject: 'Mastodon: Two-factor authentication enabled' + title: 2FA enabled + two_factor_recovery_codes_changed: + explanation: The previous recovery codes have been invalidated and new ones generated. + subject: 'Mastodon: Two-factor recovery codes re-generated' + title: 2FA recovery codes changed unlock_instructions: subject: 'Mastodon: Unlock instructions' omniauth_callbacks: diff --git a/config/locales/en.yml b/config/locales/en.yml index f05fdd48b..da06b0e51 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -621,6 +621,11 @@ en: return: Show the user's profile web: Go to web title: Follow %{acct} + challenge: + confirm: Continue + hint_html: "Tip: We won't ask you for your password again for the next hour." + invalid_password: Invalid password + prompt: Confirm password to continue datetime: distance_in_words: about_x_hours: "%{count}h" diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c542377a9..c9ffcfc13 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -43,6 +43,8 @@ en: domain: This domain will be able to fetch data from this server and incoming data from it will be processed and stored featured_tag: name: 'You might want to use one of these:' + form_challenge: + current_password: You are entering a secure area imports: data: CSV file exported from another Mastodon server invite_request: diff --git a/config/routes.rb b/config/routes.rb index a4dee2842..9ad1ea65d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,7 @@ Rails.application.routes.draw do namespace :auth do resource :setup, only: [:show, :update], controller: :setup + resource :challenge, only: [:create], controller: :challenges end end diff --git a/spec/controllers/auth/challenges_controller_spec.rb b/spec/controllers/auth/challenges_controller_spec.rb new file mode 100644 index 000000000..2a6ca301e --- /dev/null +++ b/spec/controllers/auth/challenges_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Auth::ChallengesController, type: :controller do + render_views + + let(:password) { 'foobar12345' } + let(:user) { Fabricate(:user, password: password) } + + before do + sign_in user + end + + describe 'POST #create' do + let(:return_to) { edit_user_registration_path } + + context 'with correct password' do + before { post :create, params: { form_challenge: { return_to: return_to, current_password: password } } } + + it 'redirects back' do + expect(response).to redirect_to(return_to) + end + + it 'sets session' do + expect(session[:challenge_passed_at]).to_not be_nil + end + end + + context 'with incorrect password' do + before { post :create, params: { form_challenge: { return_to: return_to, current_password: 'hhfggjjd562' } } } + + it 'renders challenge' do + expect(response).to render_template('auth/challenges/new') + end + + it 'displays error' do + expect(response.body).to include 'Invalid password' + end + + it 'does not set session' do + expect(session[:challenge_passed_at]).to be_nil + end + end + end +end diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index 7ed5edde0..1950c173a 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -80,7 +80,7 @@ RSpec.describe Auth::SessionsController, type: :controller do let(:user) do account = Fabricate.build(:account, username: 'pam_user1') account.save!(validate: false) - user = Fabricate(:user, email: 'pam@example.com', password: nil, account: account) + user = Fabricate(:user, email: 'pam@example.com', password: nil, account: account, external: true) user end diff --git a/spec/controllers/concerns/challengable_concern_spec.rb b/spec/controllers/concerns/challengable_concern_spec.rb new file mode 100644 index 000000000..4db3b740d --- /dev/null +++ b/spec/controllers/concerns/challengable_concern_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ChallengableConcern, type: :controller do + controller(ApplicationController) do + include ChallengableConcern + + before_action :require_challenge! + + def foo + render plain: 'foo' + end + + def bar + render plain: 'bar' + end + end + + before do + routes.draw do + get 'foo' => 'anonymous#foo' + post 'bar' => 'anonymous#bar' + end + end + + context 'with a no-password user' do + let(:user) { Fabricate(:user, external: true, password: nil) } + + before do + sign_in user + end + + context 'for GET requests' do + before { get :foo } + + it 'does not ask for password' do + expect(response.body).to eq 'foo' + end + end + + context 'for POST requests' do + before { post :bar } + + it 'does not ask for password' do + expect(response.body).to eq 'bar' + end + end + end + + context 'with recent challenge in session' do + let(:password) { 'foobar12345' } + let(:user) { Fabricate(:user, password: password) } + + before do + sign_in user + end + + context 'for GET requests' do + before { get :foo, session: { challenge_passed_at: Time.now.utc } } + + it 'does not ask for password' do + expect(response.body).to eq 'foo' + end + end + + context 'for POST requests' do + before { post :bar, session: { challenge_passed_at: Time.now.utc } } + + it 'does not ask for password' do + expect(response.body).to eq 'bar' + end + end + end + + context 'with a password user' do + let(:password) { 'foobar12345' } + let(:user) { Fabricate(:user, password: password) } + + before do + sign_in user + end + + context 'for GET requests' do + before { get :foo } + + it 'renders challenge' do + expect(response).to render_template('auth/challenges/new') + end + + # See Auth::ChallengesControllerSpec + end + + context 'for POST requests' do + before { post :bar } + + it 'renders challenge' do + expect(response).to render_template('auth/challenges/new') + end + + it 'accepts correct password' do + post :bar, params: { form_challenge: { current_password: password } } + expect(response.body).to eq 'bar' + expect(session[:challenge_passed_at]).to_not be_nil + end + + it 'rejects wrong password' do + post :bar, params: { form_challenge: { current_password: 'dddfff888123' } } + expect(response.body).to render_template('auth/challenges/new') + expect(session[:challenge_passed_at]).to be_nil + end + end + end +end diff --git a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb index 2e5a9325c..336f13127 100644 --- a/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb @@ -24,7 +24,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do context 'when signed in' do subject do sign_in user, scope: :user - get :new + get :new, session: { challenge_passed_at: Time.now.utc } end include_examples 'renders :new' @@ -37,7 +37,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do it 'redirects if user do not have otp_secret' do sign_in user_without_otp_secret, scope: :user - get :new + get :new, session: { challenge_passed_at: Time.now.utc } expect(response).to redirect_to('/settings/two_factor_authentication') end end @@ -50,7 +50,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do describe 'when form_two_factor_confirmation parameter is not provided' do it 'raises ActionController::ParameterMissing' do - post :create, params: {} + post :create, params: {}, session: { challenge_passed_at: Time.now.utc } expect(response).to have_http_status(400) end end @@ -68,7 +68,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do true end - post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } } + post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc } expect(assigns(:recovery_codes)).to eq otp_backup_codes expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled' @@ -85,7 +85,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do false end - post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } } + post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc } end it 'renders the new view' do diff --git a/spec/controllers/settings/two_factor_authentication/recovery_codes_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/recovery_codes_controller_spec.rb index c04760e53..630cec428 100644 --- a/spec/controllers/settings/two_factor_authentication/recovery_codes_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentication/recovery_codes_controller_spec.rb @@ -15,7 +15,7 @@ describe Settings::TwoFactorAuthentication::RecoveryCodesController do end sign_in user, scope: :user - post :create + post :create, session: { challenge_passed_at: Time.now.utc } expect(assigns(:recovery_codes)).to eq otp_backup_codes expect(flash[:notice]).to eq 'Recovery codes successfully regenerated' diff --git a/spec/controllers/settings/two_factor_authentications_controller_spec.rb b/spec/controllers/settings/two_factor_authentications_controller_spec.rb index 922231ded..9df9763fd 100644 --- a/spec/controllers/settings/two_factor_authentications_controller_spec.rb +++ b/spec/controllers/settings/two_factor_authentications_controller_spec.rb @@ -58,7 +58,7 @@ describe Settings::TwoFactorAuthenticationsController do describe 'when creation succeeds' do it 'updates user secret' do before = user.otp_secret - post :create + post :create, session: { challenge_passed_at: Time.now.utc } expect(user.reload.otp_secret).not_to eq(before) expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path) diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index ead3b3baa..464f177d0 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -18,6 +18,21 @@ class UserMailerPreview < ActionMailer::Preview UserMailer.password_change(User.first) end + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/two_factor_disabled + def two_factor_disabled + UserMailer.two_factor_disabled(User.first) + end + + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/two_factor_enabled + def two_factor_enabled + UserMailer.two_factor_enabled(User.first) + end + + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/two_factor_recovery_codes_changed + def two_factor_recovery_codes_changed + UserMailer.two_factor_recovery_codes_changed(User.first) + end + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/reconfirmation_instructions def reconfirmation_instructions user = User.first -- cgit From 3ed94dcc1acf73f1d0d1ab43567b88ee953f57c9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 19 Sep 2019 20:58:19 +0200 Subject: Add account migration UI (#11846) Fix #10736 - Change data export to be available for non-functional accounts - Change non-functional accounts to include redirecting accounts --- .../concerns/export_controller_concern.rb | 7 ++ app/controllers/settings/aliases_controller.rb | 42 +++++++++++ app/controllers/settings/exports_controller.rb | 7 ++ app/controllers/settings/migrations_controller.rb | 48 ++++++++++--- app/helpers/settings_helper.rb | 8 +++ app/models/account_alias.rb | 41 +++++++++++ app/models/account_migration.rb | 74 +++++++++++++++++++ app/models/concerns/account_associations.rb | 2 + app/models/form/migration.rb | 25 ------- app/models/remote_follow.rb | 2 +- app/models/user.rb | 2 +- app/serializers/activitypub/move_serializer.rb | 26 +++++++ app/views/auth/registrations/_status.html.haml | 30 ++++---- app/views/auth/registrations/edit.html.haml | 2 +- app/views/settings/aliases/index.html.haml | 29 ++++++++ app/views/settings/exports/show.html.haml | 4 ++ app/views/settings/migrations/show.html.haml | 84 +++++++++++++++++++--- app/views/settings/profiles/show.html.haml | 5 ++ .../activitypub/move_distribution_worker.rb | 32 +++++++++ config/locales/en.yml | 38 ++++++++-- config/locales/simple_form.en.yml | 10 +++ config/navigation.rb | 8 +-- config/routes.rb | 8 ++- .../20190914202517_create_account_migrations.rb | 12 ++++ .../20190915194355_create_account_aliases.rb | 11 +++ db/schema.rb | 23 ++++++ .../settings/migrations_controller_spec.rb | 14 ++-- spec/fabricators/account_alias_fabricator.rb | 5 ++ spec/fabricators/account_migration_fabricator.rb | 6 ++ spec/models/account_alias_spec.rb | 5 ++ spec/models/account_migration_spec.rb | 5 ++ 31 files changed, 542 insertions(+), 73 deletions(-) create mode 100644 app/controllers/settings/aliases_controller.rb create mode 100644 app/models/account_alias.rb create mode 100644 app/models/account_migration.rb delete mode 100644 app/models/form/migration.rb create mode 100644 app/serializers/activitypub/move_serializer.rb create mode 100644 app/views/settings/aliases/index.html.haml create mode 100644 app/workers/activitypub/move_distribution_worker.rb create mode 100644 db/migrate/20190914202517_create_account_migrations.rb create mode 100644 db/migrate/20190915194355_create_account_aliases.rb create mode 100644 spec/fabricators/account_alias_fabricator.rb create mode 100644 spec/fabricators/account_migration_fabricator.rb create mode 100644 spec/models/account_alias_spec.rb create mode 100644 spec/models/account_migration_spec.rb (limited to 'app/models/form') diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb index e20b71a30..bfe990c82 100644 --- a/app/controllers/concerns/export_controller_concern.rb +++ b/app/controllers/concerns/export_controller_concern.rb @@ -5,7 +5,10 @@ module ExportControllerConcern included do before_action :authenticate_user! + before_action :require_not_suspended! before_action :load_export + + skip_before_action :require_functional! end private @@ -27,4 +30,8 @@ module ExportControllerConcern def export_filename "#{controller_name}.csv" end + + def require_not_suspended! + forbidden if current_account.suspended? + end end diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb new file mode 100644 index 000000000..2b675f065 --- /dev/null +++ b/app/controllers/settings/aliases_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Settings::AliasesController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :set_aliases, except: :destroy + before_action :set_alias, only: :destroy + + def index + @alias = current_account.aliases.build + end + + def create + @alias = current_account.aliases.build(resource_params) + + if @alias.save + redirect_to settings_aliases_path, notice: I18n.t('aliases.created_msg') + else + render :show + end + end + + def destroy + @alias.destroy! + redirect_to settings_aliases_path, notice: I18n.t('aliases.deleted_msg') + end + + private + + def resource_params + params.require(:account_alias).permit(:acct) + end + + def set_alias + @alias = current_account.aliases.find(params[:id]) + end + + def set_aliases + @aliases = current_account.aliases.order(id: :desc).reject(&:new_record?) + end +end diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index 3012fbf77..0e93d07a9 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -6,6 +6,9 @@ class Settings::ExportsController < Settings::BaseController layout 'admin' before_action :authenticate_user! + before_action :require_not_suspended! + + skip_before_action :require_functional! def show @export = Export.new(current_account) @@ -34,4 +37,8 @@ class Settings::ExportsController < Settings::BaseController def lock_options { redis: Redis.current, key: "backup:#{current_user.id}" } end + + def require_not_suspended! + forbidden if current_account.suspended? + end end diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb index 59eb48779..90092c692 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -4,31 +4,59 @@ class Settings::MigrationsController < Settings::BaseController layout 'admin' before_action :authenticate_user! + before_action :require_not_suspended! + before_action :set_migrations + before_action :set_cooldown + + skip_before_action :require_functional! def show - @migration = Form::Migration.new(account: current_account.moved_to_account) + @migration = current_account.migrations.build end - def update - @migration = Form::Migration.new(resource_params) + def create + @migration = current_account.migrations.build(resource_params) - if @migration.valid? && migration_account_changed? - current_account.update!(moved_to_account: @migration.account) + if @migration.save_with_challenge(current_user) + current_account.update!(moved_to_account: @migration.target_account) ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) - redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg') + ActivityPub::MoveDistributionWorker.perform_async(@migration.id) + redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct) else render :show end end + def cancel + if current_account.moved_to_account_id.present? + current_account.update!(moved_to_account: nil) + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + end + + redirect_to settings_migration_path, notice: I18n.t('migrations.cancelled_msg') + end + + helper_method :on_cooldown? + private def resource_params - params.require(:migration).permit(:acct) + params.require(:account_migration).permit(:acct, :current_password, :current_username) + end + + def set_migrations + @migrations = current_account.migrations.includes(:target_account).order(id: :desc).reject(&:new_record?) + end + + def set_cooldown + @cooldown = current_account.migrations.within_cooldown.first + end + + def on_cooldown? + @cooldown.present? end - def migration_account_changed? - current_account.moved_to_account_id != @migration.account&.id && - current_account.id != @migration.account&.id + def require_not_suspended! + forbidden if current_account.suspended? end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 2b3fd1263..ecc73baf5 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -87,4 +87,12 @@ module SettingsHelper 'desktop' end end + + def compact_account_link_to(account) + return if account.nil? + + link_to ActivityPub::TagManager.instance.url_for(account), class: 'name-tag', title: account.acct do + safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ') + end + end end diff --git a/app/models/account_alias.rb b/app/models/account_alias.rb new file mode 100644 index 000000000..e9a0dd79e --- /dev/null +++ b/app/models/account_alias.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: account_aliases +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# acct :string default(""), not null +# uri :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class AccountAlias < ApplicationRecord + belongs_to :account + + validates :acct, presence: true, domain: { acct: true } + validates :uri, presence: true + + before_validation :set_uri + after_create :add_to_account + after_destroy :remove_from_account + + private + + def set_uri + target_account = ResolveAccountService.new.call(acct) + self.uri = ActivityPub::TagManager.instance.uri_for(target_account) unless target_account.nil? + rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + # Validation will take care of it + end + + def add_to_account + account.update(also_known_as: account.also_known_as + [uri]) + end + + def remove_from_account + account.update(also_known_as: account.also_known_as.reject { |x| x == uri }) + end +end diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb new file mode 100644 index 000000000..15830bffb --- /dev/null +++ b/app/models/account_migration.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: account_migrations +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# acct :string default(""), not null +# followers_count :bigint(8) default(0), not null +# target_account_id :bigint(8) +# created_at :datetime not null +# updated_at :datetime not null +# + +class AccountMigration < ApplicationRecord + COOLDOWN_PERIOD = 30.days.freeze + + belongs_to :account + belongs_to :target_account, class_name: 'Account' + + before_validation :set_target_account + before_validation :set_followers_count + + validates :acct, presence: true, domain: { acct: true } + validate :validate_migration_cooldown + validate :validate_target_account + + scope :within_cooldown, ->(now = Time.now.utc) { where(arel_table[:created_at].gteq(now - COOLDOWN_PERIOD)) } + + attr_accessor :current_password, :current_username + + def save_with_challenge(current_user) + if current_user.encrypted_password.present? + errors.add(:current_password, :invalid) unless current_user.valid_password?(current_password) + else + errors.add(:current_username, :invalid) unless account.username == current_username + end + + return false unless errors.empty? + + save + end + + def cooldown_at + created_at + COOLDOWN_PERIOD + end + + private + + def set_target_account + self.target_account = ResolveAccountService.new.call(acct) + rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + # Validation will take care of it + end + + def set_followers_count + self.followers_count = account.followers_count + end + + def validate_target_account + if target_account.nil? + errors.add(:acct, I18n.t('migrations.errors.not_found')) + else + errors.add(:acct, I18n.t('migrations.errors.missing_also_known_as')) unless target_account.also_known_as.include?(ActivityPub::TagManager.instance.uri_for(account)) + errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved_to_account_id.present? && account.moved_to_account_id == target_account.id + errors.add(:acct, I18n.t('migrations.errors.move_to_self')) if account.id == target_account.id + end + end + + def validate_migration_cooldown + errors.add(:base, I18n.t('migrations.errors.on_cooldown')) if account.migrations.within_cooldown.exists? + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 1db7771c7..c9cc5c610 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -52,6 +52,8 @@ module AccountAssociations # Account migrations belongs_to :moved_to_account, class_name: 'Account', optional: true + has_many :migrations, class_name: 'AccountMigration', dependent: :destroy, inverse_of: :account + has_many :aliases, class_name: 'AccountAlias', dependent: :destroy, inverse_of: :account # Hashtags has_and_belongs_to_many :tags diff --git a/app/models/form/migration.rb b/app/models/form/migration.rb deleted file mode 100644 index c2a8655e1..000000000 --- a/app/models/form/migration.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class Form::Migration - include ActiveModel::Validations - - attr_accessor :acct, :account - - 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 = (ResolveAccountService.new.call(acct) if account.nil? && acct.present?) - end -end diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index 52dd3f67b..5ea535287 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -49,7 +49,7 @@ class RemoteFollow end def fetch_template! - return missing_resource if acct.blank? + return missing_resource_error if acct.blank? _, domain = acct.split('@') diff --git a/app/models/user.rb b/app/models/user.rb index b48455802..9a19a53b3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -168,7 +168,7 @@ class User < ApplicationRecord end def functional? - confirmed? && approved? && !disabled? && !account.suspended? + confirmed? && approved? && !disabled? && !account.suspended? && account.moved_to_account_id.nil? end def unconfirmed_or_pending? diff --git a/app/serializers/activitypub/move_serializer.rb b/app/serializers/activitypub/move_serializer.rb new file mode 100644 index 000000000..5675875fa --- /dev/null +++ b/app/serializers/activitypub/move_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ActivityPub::MoveSerializer < ActivityPub::Serializer + attributes :id, :type, :target, :actor + attribute :virtual_object, key: :object + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#moves/', object.id].join + end + + def type + 'Move' + end + + def target + ActivityPub::TagManager.instance.uri_for(object.target_account) + end + + def virtual_object + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end +end diff --git a/app/views/auth/registrations/_status.html.haml b/app/views/auth/registrations/_status.html.haml index b38a83d67..47112dae0 100644 --- a/app/views/auth/registrations/_status.html.haml +++ b/app/views/auth/registrations/_status.html.haml @@ -1,16 +1,22 @@ %h3= t('auth.status.account_status') -- if @user.account.suspended? - %span.negative-hint= t('user_mailer.warning.explanation.suspend') -- elsif @user.disabled? - %span.negative-hint= t('user_mailer.warning.explanation.disable') -- elsif @user.account.silenced? - %span.warning-hint= t('user_mailer.warning.explanation.silence') -- elsif !@user.confirmed? - %span.warning-hint= t('auth.status.confirming') -- elsif !@user.approved? - %span.warning-hint= t('auth.status.pending') -- else - %span.positive-hint= t('auth.status.functional') +.simple_form + %p.hint + - if @user.account.suspended? + %span.negative-hint= t('user_mailer.warning.explanation.suspend') + - elsif @user.disabled? + %span.negative-hint= t('user_mailer.warning.explanation.disable') + - elsif @user.account.silenced? + %span.warning-hint= t('user_mailer.warning.explanation.silence') + - elsif !@user.confirmed? + %span.warning-hint= t('auth.status.confirming') + = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path + - elsif !@user.approved? + %span.warning-hint= t('auth.status.pending') + - elsif @user.account.moved_to_account_id.present? + %span.positive-hint= t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct) + = link_to t('migrations.cancel'), settings_migration_path + - else + %span.positive-hint= t('auth.status.functional') %hr.spacer/ diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml index 710ee5c68..885171c58 100644 --- a/app/views/auth/registrations/edit.html.haml +++ b/app/views/auth/registrations/edit.html.haml @@ -13,7 +13,7 @@ .fields-row__column.fields-group.fields-row__column-6 = f.input :email, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, required: true, disabled: current_account.suspended? .fields-row__column.fields-group.fields-row__column-6 - = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, required: true, disabled: current_account.suspended? + = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, required: true, disabled: current_account.suspended?, hint: false .fields-row .fields-row__column.fields-group.fields-row__column-6 diff --git a/app/views/settings/aliases/index.html.haml b/app/views/settings/aliases/index.html.haml new file mode 100644 index 000000000..5b6986368 --- /dev/null +++ b/app/views/settings/aliases/index.html.haml @@ -0,0 +1,29 @@ +- content_for :page_title do + = t('settings.aliases') + += simple_form_for @alias, url: settings_aliases_path do |f| + = render 'shared/error_messages', object: @alias + + %p.hint= t('aliases.hint_html') + + %hr.spacer/ + + .fields-group + = f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' } + + .actions + = f.button :button, t('aliases.add_new'), type: :submit, class: 'button' + +%hr.spacer/ + +.table-wrapper + %table.table.inline-table + %thead + %tr + %th= t('simple_form.labels.account_alias.acct') + %th + %tbody + - @aliases.each do |account_alias| + %tr + %td= account_alias.acct + %td= table_link_to 'trash', t('aliases.remove'), settings_alias_path(account_alias), data: { method: :delete } diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml index b13cea976..76ff76bd9 100644 --- a/app/views/settings/exports/show.html.haml +++ b/app/views/settings/exports/show.html.haml @@ -37,12 +37,16 @@ %td= number_with_delimiter @export.total_domain_blocks %td= table_link_to 'download', t('exports.csv'), settings_exports_domain_blocks_path(format: :csv) +%hr.spacer/ + %p.muted-hint= t('exports.archive_takeout.hint_html') - if policy(:backup).create? %p= link_to t('exports.archive_takeout.request'), settings_export_path, class: 'button', method: :post - unless @backups.empty? + %hr.spacer/ + .table-wrapper %table.table %thead diff --git a/app/views/settings/migrations/show.html.haml b/app/views/settings/migrations/show.html.haml index c69061d50..1e5c47726 100644 --- a/app/views/settings/migrations/show.html.haml +++ b/app/views/settings/migrations/show.html.haml @@ -1,17 +1,85 @@ - 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') +.simple_form + - if current_account.moved_to_account.present? + .fields-row + .fields-row__column.fields-group.fields-row__column-6 + = render 'application/card', account: current_account.moved_to_account + .fields-row__column.fields-group.fields-row__column-6 + %p.hint + %span.positive-hint= t('migrations.redirecting_to', acct: current_account.moved_to_account.acct) - .fields-group - = render partial: 'application/card', locals: { account: @migration.account } + %p.hint= t('migrations.cancel_explanation') + + %p.hint= link_to t('migrations.cancel'), cancel_settings_migration_path, data: { method: :post } + - else + %p.hint + %span.positive-hint= t('migrations.not_redirecting') + +%hr.spacer/ + +%h3= t 'migrations.proceed_with_move' + += simple_form_for @migration, url: settings_migration_path do |f| + - if on_cooldown? + %span.warning-hint= t('migrations.on_cooldown', count: ((@cooldown.cooldown_at - Time.now.utc) / 1.day.seconds).ceil) + - else + %p.hint= t('migrations.warning.before') + + %ul.hint + %li.warning-hint= t('migrations.warning.followers') + %li.warning-hint= t('migrations.warning.other_data') + %li.warning-hint= t('migrations.warning.backreference_required') + %li.warning-hint= t('migrations.warning.cooldown') + %li.warning-hint= t('migrations.warning.disabled_account') + + %hr.spacer/ = render 'shared/error_messages', object: @migration - .fields-group - = f.input :acct, placeholder: t('migrations.acct') + .fields-row + .fields-row__column.fields-group.fields-row__column-6 + = f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }, disabled: on_cooldown? + + .fields-row__column.fields-group.fields-row__column-6 + - if current_user.encrypted_password.present? + = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true, disabled: on_cooldown? + - else + = f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true, disabled: on_cooldown? .actions - = f.button :button, t('migrations.proceed'), type: :submit, class: 'negative' + = f.button :button, t('migrations.proceed_with_move'), type: :submit, class: 'button button--destructive', disabled: on_cooldown? + +- unless @migrations.empty? + %hr.spacer/ + + %h3= t 'migrations.past_migrations' + + %hr.spacer/ + + .table-wrapper + %table.table.inline-table + %thead + %tr + %th= t('migrations.acct') + %th= t('migrations.followers_count') + %th + %tbody + - @migrations.each do |migration| + %tr + %td + - if migration.target_account.present? + = compact_account_link_to migration.target_account + - else + = migration.acct + + %td= number_with_delimiter migration.followers_count + + %td + %time.time-ago{ datetime: migration.created_at.iso8601, title: l(migration.created_at) }= l(migration.created_at) + +%hr.spacer/ + +%h3= t 'migrations.incoming_migrations' +%p.muted-hint= t('migrations.incoming_migrations_html', path: settings_aliases_path) diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index f042011d6..6929f54f3 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -60,6 +60,11 @@ %h6= t('auth.migrate_account') %p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path) +%hr.spacer/ + +%h6= t 'migrations.incoming_migrations' +%p.muted-hint= t('migrations.incoming_migrations_html', path: settings_aliases_path) + - if open_deletion? %hr.spacer/ diff --git a/app/workers/activitypub/move_distribution_worker.rb b/app/workers/activitypub/move_distribution_worker.rb new file mode 100644 index 000000000..396d5258f --- /dev/null +++ b/app/workers/activitypub/move_distribution_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ActivityPub::MoveDistributionWorker + include Sidekiq::Worker + include Payloadable + + sidekiq_options queue: 'push' + + def perform(migration_id) + @migration = AccountMigration.find(migration_id) + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [signed_payload, @account.id, inbox_url] + end + + ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| + [signed_payload, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def inboxes + @inboxes ||= @migration.account.followers.inboxes + end + + def signed_payload + @signed_payload ||= Oj.dump(serialize_payload(@migration, ActivityPub::MoveSerializer, signer: @account)) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index dabb679e7..c29c7f871 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -554,6 +554,12 @@ en: new_trending_tag: body: 'The hashtag #%{name} is trending today, but has not been previously reviewed. It will not be displayed publicly unless you allow it to, or just save the form as it is to never hear about it again.' subject: New hashtag up for review on %{instance} (#%{name}) + aliases: + add_new: Create alias + created_msg: Successfully created a new alias. You can now initiate the move from the old account. + deleted_msg: Successfully remove the alias. Moving from that account to this one will no longer be possible. + hint_html: If you want to move from another account to this one, here you can create an alias, which is required before you can proceed with moving followers from the old account to this one. This action by itself is harmless and reversible. The account migration is initiated from the old account. + remove: Unlink alias appearance: advanced_web_interface: Advanced web interface advanced_web_interface_hint: 'If you want to make use of your entire screen width, the advanced web interface allows you to configure many different columns to see as much information at the same time as you want: Home, notifications, federated timeline, any number of lists and hashtags.' @@ -613,6 +619,7 @@ en: confirming: Waiting for e-mail confirmation to be completed. functional: Your account is fully operational. pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved. + redirecting_to: Your account is inactive because it is currently redirecting to %{acct}. trouble_logging_in: Trouble logging in? authorize_follow: already_following: You are already following this account @@ -801,10 +808,32 @@ en: 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 - updated_msg: Your account migration setting successfully updated! + acct: Moved to + cancel: Cancel redirect + cancel_explanation: Cancelling the redirect will re-activate your current account, but will not bring back followers that have been moved to that account. + cancelled_msg: Successfully cancelled the redirect. + errors: + already_moved: is the same account you have already moved to + missing_also_known_as: is not back-referencing this account + move_to_self: cannot be current account + not_found: could not be found + on_cooldown: You are on cooldown + followers_count: Followers at time of move + incoming_migrations: Moving from a different account + incoming_migrations_html: To move from another account to this one, first you need to create an account alias. + moved_msg: Your account is now redirecting to %{acct} and your followers are being moved over. + not_redirecting: Your account is not redirecting to any other account currently. + on_cooldown: You have recently migrated your account. This function will become available again in %{count} days. + past_migrations: Past migrations + proceed_with_move: Move followers + redirecting_to: Your account is redirecting to %{acct}. + warning: + backreference_required: The new account must first be configured to back-reference this one + before: 'Before proceeding, please read these notes carefully:' + cooldown: After moving there is a cooldown period during which you will not be able to move again + disabled_account: Your current account will not be fully usable afterwards. However, you will have access to data export as well as re-activation. + followers: This action will move all followers from the current account to the new account + other_data: No other data will be moved automatically moderation: title: Moderation notification_mailer: @@ -950,6 +979,7 @@ en: settings: account: Account account_settings: Account settings + aliases: Account aliases appearance: Appearance authorized_apps: Authorized apps back: Back to Mastodon diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c9ffcfc13..3d909e999 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -2,6 +2,10 @@ en: simple_form: hints: + account_alias: + acct: Specify the username@domain of the account you want to move from + account_migration: + acct: Specify the username@domain of the account you want to move to account_warning_preset: text: You can use toot syntax, such as URLs, hashtags and mentions admin_account_action: @@ -15,6 +19,8 @@ en: avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px bot: This account mainly performs automated actions and might not be monitored context: One or multiple contexts where the filter should apply + current_password: For security purposes please enter the password of the current account + current_username: To confirm, please enter the username of the current account digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence discoverable: The profile directory is another way by which your account can reach a wider audience email: You will be sent a confirmation e-mail @@ -60,6 +66,10 @@ en: fields: name: Label value: Content + account_alias: + acct: Handle of the old account + account_migration: + acct: Handle of the new account account_warning_preset: text: Preset text admin_account_action: diff --git a/config/navigation.rb b/config/navigation.rb index 38668bbf7..32c299143 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -5,7 +5,7 @@ SimpleNavigation::Configuration.run do |navigation| n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_url n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_url, if: -> { current_user.functional? } do |s| - s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_url, highlights_on: %r{/settings/profile|/settings/migration} + s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_url s.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_url s.item :identity_proofs, safe_join([fa_icon('key fw'), t('settings.identity_proofs')]), settings_identity_proofs_path, highlights_on: %r{/settings/identity_proofs*}, if: proc { current_account.identity_proofs.exists? } end @@ -20,13 +20,13 @@ SimpleNavigation::Configuration.run do |navigation| n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? } n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s| - s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete} + s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases} s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication} s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url end - n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_url, if: -> { current_user.functional? } do |s| - s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url + n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_url do |s| + s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url, if: -> { current_user.functional? } s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url end diff --git a/config/routes.rb b/config/routes.rb index dcfa079a0..37e0cbdee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -134,8 +134,14 @@ Rails.application.routes.draw do end resource :delete, only: [:show, :destroy] - resource :migration, only: [:show, :update] + resource :migration, only: [:show, :create] do + collection do + post :cancel + end + end + + resources :aliases, only: [:index, :create, :destroy] resources :sessions, only: [:destroy] resources :featured_tags, only: [:index, :create, :destroy] end diff --git a/db/migrate/20190914202517_create_account_migrations.rb b/db/migrate/20190914202517_create_account_migrations.rb new file mode 100644 index 000000000..cb9d71c09 --- /dev/null +++ b/db/migrate/20190914202517_create_account_migrations.rb @@ -0,0 +1,12 @@ +class CreateAccountMigrations < ActiveRecord::Migration[5.2] + def change + create_table :account_migrations do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.string :acct, null: false, default: '' + t.bigint :followers_count, null: false, default: 0 + t.belongs_to :target_account, foreign_key: { to_table: :accounts, on_delete: :nullify } + + t.timestamps + end + end +end diff --git a/db/migrate/20190915194355_create_account_aliases.rb b/db/migrate/20190915194355_create_account_aliases.rb new file mode 100644 index 000000000..32ce031d9 --- /dev/null +++ b/db/migrate/20190915194355_create_account_aliases.rb @@ -0,0 +1,11 @@ +class CreateAccountAliases < ActiveRecord::Migration[5.2] + def change + create_table :account_aliases do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.string :acct, null: false, default: '' + t.string :uri, null: false, default: '' + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 749f79dee..fabeb16f3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -15,6 +15,15 @@ ActiveRecord::Schema.define(version: 2019_09_17_213523) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "account_aliases", force: :cascade do |t| + t.bigint "account_id" + t.string "acct", default: "", null: false + t.string "uri", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_aliases_on_account_id" + end + create_table "account_conversations", force: :cascade do |t| t.bigint "account_id" t.bigint "conversation_id" @@ -49,6 +58,17 @@ ActiveRecord::Schema.define(version: 2019_09_17_213523) do t.index ["account_id"], name: "index_account_identity_proofs_on_account_id" end + create_table "account_migrations", force: :cascade do |t| + t.bigint "account_id" + t.string "acct", default: "", null: false + t.bigint "followers_count", default: 0, null: false + t.bigint "target_account_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_migrations_on_account_id" + t.index ["target_account_id"], name: "index_account_migrations_on_target_account_id" + end + create_table "account_moderation_notes", force: :cascade do |t| t.text "content", null: false t.bigint "account_id", null: false @@ -768,10 +788,13 @@ ActiveRecord::Schema.define(version: 2019_09_17_213523) do t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true end + add_foreign_key "account_aliases", "accounts", on_delete: :cascade add_foreign_key "account_conversations", "accounts", on_delete: :cascade add_foreign_key "account_conversations", "conversations", on_delete: :cascade add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade add_foreign_key "account_identity_proofs", "accounts", on_delete: :cascade + add_foreign_key "account_migrations", "accounts", column: "target_account_id", on_delete: :nullify + add_foreign_key "account_migrations", "accounts", on_delete: :cascade add_foreign_key "account_moderation_notes", "accounts" add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade diff --git a/spec/controllers/settings/migrations_controller_spec.rb b/spec/controllers/settings/migrations_controller_spec.rb index 4d814a45e..36e4ba86e 100644 --- a/spec/controllers/settings/migrations_controller_spec.rb +++ b/spec/controllers/settings/migrations_controller_spec.rb @@ -21,6 +21,7 @@ describe Settings::MigrationsController do let(:user) { Fabricate(:user, account: account) } let(:account) { Fabricate(:account, moved_to_account: moved_to_account) } + before { sign_in user, scope: :user } context 'when user does not have moved to account' do @@ -32,7 +33,7 @@ describe Settings::MigrationsController do end end - context 'when user does not have moved to account' do + context 'when user has a moved to account' do let(:moved_to_account) { Fabricate(:account) } it 'renders show page' do @@ -43,21 +44,22 @@ describe Settings::MigrationsController do end end - describe 'PUT #update' do + describe 'POST #create' do context 'when user is not sign in' do - subject { put :update } + subject { post :create } it_behaves_like 'authenticate user' end context 'when user is sign in' do - subject { put :update, params: { migration: { acct: acct } } } + subject { post :create, params: { account_migration: { acct: acct, current_password: '12345678' } } } + + let(:user) { Fabricate(:user, password: '12345678') } - let(:user) { Fabricate(:user) } before { sign_in user, scope: :user } context 'when migration account is changed' do - let(:acct) { Fabricate(:account) } + let(:acct) { Fabricate(:account, also_known_as: [ActivityPub::TagManager.instance.uri_for(user.account)]) } it 'updates moved to account' do is_expected.to redirect_to settings_migration_path diff --git a/spec/fabricators/account_alias_fabricator.rb b/spec/fabricators/account_alias_fabricator.rb new file mode 100644 index 000000000..94dde9bb8 --- /dev/null +++ b/spec/fabricators/account_alias_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:account_alias) do + account + acct 'test@example.com' + uri 'https://example.com/users/test' +end diff --git a/spec/fabricators/account_migration_fabricator.rb b/spec/fabricators/account_migration_fabricator.rb new file mode 100644 index 000000000..3b3fc2077 --- /dev/null +++ b/spec/fabricators/account_migration_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:account_migration) do + account + target_account + followers_count 1234 + acct 'test@example.com' +end diff --git a/spec/models/account_alias_spec.rb b/spec/models/account_alias_spec.rb new file mode 100644 index 000000000..27ec215aa --- /dev/null +++ b/spec/models/account_alias_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe AccountAlias, type: :model do + +end diff --git a/spec/models/account_migration_spec.rb b/spec/models/account_migration_spec.rb new file mode 100644 index 000000000..8461b4b28 --- /dev/null +++ b/spec/models/account_migration_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe AccountMigration, type: :model do + +end -- cgit From 163ed91af381d86bb6c52546c983effa4c9a18c3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 29 Sep 2019 05:03:19 +0200 Subject: Add (back) option to set redirect notice on account without moving followers (#11994) Fix #11913 --- .../settings/migration/redirects_controller.rb | 45 +++++++++++++++++++++ app/controllers/settings/migrations_controller.rb | 9 ----- app/models/account_migration.rb | 3 +- app/models/form/redirect.rb | 47 ++++++++++++++++++++++ app/views/auth/registrations/edit.html.haml | 11 +++++ .../settings/migration/redirects/new.html.haml | 27 +++++++++++++ app/views/settings/migrations/show.html.haml | 10 +++-- config/locales/en.yml | 3 ++ config/routes.rb | 7 ++-- 9 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 app/controllers/settings/migration/redirects_controller.rb create mode 100644 app/models/form/redirect.rb create mode 100644 app/views/settings/migration/redirects/new.html.haml (limited to 'app/models/form') diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb new file mode 100644 index 000000000..6e5b72ffb --- /dev/null +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Settings::Migration::RedirectsController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :require_not_suspended! + + skip_before_action :require_functional! + + def new + @redirect = Form::Redirect.new + end + + def create + @redirect = Form::Redirect.new(resource_params.merge(account: current_account)) + + if @redirect.valid_with_challenge?(current_user) + current_account.update!(moved_to_account: @redirect.target_account) + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct) + else + render :new + end + end + + def destroy + if current_account.moved_to_account_id.present? + current_account.update!(moved_to_account: nil) + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + end + + redirect_to settings_migration_path, notice: I18n.t('migrations.cancelled_msg') + end + + private + + def resource_params + params.require(:form_redirect).permit(:acct, :current_password, :current_username) + end + + def require_not_suspended! + forbidden if current_account.suspended? + end +end diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb index 90092c692..00bde1d61 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -27,15 +27,6 @@ class Settings::MigrationsController < Settings::BaseController end end - def cancel - if current_account.moved_to_account_id.present? - current_account.update!(moved_to_account: nil) - ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) - end - - redirect_to settings_migration_path, notice: I18n.t('migrations.cancelled_msg') - end - helper_method :on_cooldown? private diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb index e2c2cb085..681b5b2cd 100644 --- a/app/models/account_migration.rb +++ b/app/models/account_migration.rb @@ -47,8 +47,7 @@ class AccountMigration < ApplicationRecord end def acct=(val) - val = val.to_s.strip - super(val.start_with?('@') ? val[1..-1] : val) + super(val.to_s.strip.gsub(/\A@/, '')) end private diff --git a/app/models/form/redirect.rb b/app/models/form/redirect.rb new file mode 100644 index 000000000..a7961f8e8 --- /dev/null +++ b/app/models/form/redirect.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Form::Redirect + include ActiveModel::Model + + attr_accessor :account, :target_account, :current_password, + :current_username + + attr_reader :acct + + validates :acct, presence: true, domain: { acct: true } + validate :validate_target_account + + def valid_with_challenge?(current_user) + if current_user.encrypted_password.present? + errors.add(:current_password, :invalid) unless current_user.valid_password?(current_password) + else + errors.add(:current_username, :invalid) unless account.username == current_username + end + + return false unless errors.empty? + + set_target_account + valid? + end + + def acct=(val) + @acct = val.to_s.strip.gsub(/\A@/, '') + end + + private + + def set_target_account + @target_account = ResolveAccountService.new.call(acct) + rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + # Validation will take care of it + end + + def validate_target_account + if target_account.nil? + errors.add(:acct, I18n.t('migrations.errors.not_found')) + else + errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved_to_account_id.present? && account.moved_to_account_id == target_account.id + errors.add(:acct, I18n.t('migrations.errors.move_to_self')) if account.id == target_account.id + end + end +end diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml index 885171c58..a155c75c9 100644 --- a/app/views/auth/registrations/edit.html.haml +++ b/app/views/auth/registrations/edit.html.haml @@ -30,7 +30,18 @@ = render 'sessions' +%hr.spacer/ + +%h3= t('auth.migrate_account') +%p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path) + +%hr.spacer/ + +%h3= t('migrations.incoming_migrations') +%p.muted-hint= t('migrations.incoming_migrations_html', path: settings_aliases_path) + - if open_deletion? && !current_account.suspended? %hr.spacer/ + %h3= t('auth.delete_account') %p.muted-hint= t('auth.delete_account_html', path: settings_delete_path) diff --git a/app/views/settings/migration/redirects/new.html.haml b/app/views/settings/migration/redirects/new.html.haml new file mode 100644 index 000000000..017450f4b --- /dev/null +++ b/app/views/settings/migration/redirects/new.html.haml @@ -0,0 +1,27 @@ +- content_for :page_title do + = t('settings.migrate') + += simple_form_for @redirect, url: settings_migration_redirect_path do |f| + %p.hint= t('migrations.warning.before') + + %ul.hint + %li.warning-hint= t('migrations.warning.redirect') + %li.warning-hint= t('migrations.warning.other_data') + %li.warning-hint= t('migrations.warning.disabled_account') + + %hr.spacer/ + + = render 'shared/error_messages', object: @redirect + + .fields-row + .fields-row__column.fields-group.fields-row__column-6 + = f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }, label: t('simple_form.labels.account_migration.acct'), hint: t('simple_form.hints.account_migration.acct') + + .fields-row__column.fields-group.fields-row__column-6 + - if current_user.encrypted_password.present? + = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true + - else + = f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true + + .actions + = f.button :button, t('migrations.set_redirect'), type: :submit, class: 'button button--destructive' diff --git a/app/views/settings/migrations/show.html.haml b/app/views/settings/migrations/show.html.haml index 1e5c47726..078eaebc6 100644 --- a/app/views/settings/migrations/show.html.haml +++ b/app/views/settings/migrations/show.html.haml @@ -12,28 +12,32 @@ %p.hint= t('migrations.cancel_explanation') - %p.hint= link_to t('migrations.cancel'), cancel_settings_migration_path, data: { method: :post } + %p.hint= link_to t('migrations.cancel'), settings_migration_redirect_path, data: { method: :delete } - else %p.hint %span.positive-hint= t('migrations.not_redirecting') %hr.spacer/ -%h3= t 'migrations.proceed_with_move' +%h3= t('auth.migrate_account') = simple_form_for @migration, url: settings_migration_path do |f| - if on_cooldown? - %span.warning-hint= t('migrations.on_cooldown', count: ((@cooldown.cooldown_at - Time.now.utc) / 1.day.seconds).ceil) + %p.hint + %span.warning-hint= t('migrations.on_cooldown', count: ((@cooldown.cooldown_at - Time.now.utc) / 1.day.seconds).ceil) - else %p.hint= t('migrations.warning.before') %ul.hint %li.warning-hint= t('migrations.warning.followers') + %li.warning-hint= t('migrations.warning.redirect') %li.warning-hint= t('migrations.warning.other_data') %li.warning-hint= t('migrations.warning.backreference_required') %li.warning-hint= t('migrations.warning.cooldown') %li.warning-hint= t('migrations.warning.disabled_account') + %p.hint= t('migrations.warning.only_redirect_html', path: new_settings_migration_redirect_path) + %hr.spacer/ = render 'shared/error_messages', object: @migration diff --git a/config/locales/en.yml b/config/locales/en.yml index ee798e87f..1e7d0701b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -831,13 +831,16 @@ en: past_migrations: Past migrations proceed_with_move: Move followers redirecting_to: Your account is redirecting to %{acct}. + set_redirect: Set redirect warning: backreference_required: The new account must first be configured to back-reference this one before: 'Before proceeding, please read these notes carefully:' cooldown: After moving there is a cooldown period during which you will not be able to move again disabled_account: Your current account will not be fully usable afterwards. However, you will have access to data export as well as re-activation. followers: This action will move all followers from the current account to the new account + only_redirect_html: Alternatively, you can only put up a redirect on your profile. other_data: No other data will be moved automatically + redirect: Your current account's profile will be updated with a redirect notice and be excluded from searches moderation: title: Moderation notification_mailer: diff --git a/config/routes.rb b/config/routes.rb index 37e0cbdee..f1a69cf5c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -134,11 +134,10 @@ Rails.application.routes.draw do end resource :delete, only: [:show, :destroy] + resource :migration, only: [:show, :create] - resource :migration, only: [:show, :create] do - collection do - post :cancel - end + namespace :migration do + resource :redirect, only: [:new, :create, :destroy] end resources :aliases, only: [:index, :create, :destroy] -- cgit From c8bcf5cbfdc4b076eae0d9091e688436aa7f2508 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 9 Oct 2019 00:30:15 +0200 Subject: Add admin setting to auto-approve hashtags (#12122) Change inaccurate labels on other admin settings --- app/models/form/admin_settings.rb | 2 ++ app/models/tag.rb | 2 +- app/views/admin/settings/edit.html.haml | 9 ++++++--- config/locales/en.yml | 11 +++++++---- config/settings.yml | 1 + 5 files changed, 17 insertions(+), 8 deletions(-) (limited to 'app/models/form') diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 24196e182..70e9c21f1 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -30,6 +30,7 @@ class Form::AdminSettings mascot spam_check_enabled trends + trendable_by_default show_domain_blocks show_domain_blocks_rationale noindex @@ -46,6 +47,7 @@ class Form::AdminSettings profile_directory spam_check_enabled trends + trendable_by_default noindex ).freeze diff --git a/app/models/tag.rb b/app/models/tag.rb index 82786daa8..59445a83b 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -76,7 +76,7 @@ class Tag < ApplicationRecord alias listable? listable def trendable - boolean_with_default('trendable', false) + boolean_with_default('trendable', Setting.trendable_by_default) end alias trendable? trendable diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 752386b3c..6282bb39c 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -20,10 +20,10 @@ = f.input :site_contact_email, wrapper: :with_label, label: t('admin.settings.contact_information.email') .fields-group - = f.input :site_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description.title'), hint: t('admin.settings.site_description.desc_html'), input_html: { rows: 4 } + = f.input :site_short_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_short_description.title'), hint: t('admin.settings.site_short_description.desc_html'), input_html: { rows: 2 } .fields-group - = f.input :site_short_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_short_description.title'), hint: t('admin.settings.site_short_description.desc_html'), input_html: { rows: 2 } + = f.input :site_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description.title'), hint: t('admin.settings.site_description.desc_html'), input_html: { rows: 2 } .fields-row .fields-row__column.fields-row__column-6.fields-group @@ -71,6 +71,9 @@ .fields-group = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') + .fields-group + = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, label: t('admin.settings.trendable_by_default.title'), hint: t('admin.settings.trendable_by_default.desc_html') + .fields-group = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') @@ -89,8 +92,8 @@ = f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks_rationale.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' .fields-group - = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } = 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 } unless whitelist_mode? + = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.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 } = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }, label: t('admin.settings.custom_css.title'), hint: t('admin.settings.custom_css.desc_html') diff --git a/config/locales/en.yml b/config/locales/en.yml index 68fc21323..0e8ee6a76 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -478,8 +478,8 @@ en: open: Anyone can sign up title: Registrations mode show_known_fediverse_at_about_page: - desc_html: When toggled, it will show toots from all the known fediverse on preview. Otherwise it will only show local toots. - title: Show known fediverse on timeline preview + desc_html: When disabled, restricts the public timeline linked from the landing page to showing only local content + title: Include federated content on unauthenticated public timeline page show_staff_badge: desc_html: Show a staff badge on a user page title: Show staff badge @@ -503,9 +503,12 @@ en: desc_html: Used for previews via OpenGraph and API. 1200x630px recommended title: Server thumbnail timeline_preview: - desc_html: Display public timeline on landing page - title: Timeline preview + desc_html: Display link to public timeline on landing page and allow API access to the public timeline without authentication + title: Allow unauthenticated access to public timeline title: Site settings + trendable_by_default: + desc_html: Affects hashtags that have not been previously disallowed + title: Allow hashtags to trend without prior review trends: desc_html: Publicly display previously reviewed hashtags that are currently trending title: Trending hashtags diff --git a/config/settings.yml b/config/settings.yml index 6dbc46706..bd2f65b5e 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -35,6 +35,7 @@ defaults: &defaults use_blurhash: true use_pending_items: false trends: true + trendable_by_default: false notification_emails: follow: false reblog: false -- cgit