diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index 3f2e28b6ae..e89404b609 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -21,7 +21,7 @@ module Admin account_action.save! if account_action.with_report? - redirect_to admin_reports_path + redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id]) else redirect_to admin_account_path(@account.id) end diff --git a/app/controllers/admin/export_domain_allows_controller.rb b/app/controllers/admin/export_domain_allows_controller.rb index 57fb12c620..adfc39da21 100644 --- a/app/controllers/admin/export_domain_allows_controller.rb +++ b/app/controllers/admin/export_domain_allows_controller.rb @@ -23,9 +23,7 @@ module Admin @import = Admin::Import.new(import_params) return render :new unless @import.validate - parse_import_data!(export_headers) - - @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row| + @import.csv_rows.each do |row| domain = row['#domain'].strip next if DomainAllow.allowed?(domain) diff --git a/app/controllers/admin/export_domain_blocks_controller.rb b/app/controllers/admin/export_domain_blocks_controller.rb index fb0cd05d29..816422d4ff 100644 --- a/app/controllers/admin/export_domain_blocks_controller.rb +++ b/app/controllers/admin/export_domain_blocks_controller.rb @@ -23,24 +23,30 @@ module Admin @import = Admin::Import.new(import_params) return render :new unless @import.validate - parse_import_data!(export_headers) - @global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc)) @form = Form::DomainBlockBatch.new - @domain_blocks = @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).filter_map do |row| + @domain_blocks = @import.csv_rows.filter_map do |row| domain = row['#domain'].strip next if DomainBlock.rule_for(domain).present? domain_block = DomainBlock.new(domain: domain, - severity: row['#severity'].strip, - reject_media: row['#reject_media'].strip, - reject_reports: row['#reject_reports'].strip, + severity: row.fetch('#severity', :suspend), + reject_media: row.fetch('#reject_media', false), + reject_reports: row.fetch('#reject_reports', false), private_comment: @global_private_comment, - public_comment: row['#public_comment']&.strip, - obfuscate: row['#obfuscate'].strip) + public_comment: row['#public_comment'], + obfuscate: row.fetch('#obfuscate', false)) - domain_block if domain_block.valid? + if domain_block.invalid? + flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', ')) + next + end + + domain_block + rescue ArgumentError => e + flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: e.message) + next end @warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain) diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb index 5cb5c744f2..554f7906f8 100644 --- a/app/controllers/admin/reports/actions_controller.rb +++ b/app/controllers/admin/reports/actions_controller.rb @@ -3,6 +3,11 @@ class Admin::Reports::ActionsController < Admin::BaseController before_action :set_report + def preview + authorize @report, :show? + @moderation_action = action_from_button + end + def create authorize @report, :show? @@ -13,7 +18,8 @@ class Admin::Reports::ActionsController < Admin::BaseController status_ids: @report.status_ids, current_account: current_account, report_id: @report.id, - send_email_notification: !@report.spam? + send_email_notification: !@report.spam?, + text: params[:text] ) status_batch_action.save! @@ -23,13 +29,16 @@ class Admin::Reports::ActionsController < Admin::BaseController report_id: @report.id, target_account: @report.target_account, current_account: current_account, - send_email_notification: !@report.spam? + send_email_notification: !@report.spam?, + text: params[:text] ) account_action.save! + else + return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button) end - redirect_to admin_reports_path + redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: @report.id) end private @@ -47,6 +56,8 @@ class Admin::Reports::ActionsController < Admin::BaseController 'silence' elsif params[:suspend] 'suspend' + elsif params[:moderation_action] + params[:moderation_action] end end end diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb index f3c0c4b6b4..e77df30216 100644 --- a/app/controllers/api/v1/admin/trends/tags_controller.rb +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -3,6 +3,14 @@ class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController before_action -> { authorize_if_got_token! :'admin:read' } + def index + if current_user&.can?(:manage_taxonomies) + render json: @tags, each_serializer: REST::Admin::TagSerializer + else + super + end + end + private def enabled? diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index e2e48f6337..3a9cf056ba 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -80,6 +80,7 @@ class Api::V1::StatusesController < Api::BaseController current_account.id, text: status_params[:status], media_ids: status_params[:media_ids], + media_attributes: status_params[:media_attributes], sensitive: status_params[:sensitive], language: status_params[:language], spoiler_text: status_params[:spoiler_text], @@ -131,6 +132,12 @@ class Api::V1::StatusesController < Api::BaseController :scheduled_at, :content_type, media_ids: [], + media_attributes: [ + :id, + :thumbnail, + :description, + :focus, + ], poll: [ :multiple, :hide_totals, diff --git a/app/controllers/concerns/admin_export_controller_concern.rb b/app/controllers/concerns/admin_export_controller_concern.rb index b40c76557f..4ac48a04b7 100644 --- a/app/controllers/concerns/admin_export_controller_concern.rb +++ b/app/controllers/concerns/admin_export_controller_concern.rb @@ -26,14 +26,4 @@ module AdminExportControllerConcern def import_params params.require(:admin_import).permit(:data) end - - def import_data_path - params[:admin_import][:data].path - end - - def parse_import_data!(default_headers) - data = CSV.read(import_data_path, headers: true, encoding: 'UTF-8') - data = CSV.read(import_data_path, headers: default_headers, encoding: 'UTF-8') unless data.headers&.first&.strip&.include?(default_headers[0]) - @data = data.reject(&:blank?) - end end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4502da698c..a9950d21fb 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -46,11 +46,11 @@ module SignatureVerification end def require_account_signature! - render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account + render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account end def require_actor_signature! - render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor + render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor end def signed_request? @@ -97,11 +97,11 @@ module SignatureVerification actor = stoplight_wrap_request { actor_refresh_key!(actor) } - raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil? + raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil? return actor unless verify_signature(actor, signature, compare_signed_string).nil? - fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)" + fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature'] rescue SignatureVerificationError => e fail_with! e.message rescue HTTP::Error, OpenSSL::SSL::SSLError => e @@ -118,8 +118,8 @@ module SignatureVerification private - def fail_with!(message) - @signature_verification_failure_reason = message + def fail_with!(message, **options) + @signature_verification_failure_reason = { error: message }.merge(options) @signed_request_actor = nil end @@ -209,8 +209,8 @@ module SignatureVerification end expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present? - rescue ArgumentError - return false + rescue ArgumentError => e + raise SignatureVerificationError, "Invalid Date header: #{e.message}" end expires_time ||= created_time + 5.minutes unless created_time.nil? diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 12d25490b4..cc27840d00 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -160,6 +160,18 @@ export function submitCompose(routerHistory) { dispatch(submitComposeRequest()); + // If we're editing a post with media attachments, those have not + // necessarily been changed on the server. Do it now in the same + // API call. + let media_attributes; + if (statusId !== null) { + media_attributes = media.map(item => ({ + id: item.get('id'), + description: item.get('description'), + focus: item.get('focus'), + })); + } + api(getState).request({ url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, method: statusId === null ? 'post' : 'put', @@ -167,6 +179,7 @@ export function submitCompose(routerHistory) { status, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), + media_attributes, sensitive: getState().getIn(['compose', 'sensitive']), spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', visibility: getState().getIn(['compose', 'privacy']), @@ -377,11 +390,31 @@ export function changeUploadCompose(id, params) { return (dispatch, getState) => { dispatch(changeUploadComposeRequest()); - api(getState).put(`/api/v1/media/${id}`, params).then(response => { - dispatch(changeUploadComposeSuccess(response.data)); - }).catch(error => { - dispatch(changeUploadComposeFail(id, error)); - }); + let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id); + + // Editing already-attached media is deferred to editing the post itself. + // For simplicity's sake, fake an API reply. + if (media && !media.get('unattached')) { + let { description, focus } = params; + const data = media.toJS(); + + if (description) { + data.description = description; + } + + if (focus) { + focus = focus.split(','); + data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } }; + } + + dispatch(changeUploadComposeSuccess(data, true)); + } else { + api(getState).put(`/api/v1/media/${id}`, params).then(response => { + dispatch(changeUploadComposeSuccess(response.data, false)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + } }; } @@ -392,10 +425,11 @@ export function changeUploadComposeRequest() { }; } -export function changeUploadComposeSuccess(media) { +export function changeUploadComposeSuccess(media, attached) { return { type: COMPOSE_UPLOAD_CHANGE_SUCCESS, media: media, + attached: attached, skipLoading: true, }; } diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js index 37e79d4cba..08a08cda31 100644 --- a/app/javascript/mastodon/actions/tags.js +++ b/app/javascript/mastodon/actions/tags.js @@ -1,9 +1,17 @@ -import api from '../api'; +import api, { getLinks } from '../api'; export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; +export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST'; +export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS'; +export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL'; + +export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST'; +export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS'; +export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL'; + export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; @@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({ error, }); +export const fetchFollowedHashtags = () => (dispatch, getState) => { + dispatch(fetchFollowedHashtagsRequest()); + + api(getState).get('/api/v1/followed_tags').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchFollowedHashtagsFail(err)); + }); +}; + +export function fetchFollowedHashtagsRequest() { + return { + type: FOLLOWED_HASHTAGS_FETCH_REQUEST, + }; +}; + +export function fetchFollowedHashtagsSuccess(followed_tags, next) { + return { + type: FOLLOWED_HASHTAGS_FETCH_SUCCESS, + followed_tags, + next, + }; +}; + +export function fetchFollowedHashtagsFail(error) { + return { + type: FOLLOWED_HASHTAGS_FETCH_FAIL, + error, + }; +}; + +export function expandFollowedHashtags() { + return (dispatch, getState) => { + const url = getState().getIn(['followed_tags', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowedHashtagsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandFollowedHashtagsFail(error)); + }); + }; +}; + +export function expandFollowedHashtagsRequest() { + return { + type: FOLLOWED_HASHTAGS_EXPAND_REQUEST, + }; +}; + +export function expandFollowedHashtagsSuccess(followed_tags, next) { + return { + type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + followed_tags, + next, + }; +}; + +export function expandFollowedHashtagsFail(error) { + return { + type: FOLLOWED_HASHTAGS_EXPAND_FAIL, + error, + }; +}; + export const followHashtag = name => (dispatch, getState) => { dispatch(followHashtagRequest(name)); diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js index 9530c2a5be..d01b8437ed 100644 --- a/app/javascript/mastodon/components/admin/Trends.js +++ b/app/javascript/mastodon/components/admin/Trends.js @@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent { day.uses)} diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 2481e4783e..46fb89f2fa 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -46,6 +46,7 @@ const messages = defineMessages({ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, @@ -193,7 +194,7 @@ class Header extends ImmutablePureComponent { } if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) { - bellBtn = ; + bellBtn = ; } if (me !== account.get('id')) { @@ -242,6 +243,7 @@ class Header extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); + menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index ceed928bf5..90c85321e5 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -11,6 +11,7 @@ const messages = defineMessages({ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, @@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); + menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index b08307adee..af06ce1bf5 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -43,10 +43,10 @@ export default class Upload extends ImmutablePureComponent {
- {!!media.get('unattached') && ()} +
- {(media.get('description') || '').length === 0 && !!media.get('unattached') && ( + {(media.get('description') || '').length === 0 && (
diff --git a/app/javascript/mastodon/features/followed_tags/index.js b/app/javascript/mastodon/features/followed_tags/index.js new file mode 100644 index 0000000000..0a62ca76d0 --- /dev/null +++ b/app/javascript/mastodon/features/followed_tags/index.js @@ -0,0 +1,89 @@ +import { debounce } from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; +import ColumnHeader from 'mastodon/components/column_header'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import Column from 'mastodon/features/ui/components/column'; +import { Helmet } from 'react-helmet'; +import Hashtag from 'mastodon/components/hashtag'; +import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags'; + +const messages = defineMessages({ + heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' }, +}); + +const mapStateToProps = state => ({ + hashtags: state.getIn(['followed_tags', 'items']), + isLoading: state.getIn(['followed_tags', 'isLoading'], true), + hasMore: !!state.getIn(['followed_tags', 'next']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class FollowedTags extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + hashtags: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + componentDidMount() { + this.props.dispatch(fetchFollowedHashtags()); + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandFollowedHashtags()); + }, 300, { leading: true }); + + render () { + const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props; + + const emptyMessage = ; + + return ( + + + + + {hashtags.map((hashtag) => ( + day.get('uses')).toArray()} + /> + ))} + + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 479f4abd21..b9dbd93900 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent { -