diff --git a/Gemfile.lock b/Gemfile.lock index 03098ab57b..4a659d10e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -613,7 +613,7 @@ GEM activerecord (>= 4.0.0) railties (>= 4.0.0) semantic_range (3.0.0) - sidekiq (6.5.4) + sidekiq (6.5.5) connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.5.0) @@ -651,7 +651,7 @@ GEM sshkit (1.21.2) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - stackprof (0.2.20) + stackprof (0.2.21) statsd-ruby (1.5.0) stoplight (3.0.0) strong_migrations (0.7.9) diff --git a/app/controllers/api/v1/filters/statuses_controller.rb b/app/controllers/api/v1/filters/statuses_controller.rb new file mode 100644 index 0000000000..b6bed306fc --- /dev/null +++ b/app/controllers/api/v1/filters/statuses_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Api::V1::Filters::StatusesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show] + before_action :require_user! + + before_action :set_status_filters, only: :index + before_action :set_status_filter, only: [:show, :destroy] + + def index + render json: @status_filters, each_serializer: REST::FilterStatusSerializer + end + + def create + @status_filter = current_account.custom_filters.find(params[:filter_id]).statuses.create!(resource_params) + + render json: @status_filter, serializer: REST::FilterStatusSerializer + end + + def show + render json: @status_filter, serializer: REST::FilterStatusSerializer + end + + def destroy + @status_filter.destroy! + render_empty + end + + private + + def set_status_filters + filter = current_account.custom_filters.includes(:statuses).find(params[:filter_id]) + @status_filters = filter.statuses + end + + def set_status_filter + @status_filter = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id]) + end + + def resource_params + params.permit(:status_id) + end +end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 1c0f360a93..486edcdcb8 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -83,7 +83,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def check_enabled_registrations - redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations? + redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations? || ip_blocked? end def allowed_registrations? @@ -94,6 +94,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController ENV['OMNIAUTH_ONLY'] == 'true' end + def ip_blocked? + IpBlock.where(severity: :sign_up_block).where('ip >>= ?', request.remote_ip.to_s).exists? + end + def invite_code if params[:user] params[:user][:invite_code] diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb new file mode 100644 index 0000000000..cc493c22c6 --- /dev/null +++ b/app/controllers/filters/statuses_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Filters::StatusesController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_filter + before_action :set_status_filters + before_action :set_body_classes + + PER_PAGE = 20 + + def index + @status_filter_batch_action = Form::StatusFilterBatchAction.new + end + + def batch + @status_filter_batch_action = Form::StatusFilterBatchAction.new(status_filter_batch_action_params.merge(current_account: current_account, filter_id: params[:filter_id], type: action_from_button)) + @status_filter_batch_action.save! + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.statuses.no_status_selected') + ensure + redirect_to edit_filter_path(@filter) + end + + private + + def set_filter + @filter = current_account.custom_filters.find(params[:filter_id]) + end + + def set_status_filters + @status_filters = @filter.statuses.preload(:status).page(params[:page]).per(PER_PAGE) + end + + def status_filter_batch_action_params + params.require(:form_status_filter_batch_action).permit(status_filter_ids: []) + end + + def action_from_button + if params[:remove] + 'remove' + end + end + + def set_body_classes + @body_classes = 'admin' + end +end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 6d778312e5..2ab3b0a744 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -9,7 +9,7 @@ class FiltersController < ApplicationController before_action :set_body_classes def index - @filters = current_account.custom_filters.includes(:keywords).order(:phrase) + @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase) end def new diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 3993b1ea5b..63c1fe038a 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -158,13 +158,13 @@ const excludeTypesFromFilter = filter => { const noOp = () => {}; -export function expandNotifications({ maxId } = {}, done = noOp) { +export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { return (dispatch, getState) => { const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const notifications = getState().get('notifications'); const isLoadingMore = !!maxId; - if (notifications.get('isLoading')) { + if (notifications.get('isLoading') && !forceLoad) { done(); return; } @@ -343,7 +343,7 @@ export function setFilter (filterType) { path: ['notifications', 'quickFilter', 'active'], value: filterType, }); - dispatch(expandNotifications()); + dispatch(expandNotifications({ forceLoad: true })); dispatch(saveSettings()); }; }; diff --git a/app/javascript/flavours/glitch/actions/tags.js b/app/javascript/flavours/glitch/actions/tags.js index 3933da8ba6..4016cf96fc 100644 --- a/app/javascript/flavours/glitch/actions/tags.js +++ b/app/javascript/flavours/glitch/actions/tags.js @@ -75,18 +75,18 @@ export const unfollowHashtag = name => (dispatch, getState) => { }; export const unfollowHashtagRequest = name => ({ - type: HASHTAG_FETCH_REQUEST, + type: HASHTAG_UNFOLLOW_REQUEST, name, }); export const unfollowHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FETCH_SUCCESS, + type: HASHTAG_UNFOLLOW_SUCCESS, name, tag, }); export const unfollowHashtagFail = (name, error) => ({ - type: HASHTAG_FETCH_FAIL, + type: HASHTAG_UNFOLLOW_FAIL, name, error, }); diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index be2468d688..9ff745355f 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -139,17 +139,9 @@ export default class IconButton extends React.PureComponent { ); - if (href) { - return ( - + if (href && !this.prop) { + contents = ( + {contents} ); diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 1e61526b1b..51d7886d7a 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -43,7 +43,7 @@ const initialState = ImmutableMap({ unread: 0, lastReadId: '0', readMarkerId: '0', - isLoading: false, + isLoading: 0, cleaningMode: false, isTabVisible: true, browserSupport: false, @@ -121,7 +121,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece } } - mutable.set('isLoading', false); + mutable.update('isLoading', (nbLoading) => nbLoading - 1); }); }; @@ -249,10 +249,10 @@ export default function notifications(state = initialState, action) { return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); case NOTIFICATIONS_EXPAND_REQUEST: case NOTIFICATIONS_DELETE_MARKED_REQUEST: - return state.set('isLoading', true); + return state.set('isLoading', (nbLoading) => nbLoading + 1); case NOTIFICATIONS_DELETE_MARKED_FAIL: case NOTIFICATIONS_EXPAND_FAIL: - return state.set('isLoading', false); + return state.set('isLoading', (nbLoading) => nbLoading - 1); case NOTIFICATIONS_FILTER_SET: return state.set('items', ImmutableList()).set('hasMore', true); case NOTIFICATIONS_SCROLL_TOP: @@ -287,7 +287,7 @@ export default function notifications(state = initialState, action) { return markForDelete(state, action.id, action.yes); case NOTIFICATIONS_DELETE_MARKED_SUCCESS: - return deleteMarkedNotifs(state).set('isLoading', false); + return deleteMarkedNotifs(state).set('isLoading', (nbLoading) => nbLoading - 1); case NOTIFICATIONS_ENTER_CLEARING_MODE: st = state.set('cleaningMode', action.yes); diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js new file mode 100644 index 0000000000..76326802ea --- /dev/null +++ b/app/javascript/mastodon/actions/filters.js @@ -0,0 +1,93 @@ +import api from '../api'; +import { openModal } from './modal'; + +export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; +export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; +export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; + +export const FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST'; +export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS'; +export const FILTERS_STATUS_CREATE_FAIL = 'FILTERS_STATUS_CREATE_FAIL'; + +export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; +export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; +export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; + +export const initAddFilter = (status, { contextType }) => dispatch => + dispatch(openModal('FILTER', { + statusId: status?.get('id'), + contextType: contextType, + })); + +export const fetchFilters = () => (dispatch, getState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v2/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); +}; + +export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(createFilterStatusRequest()); + + api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => { + dispatch(createFilterStatusSuccess(response.data)); + if (onSuccess) onSuccess(); + }).catch(error => { + dispatch(createFilterStatusFail(error)); + if (onFail) onFail(); + }); +}; + +export const createFilterStatusRequest = () => ({ + type: FILTERS_STATUS_CREATE_REQUEST, +}); + +export const createFilterStatusSuccess = filter_status => ({ + type: FILTERS_STATUS_CREATE_SUCCESS, + filter_status, +}); + +export const createFilterStatusFail = error => ({ + type: FILTERS_STATUS_CREATE_FAIL, + error, +}); + +export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(createFilterRequest()); + + api(getState).post('/api/v2/filters', params).then(response => { + dispatch(createFilterSuccess(response.data)); + if (onSuccess) onSuccess(response.data); + }).catch(error => { + dispatch(createFilterFail(error)); + if (onFail) onFail(); + }); +}; + +export const createFilterRequest = () => ({ + type: FILTERS_CREATE_REQUEST, +}); + +export const createFilterSuccess = filter => ({ + type: FILTERS_CREATE_SUCCESS, + filter, +}); + +export const createFilterFail = error => ({ + type: FILTERS_CREATE_FAIL, + error, +}); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 3c42f71da3..7f62e6c042 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -141,13 +141,13 @@ const excludeTypesFromFilter = filter => { const noOp = () => {}; -export function expandNotifications({ maxId } = {}, done = noOp) { +export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { return (dispatch, getState) => { const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const notifications = getState().get('notifications'); const isLoadingMore = !!maxId; - if (notifications.get('isLoading')) { + if (notifications.get('isLoading') && !forceLoad) { done(); return; } @@ -243,7 +243,7 @@ export function setFilter (filterType) { path: ['notifications', 'quickFilter', 'active'], value: filterType, }); - dispatch(expandNotifications()); + dispatch(expandNotifications({ forceLoad: true })); dispatch(saveSettings()); }; }; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index adc24eabfb..32a4f1f857 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -42,9 +42,9 @@ export function fetchStatusRequest(id, skipLoading) { }; }; -export function fetchStatus(id) { +export function fetchStatus(id, forceFetch = false) { return (dispatch, getState) => { - const skipLoading = getState().getIn(['statuses', id], null) !== null; + const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; dispatch(fetchContext(id)); diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js index 216e5b5413..37e79d4cba 100644 --- a/app/javascript/mastodon/actions/tags.js +++ b/app/javascript/mastodon/actions/tags.js @@ -75,18 +75,18 @@ export const unfollowHashtag = name => (dispatch, getState) => { }; export const unfollowHashtagRequest = name => ({ - type: HASHTAG_FETCH_REQUEST, + type: HASHTAG_UNFOLLOW_REQUEST, name, }); export const unfollowHashtagSuccess = (name, tag) => ({ - type: HASHTAG_FETCH_SUCCESS, + type: HASHTAG_UNFOLLOW_SUCCESS, name, tag, }); export const unfollowHashtagFail = (name, error) => ({ - type: HASHTAG_FETCH_FAIL, + type: HASHTAG_UNFOLLOW_FAIL, name, error, }); diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 81743a1dbe..47945c475f 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -131,17 +131,9 @@ export default class IconButton extends React.PureComponent { ); - if (href) { - return ( - + if (href && !this.prop) { + contents = ( + {contents} ); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index dee935a6cd..6fc132bf50 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -80,6 +80,7 @@ class Status extends ImmutablePureComponent { onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, onBlock: PropTypes.func, + onAddFilter: PropTypes.func, onEmbed: PropTypes.func, onHeightChange: PropTypes.func, onToggleHidden: PropTypes.func, @@ -515,7 +516,7 @@ class Status extends ImmutablePureComponent { {media} - + diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index d44da482df..4b384e6e54 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -44,6 +44,7 @@ const messages = defineMessages({ unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, }); const mapStateToProps = (state, { status }) => ({ @@ -80,6 +81,7 @@ class StatusActionBar extends ImmutablePureComponent { onPin: PropTypes.func, onBookmark: PropTypes.func, onFilter: PropTypes.func, + onAddFilter: PropTypes.func, withDismiss: PropTypes.bool, withCounters: PropTypes.bool, scrollKey: PropTypes.string, @@ -211,8 +213,8 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onMuteConversation(this.props.status); } - handleFilter = () => { - this.props.onFilter(); + handleFilterClick = () => { + this.props.onAddFilter(this.props.status); } handleCopy = () => { @@ -235,7 +237,7 @@ class StatusActionBar extends ImmutablePureComponent { } - handleFilterClick = () => { + handleHideClick = () => { this.props.onFilter(); } @@ -294,6 +296,12 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick }); } + if (!this.props.onFilter) { + menu.push(null); + menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick }); + menu.push(null); + } + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport }); if (account.get('acct') !== account.get('username')) { @@ -343,7 +351,7 @@ class StatusActionBar extends ImmutablePureComponent { ); const filterButton = this.props.onFilter && ( - + ); return ( diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index ef0aca13a6..28698b082d 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -34,6 +34,9 @@ import { blockDomain, unblockDomain, } from '../actions/domain_blocks'; +import { + initAddFilter, +} from '../actions/filters'; import { initMuteModal } from '../actions/mutes'; import { initBlockModal } from '../actions/blocks'; import { initBoostModal } from '../actions/boosts'; @@ -66,7 +69,7 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -const mapDispatchToProps = (dispatch, { intl }) => ({ +const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ onReply (status, router) { dispatch((_, getState) => { @@ -176,6 +179,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(initReport(status.get('account'), status)); }, + onAddFilter (status) { + dispatch(initAddFilter(status, { contextType })); + }, + onMute (account) { dispatch(initMuteModal(account)); }, diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.js b/app/javascript/mastodon/features/compose/components/language_dropdown.js index d76490c775..0af3db7a46 100644 --- a/app/javascript/mastodon/features/compose/components/language_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/language_dropdown.js @@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring'; import { supportsPassiveEvents } from 'detect-passive-events'; import classNames from 'classnames'; import { languages as preloadedLanguages } from 'mastodon/initial_state'; +import { loupeIcon, deleteIcon } from 'mastodon/utils/icons'; import fuzzysort from 'fuzzysort'; const messages = defineMessages({ @@ -16,22 +17,6 @@ const messages = defineMessages({ clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, }); -// Copied from emoji-mart for consistency with emoji picker and since -// they don't export the icons in the package -const icons = { - loupe: ( - - - - ), - - delete: ( - - - - ), -}; - const listenerOptions = supportsPassiveEvents ? { passive: true } : false; class LanguageDropdownMenu extends React.PureComponent { @@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
- +
diff --git a/app/javascript/mastodon/features/filters/added_to_filter.js b/app/javascript/mastodon/features/filters/added_to_filter.js new file mode 100644 index 0000000000..3785eb3c5a --- /dev/null +++ b/app/javascript/mastodon/features/filters/added_to_filter.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import { toServerSideType } from 'mastodon/utils/filters'; +import Button from 'mastodon/components/button'; +import { connect } from 'react-redux'; + +const mapStateToProps = (state, { filterId }) => ({ + filter: state.getIn(['filters', filterId]), +}); + +export default @connect(mapStateToProps) +class AddedToFilter extends React.PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + contextType: PropTypes.string, + filter: ImmutablePropTypes.map.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + handleCloseClick = () => { + const { onClose } = this.props; + onClose(); + }; + + render () { + const { filter, contextType } = this.props; + + let expiredMessage = null; + if (filter.get('expires_at') && filter.get('expires_at') < new Date()) { + expiredMessage = ( + +

+

+ +

+
+ ); + } + + let contextMismatchMessage = null; + if (contextType && !filter.get('context').includes(toServerSideType(contextType))) { + contextMismatchMessage = ( + +

+

+ +

+
+ ); + } + + const settings_link = ( + + + + ); + + return ( + +

+

+ +

+ + {expiredMessage} + {contextMismatchMessage} + +

+

+ +

+ +
+ +
+ +
+ + ); + } + +} diff --git a/app/javascript/mastodon/features/filters/select_filter.js b/app/javascript/mastodon/features/filters/select_filter.js new file mode 100644 index 0000000000..b5b3545296 --- /dev/null +++ b/app/javascript/mastodon/features/filters/select_filter.js @@ -0,0 +1,192 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { toServerSideType } from 'mastodon/utils/filters'; +import { loupeIcon, deleteIcon } from 'mastodon/utils/icons'; +import Icon from 'mastodon/components/icon'; +import fuzzysort from 'fuzzysort'; + +const messages = defineMessages({ + search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' }, + clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, +}); + +const mapStateToProps = (state, { contextType }) => ({ + filters: Array.from(state.get('filters').values()).map((filter) => [ + filter.get('id'), + filter.get('title'), + filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'), + filter.get('expires_at') && filter.get('expires_at') < new Date(), + contextType && !filter.get('context').includes(toServerSideType(contextType)), + ]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class SelectFilter extends React.PureComponent { + + static propTypes = { + onSelectFilter: PropTypes.func.isRequired, + onNewFilter: PropTypes.func.isRequired, + filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)), + intl: PropTypes.object.isRequired, + }; + + state = { + searchValue: '', + }; + + search () { + const { filters } = this.props; + const { searchValue } = this.state; + + if (searchValue === '') { + return filters; + } + + return fuzzysort.go(searchValue, filters, { + keys: ['1', '2'], + limit: 5, + threshold: -10000, + }).map(result => result.obj); + } + + renderItem = filter => { + let warning = null; + if (filter[3] || filter[4]) { + warning = ( + + ( + {filter[3] && } + {filter[3] && filter[4] && ', '} + {filter[4] && } + ) + + ); + } + + return ( +
+ {filter[1]} {warning} +
+ ); + } + + renderCreateNew (name) { + return ( +
+ +
+ ); + } + + handleSearchChange = ({ target }) => { + this.setState({ searchValue: target.value }); + } + + setListRef = c => { + this.listNode = c; + } + + handleKeyDown = e => { + const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget); + + let element = null; + + switch(e.key) { + case ' ': + case 'Enter': + e.currentTarget.click(); + break; + case 'ArrowDown': + element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + break; + case 'ArrowUp': + element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + } else { + element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + } + break; + case 'Home': + element = this.listNode.firstChild; + break; + case 'End': + element = this.listNode.lastChild; + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleSearchKeyDown = e => { + let element = null; + + switch(e.key) { + case 'Tab': + case 'ArrowDown': + element = this.listNode.firstChild; + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + + break; + } + } + + handleClear = () => { + this.setState({ searchValue: '' }); + } + + handleItemClick = e => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + this.props.onSelectFilter(value); + } + + handleNewFilterClick = e => { + e.preventDefault(); + + this.props.onNewFilter(this.state.searchValue); + }; + + render () { + const { intl } = this.props; + + const { searchValue } = this.state; + const isSearching = searchValue !== ''; + const results = this.search(); + + return ( + +

+

+ +
+ + +
+ +
+ {results.map(this.renderItem)} + {isSearching && this.renderCreateNew(searchValue) } +
+ +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/filter_modal.js b/app/javascript/mastodon/features/ui/components/filter_modal.js new file mode 100644 index 0000000000..376db961d1 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/filter_modal.js @@ -0,0 +1,134 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { fetchStatus } from 'mastodon/actions/statuses'; +import { fetchFilters, createFilter, createFilterStatus } from 'mastodon/actions/filters'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import IconButton from 'mastodon/components/icon_button'; +import SelectFilter from 'mastodon/features/filters/select_filter'; +import AddedToFilter from 'mastodon/features/filters/added_to_filter'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @connect(undefined) +@injectIntl +class FilterModal extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + contextType: PropTypes.string, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + step: 'select', + filterId: null, + isSubmitting: false, + isSubmitted: false, + }; + + handleNewFilterSuccess = (result) => { + this.handleSelectFilter(result.id); + }; + + handleSuccess = () => { + const { dispatch, statusId } = this.props; + dispatch(fetchStatus(statusId, true)); + this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' }); + }; + + handleFail = () => { + this.setState({ isSubmitting: false }); + }; + + handleNextStep = step => { + this.setState({ step }); + }; + + handleSelectFilter = (filterId) => { + const { dispatch, statusId } = this.props; + + this.setState({ isSubmitting: true, filterId }); + + dispatch(createFilterStatus({ + filter_id: filterId, + status_id: statusId, + }, this.handleSuccess, this.handleFail)); + }; + + handleNewFilter = (title) => { + const { dispatch } = this.props; + + this.setState({ isSubmitting: true }); + + dispatch(createFilter({ + title, + context: ['home', 'notifications', 'public', 'thread', 'account'], + action: 'warn', + }, this.handleNewFilterSuccess, this.handleFail)); + }; + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(fetchFilters()); + } + + render () { + const { + intl, + statusId, + contextType, + onClose, + } = this.props; + + const { + step, + filterId, + } = this.state; + + let stepComponent; + + switch(step) { + case 'select': + stepComponent = ( + + ); + break; + case 'create': + stepComponent = null; + break; + case 'submitted': + stepComponent = ( + + ); + } + + return ( +
+
+ + +
+ +
+ {stepComponent} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 3fc235849d..b2c30e0791 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -20,6 +20,7 @@ import { ListEditor, ListAdder, CompareHistoryModal, + FilterModal, } from 'mastodon/features/ui/util/async-components'; const MODAL_COMPONENTS = { @@ -37,6 +38,7 @@ const MODAL_COMPONENTS = { 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), 'LIST_ADDER': ListAdder, 'COMPARE_HISTORY': CompareHistoryModal, + 'FILTER': FilterModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 92c683e2f8..29b06206a1 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -161,3 +161,7 @@ export function CompareHistoryModal () { export function Explore () { return import(/* webpackChunkName: "features/explore" */'../../explore'); } + +export function FilterModal () { + return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal'); +} diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js index 14b7040273..cc1d3349c5 100644 --- a/app/javascript/mastodon/reducers/filters.js +++ b/app/javascript/mastodon/reducers/filters.js @@ -1,4 +1,5 @@ import { FILTERS_IMPORT } from '../actions/importer'; +import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters'; import { Map as ImmutableMap, is, fromJS } from 'immutable'; const normalizeFilter = (state, filter) => { @@ -7,13 +8,17 @@ const normalizeFilter = (state, filter) => { title: filter.title, context: filter.context, filter_action: filter.filter_action, + keywords: filter.keywords, expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null, }); if (is(state.get(filter.id), normalizedFilter)) { return state; } else { - return state.set(filter.id, normalizedFilter); + // Do not overwrite keywords when receiving a partial filter + return state.update(filter.id, ImmutableMap(), (old) => ( + old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter) + )); } }; @@ -27,6 +32,10 @@ const normalizeFilters = (state, filters) => { export default function filters(state = ImmutableMap(), action) { switch(action.type) { + case FILTERS_CREATE_SUCCESS: + return normalizeFilter(state, action.filter); + case FILTERS_FETCH_SUCCESS: + //TODO: handle deleting obsolete filters case FILTERS_IMPORT: return normalizeFilters(state, action.filters); default: diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 4563118eaa..eb34edb633 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -41,7 +41,7 @@ const initialState = ImmutableMap({ lastReadId: '0', readMarkerId: '0', isTabVisible: true, - isLoading: false, + isLoading: 0, browserSupport: false, browserPermission: 'default', }); @@ -115,7 +115,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece } } - mutable.set('isLoading', false); + mutable.update('isLoading', (nbLoading) => nbLoading - 1); }); }; @@ -214,9 +214,9 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_LOAD_PENDING: return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); case NOTIFICATIONS_EXPAND_REQUEST: - return state.set('isLoading', true); + return state.update('isLoading', (nbLoading) => nbLoading + 1); case NOTIFICATIONS_EXPAND_FAIL: - return state.set('isLoading', false); + return state.update('isLoading', (nbLoading) => nbLoading - 1); case NOTIFICATIONS_FILTER_SET: return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true); case NOTIFICATIONS_SCROLL_TOP: diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 187e3306dd..3dd7f48972 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -1,5 +1,6 @@ import { createSelector } from 'reselect'; import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import { toServerSideType } from 'mastodon/utils/filters'; import { me } from '../initial_state'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); @@ -20,23 +21,6 @@ export const makeGetAccount = () => { }); }; -const toServerSideType = columnType => { - switch (columnType) { - case 'home': - case 'notifications': - case 'public': - case 'thread': - case 'account': - return columnType; - default: - if (columnType.indexOf('list:') > -1) { - return 'home'; - } else { - return 'public'; // community, account, hashtag - } - } -}; - const getFilters = (state, { contextType }) => { if (!contextType) return null; @@ -73,6 +57,7 @@ export const makeGetStatus = () => { if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { return null; } + filterResults = filterResults.filter(result => filters.has(result.get('filter'))); if (!filterResults.isEmpty()) { filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); } diff --git a/app/javascript/mastodon/utils/filters.js b/app/javascript/mastodon/utils/filters.js new file mode 100644 index 0000000000..97b433a991 --- /dev/null +++ b/app/javascript/mastodon/utils/filters.js @@ -0,0 +1,16 @@ +export const toServerSideType = columnType => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + case 'account': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; diff --git a/app/javascript/mastodon/utils/icons.js b/app/javascript/mastodon/utils/icons.js new file mode 100644 index 0000000000..be566032e0 --- /dev/null +++ b/app/javascript/mastodon/utils/icons.js @@ -0,0 +1,13 @@ +// Copied from emoji-mart for consistency with emoji picker and since +// they don't export the icons in the package +export const loupeIcon = ( + + + +); + +export const deleteIcon = ( + + + +); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 24efcc39fb..00f6540824 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5233,6 +5233,16 @@ a.status-card.compact:hover { line-height: 22px; color: lighten($inverted-text-color, 16%); margin-bottom: 30px; + + a { + text-decoration: none; + color: $inverted-text-color; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } } &__actions { @@ -5379,6 +5389,14 @@ a.status-card.compact:hover { background: transparent; margin: 15px 0; } + + .emoji-mart-search { + padding-right: 10px; + } + + .emoji-mart-search-icon { + right: 10px + 5px; + } } .report-modal__container { diff --git a/app/lib/request.rb b/app/lib/request.rb index 4289da9333..f5123d776a 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -31,7 +31,7 @@ class Request @url = Addressable::URI.parse(url).normalize @http_client = options.delete(:http_client) @options = options.merge(socket_class: use_proxy? ? ProxySocket : Socket) - @options = @options.merge(Rails.configuration.x.http_client_proxy) if use_proxy? + @options = @options.merge(proxy_url) if use_proxy? @headers = {} raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service? @@ -141,11 +141,23 @@ class Request end def use_proxy? - Rails.configuration.x.http_client_proxy.present? + proxy_url.present? + end + + def proxy_url + if hidden_service? && Rails.configuration.x.http_client_hidden_proxy.present? + Rails.configuration.x.http_client_hidden_proxy + else + Rails.configuration.x.http_client_proxy + end end def block_hidden_service? - !Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match?(@url.host) + !Rails.configuration.x.access_to_hidden_service && hidden_service? + end + + def hidden_service? + /\.(onion|i2p)$/.match?(@url.host) end module ClientLimit diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index a7401362f4..9b358d338e 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -249,15 +249,7 @@ module AccountInteractions def status_matches_filters(status) active_filters = CustomFilter.cached_filters_for(id) - - filter_matches = active_filters.filter_map do |filter, rules| - next if rules[:keywords].blank? - - match = rules[:keywords].match(status.proper.searchable_text) - FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil? - end - - filter_matches + CustomFilter.apply_cached_filters(active_filters, status) end def followers_for_local_distribution diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 985eab1254..da2a914934 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -34,6 +34,7 @@ class CustomFilter < ApplicationRecord belongs_to :account has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy + has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true validates :title, :context, presence: true @@ -62,8 +63,10 @@ class CustomFilter < ApplicationRecord def self.cached_filters_for(account_id) active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do + filters_hash = {} + scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) - scope.to_a.group_by(&:custom_filter).map do |filter, keywords| + scope.to_a.group_by(&:custom_filter).each do |filter, keywords| keywords.map! do |keyword| if keyword.whole_word sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : '' @@ -74,13 +77,34 @@ class CustomFilter < ApplicationRecord /#{Regexp.escape(keyword.keyword)}/i end end - [filter, { keywords: Regexp.union(keywords) }] + + filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter } + end.to_h + + scope = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) + scope.to_a.group_by(&:custom_filter).each do |filter, statuses| + filters_hash[filter.id] ||= { filter: filter } + filters_hash[filter.id].merge!(status_ids: statuses.map(&:status_id)) end + + filters_hash.values.map { |cache| [cache.delete(:filter), cache] } end.to_a active_filters.select { |custom_filter, _| !custom_filter.expired? } end + def self.apply_cached_filters(cached_filters, status) + cached_filters.filter_map do |filter, rules| + match = rules[:keywords].match(status.proper.searchable_text) if rules[:keywords].present? + keyword_matches = [match.to_s] unless match.nil? + + status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present? + + next if keyword_matches.blank? && status_matches.blank? + FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches) + end + end + def prepare_cache_invalidation! @should_invalidate_cache = true end diff --git a/app/models/custom_filter_status.rb b/app/models/custom_filter_status.rb new file mode 100644 index 0000000000..b6bea13943 --- /dev/null +++ b/app/models/custom_filter_status.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_filter_statuses +# +# id :bigint(8) not null, primary key +# custom_filter_id :bigint(8) not null +# status_id :bigint(8) default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomFilterStatus < ApplicationRecord + belongs_to :custom_filter + belongs_to :status + + validates :status, uniqueness: { scope: :custom_filter } + validate :validate_status_access + + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! + + private + + def validate_status_access + errors.add(:status_id, :invalid) unless StatusPolicy.new(custom_filter.account, status).show? + end + + def prepare_cache_invalidation! + custom_filter.prepare_cache_invalidation! + end + + def invalidate_cache! + custom_filter.invalidate_cache! + end +end diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb index 0e1e663c10..f9d74332b8 100644 --- a/app/models/email_domain_block.rb +++ b/app/models/email_domain_block.rb @@ -30,32 +30,56 @@ class EmailDomainBlock < ApplicationRecord @history ||= Trends::History.new('email_domain_blocks', id) end - def self.block?(domain_or_domains, attempt_ip: nil) - domains = Array(domain_or_domains).map do |str| - domain = begin - if str.include?('@') - str.split('@', 2).last - else - str - end - end + class Matcher + def initialize(domain_or_domains, attempt_ip: nil) + @uris = extract_uris(domain_or_domains) + @attempt_ip = attempt_ip + end - TagManager.instance.normalize_domain(domain) if domain.present? - rescue Addressable::URI::InvalidURIError - nil + def match? + blocking? || invalid_uri? end - # If some of the inputs passed in are invalid, we definitely want to - # block the attempt, but we also want to register hits against any - # other valid matches + private - blocked = domains.any?(&:nil?) + def invalid_uri? + @uris.any?(&:nil?) + end - where(domain: domains).find_each do |block| - blocked = true - block.history.add(attempt_ip) if attempt_ip.present? + def blocking? + blocks = EmailDomainBlock.where(domain: domains_with_variants).order(Arel.sql('char_length(domain) desc')) + blocks.each { |block| block.history.add(@attempt_ip) } if @attempt_ip.present? + blocks.any? end - blocked + def domains_with_variants + @uris.flat_map do |uri| + next if uri.nil? + + segments = uri.normalized_host.split('.') + + segments.map.with_index { |_, i| segments[i..-1].join('.') } + end + end + + def extract_uris(domain_or_domains) + Array(domain_or_domains).map do |str| + domain = begin + if str.include?('@') + str.split('@', 2).last + else + str + end + end + + Addressable::URI.new.tap { |u| u.host = domain.strip } if domain.present? + rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError + nil + end + end + end + + def self.block?(domain_or_domains, attempt_ip: nil) + Matcher.new(domain_or_domains, attempt_ip: attempt_ip).match? end end diff --git a/app/models/form/status_filter_batch_action.rb b/app/models/form/status_filter_batch_action.rb new file mode 100644 index 0000000000..d87bd5cc4d --- /dev/null +++ b/app/models/form/status_filter_batch_action.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Form::StatusFilterBatchAction + include ActiveModel::Model + include AccountableConcern + include Authorization + + attr_accessor :current_account, :type, + :status_filter_ids, :filter_id + + def save! + process_action! + end + + private + + def status_filters + filter = current_account.custom_filters.find(filter_id) + filter.statuses.where(id: status_filter_ids) + end + + def process_action! + return if status_filter_ids.empty? + + case type + when 'remove' + handle_remove! + end + end + + def handle_remove! + status_filters.destroy_all + end +end diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb index aedd3ca0d4..e1ab59806e 100644 --- a/app/models/ip_block.rb +++ b/app/models/ip_block.rb @@ -19,6 +19,7 @@ class IpBlock < ApplicationRecord enum severity: { sign_up_requires_approval: 5000, + sign_up_block: 5500, no_access: 9999, } diff --git a/app/models/user.rb b/app/models/user.rb index ffad4ae5ac..46f66526ea 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -94,7 +94,7 @@ class User < ApplicationRecord validates :invite_request, presence: true, on: :create, if: :invite_text_required? validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? - validates_with BlacklistedEmailValidator, if: -> { !confirmed? } + validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? } validates_with EmailMxValidator, if: :validate_email_dns? validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create diff --git a/app/presenters/filter_result_presenter.rb b/app/presenters/filter_result_presenter.rb index 677225f5ec..1e9e8f3c19 100644 --- a/app/presenters/filter_result_presenter.rb +++ b/app/presenters/filter_result_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class FilterResultPresenter < ActiveModelSerializers::Model - attributes :filter, :keyword_matches + attributes :filter, :keyword_matches, :status_matches end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index d7ffb1954a..be818a2de7 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -33,12 +33,7 @@ class StatusRelationshipsPresenter active_filters = CustomFilter.cached_filters_for(current_account_id) @filters_map = statuses.each_with_object({}) do |status, h| - filter_matches = active_filters.filter_map do |filter, rules| - next if rules[:keywords].blank? - - match = rules[:keywords].match(status.proper.searchable_text) - FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil? - end + filter_matches = CustomFilter.apply_cached_filters(active_filters, status) unless filter_matches.empty? h[status.id] = filter_matches diff --git a/app/serializers/rest/filter_result_serializer.rb b/app/serializers/rest/filter_result_serializer.rb index 0ef4db79a8..54ead2f1f1 100644 --- a/app/serializers/rest/filter_result_serializer.rb +++ b/app/serializers/rest/filter_result_serializer.rb @@ -3,4 +3,9 @@ class REST::FilterResultSerializer < ActiveModel::Serializer belongs_to :filter, serializer: REST::FilterSerializer has_many :keyword_matches + has_many :status_matches + + def status_matches + object.status_matches&.map(&:to_s) + end end diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb index 98d7edb175..8816dd8076 100644 --- a/app/serializers/rest/filter_serializer.rb +++ b/app/serializers/rest/filter_serializer.rb @@ -3,6 +3,7 @@ class REST::FilterSerializer < ActiveModel::Serializer attributes :id, :title, :context, :expires_at, :filter_action has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested? + has_many :statuses, serializer: REST::FilterStatusSerializer, if: :rules_requested? def id object.id.to_s diff --git a/app/serializers/rest/filter_status_serializer.rb b/app/serializers/rest/filter_status_serializer.rb new file mode 100644 index 0000000000..6bcbaa249c --- /dev/null +++ b/app/serializers/rest/filter_status_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class REST::FilterStatusSerializer < ActiveModel::Serializer + attributes :id, :status_id + + def id + object.id.to_s + end + + def status_id + object.status_id.to_s + end +end diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb index e006941577..3833327bbc 100644 --- a/app/services/app_sign_up_service.rb +++ b/app/services/app_sign_up_service.rb @@ -2,23 +2,67 @@ class AppSignUpService < BaseService def call(app, remote_ip, params) - return unless allowed_registrations? + @app = app + @remote_ip = remote_ip + @params = params - user_params = params.slice(:email, :password, :agreement, :locale) - account_params = params.slice(:username) - invite_request_params = { text: params[:reason] } - user = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params)) + raise Mastodon::NotPermittedError unless allowed_registrations? - Doorkeeper::AccessToken.create!(application: app, - resource_owner_id: user.id, - scopes: app.scopes, - expires_in: Doorkeeper.configuration.access_token_expires_in, - use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?) + ApplicationRecord.transaction do + create_user! + create_access_token! + end + + @access_token end private + def create_user! + @user = User.create!( + user_params.merge(created_by_application: @app, sign_up_ip: @remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params) + ) + end + + def create_access_token! + @access_token = Doorkeeper::AccessToken.create!( + application: @app, + resource_owner_id: @user.id, + scopes: @app.scopes, + expires_in: Doorkeeper.configuration.access_token_expires_in, + use_refresh_token: Doorkeeper.configuration.refresh_token_enabled? + ) + end + + def user_params + @params.slice(:email, :password, :agreement, :locale) + end + + def account_params + @params.slice(:username) + end + + def invite_request_params + { text: @params[:reason] } + end + def allowed_registrations? - Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode + registrations_open? && !single_user_mode? && !omniauth_only? && !ip_blocked? + end + + def registrations_open? + Setting.registrations_mode != 'none' + end + + def single_user_mode? + Rails.configuration.x.single_user_mode + end + + def omniauth_only? + ENV['OMNIAUTH_ONLY'] == 'true' + end + + def ip_blocked? + IpBlock.where(severity: :sign_up_block).where('ip >>= ?', @remote_ip.to_s).exists? end end diff --git a/app/views/admin/roles/_form.html.haml b/app/views/admin/roles/_form.html.haml index 9beaf619fa..31f78f2405 100644 --- a/app/views/admin/roles/_form.html.haml +++ b/app/views/admin/roles/_form.html.haml @@ -13,7 +13,7 @@ = f.input :position, wrapper: :with_label, input_html: { max: current_user.role.position - 1 } .fields-group - = f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000' } + = f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000', type: 'color' } %hr.spacer/ diff --git a/app/views/filters/_filter.html.haml b/app/views/filters/_filter.html.haml index 2ab014081c..9993ad2ee8 100644 --- a/app/views/filters/_filter.html.haml +++ b/app/views/filters/_filter.html.haml @@ -22,6 +22,15 @@ - keywords = filter.keywords.map(&:keyword) - keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO = keywords.join(', ') + - unless filter.statuses.empty? + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('comment') + .permissions-list__item__text + .permissions-list__item__text__title + = t('filters.index.statuses', count: filter.statuses.size) + .permissions-list__item__text__type + = t('filters.index.statuses_long', count: filter.statuses.size) .announcements-list__item__action-bar .announcements-list__item__meta diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml index 1a52faa7af..c58978f5a3 100644 --- a/app/views/filters/_filter_fields.html.haml +++ b/app/views/filters/_filter_fields.html.haml @@ -14,6 +14,13 @@ %hr.spacer/ +- unless f.object.statuses.empty? + %h4= t('filters.edit.statuses') + + %p.muted-hint= t('filters.edit.statuses_hint_html', path: filter_statuses_path(f.object)) + + %hr.spacer/ + %h4= t('filters.edit.keywords') .table-wrapper diff --git a/app/views/filters/statuses/_status_filter.html.haml b/app/views/filters/statuses/_status_filter.html.haml new file mode 100644 index 0000000000..ba1170cf92 --- /dev/null +++ b/app/views/filters/statuses/_status_filter.html.haml @@ -0,0 +1,37 @@ +- status = status_filter.status.proper + +.batch-table__row + %label.batch-table__row__select.batch-checkbox + = f.check_box :status_filter_ids, { multiple: true, include_hidden: false }, status_filter.id + .batch-table__row__content + .status__content>< + - if status.spoiler_text.blank? + = prerender_custom_emojis(status_content_format(status), status.emojis) + - else + %details< + %summary>< + %strong> Content warning: #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)} + = prerender_custom_emojis(status_content_format(status), status.emojis) + + - status.ordered_media_attachments.each do |media_attachment| + %abbr{ title: media_attachment.description } + = fa_icon 'link' + = media_attachment.file_file_name + + .detailed-status__meta + = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'name-tag', target: '_blank', rel: 'noopener noreferrer' do + = image_tag(status.account.avatar.url, width: 15, height: 15, alt: display_name(status.account), class: 'avatar') + .username= status.account.acct + · + = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do + %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) + - if status.edited? + · + = t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted')) + · + = fa_visibility_icon(status) + = t("statuses.visibilities.#{status.visibility}") + - if status.sensitive? + · + = fa_icon('eye-slash fw') + = t('stream_entries.sensitive_content') diff --git a/app/views/filters/statuses/index.html.haml b/app/views/filters/statuses/index.html.haml new file mode 100644 index 0000000000..886de58fa0 --- /dev/null +++ b/app/views/filters/statuses/index.html.haml @@ -0,0 +1,38 @@ +- content_for :header_tags do + = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' + +- content_for :page_title do + = t('filters.statuses.index.title') + \- + = @filter.title + +.filters + .back-link + = link_to edit_filter_path(@filter) do + = fa_icon 'chevron-left fw' + = t('filters.statuses.back_to_filter') + +%p.hint= t('filters.statuses.index.hint') + +%hr.spacer/ + += form_for(@status_filter_batch_action, url: batch_filter_statuses_path(@filter.id)) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - Admin::StatusFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .batch-table + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + - unless @status_filters.empty? + = f.button safe_join([fa_icon('times'), t('filters.statuses.batch.remove')]), name: :remove, class: 'table-action-link', type: :submit + .batch-table__body + - if @status_filters.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'status_filter', collection: @status_filters, locals: { f: f } + += paginate @status_filters diff --git a/chart/templates/cronjob-media-remove.yaml b/chart/templates/cronjob-media-remove.yaml index 726e100cf8..160aee2042 100644 --- a/chart/templates/cronjob-media-remove.yaml +++ b/chart/templates/cronjob-media-remove.yaml @@ -12,6 +12,10 @@ spec: template: metadata: name: {{ include "mastodon.fullname" . }}-media-remove + {{- with .Values.jobAnnotations }} + annotations: + {{- toYaml . | nindent 12 }} + {{- end }} spec: restartPolicy: OnFailure {{- if (not .Values.mastodon.s3.enabled) }} diff --git a/chart/templates/deployment-web.yaml b/chart/templates/deployment-web.yaml index 5e22ca5393..ab722c77b1 100644 --- a/chart/templates/deployment-web.yaml +++ b/chart/templates/deployment-web.yaml @@ -70,6 +70,18 @@ spec: key: redis-password - name: "PORT" value: {{ .Values.mastodon.web.port | quote }} + {{- if (and .Values.mastodon.s3.enabled .Values.mastodon.s3.existingSecret) }} + - name: "AWS_SECRET_ACCESS_KEY" + valueFrom: + secretKeyRef: + name: {{ .Values.mastodon.s3.existingSecret }} + key: AWS_SECRET_ACCESS_KEY + - name: "AWS_ACCESS_KEY_ID" + valueFrom: + secretKeyRef: + name: {{ .Values.mastodon.s3.existingSecret }} + key: AWS_ACCESS_KEY_ID + {{- end -}} {{- if (not .Values.mastodon.s3.enabled) }} volumeMounts: - name: assets diff --git a/chart/templates/job-assets-precompile.yaml b/chart/templates/job-assets-precompile.yaml index 4aa8d1407c..faa51a20d9 100644 --- a/chart/templates/job-assets-precompile.yaml +++ b/chart/templates/job-assets-precompile.yaml @@ -12,6 +12,10 @@ spec: template: metadata: name: {{ include "mastodon.fullname" . }}-assets-precompile + {{- with .Values.jobAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} spec: restartPolicy: Never {{- if (not .Values.mastodon.s3.enabled) }} diff --git a/chart/templates/job-chewy-upgrade.yaml b/chart/templates/job-chewy-upgrade.yaml index 16b4f75a7d..ae6fb38e12 100644 --- a/chart/templates/job-chewy-upgrade.yaml +++ b/chart/templates/job-chewy-upgrade.yaml @@ -13,6 +13,10 @@ spec: template: metadata: name: {{ include "mastodon.fullname" . }}-chewy-upgrade + {{- with .Values.jobAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} spec: restartPolicy: Never {{- if (not .Values.mastodon.s3.enabled) }} diff --git a/chart/templates/job-create-admin.yaml b/chart/templates/job-create-admin.yaml index 486c0c3572..659c00671f 100644 --- a/chart/templates/job-create-admin.yaml +++ b/chart/templates/job-create-admin.yaml @@ -13,6 +13,10 @@ spec: template: metadata: name: {{ include "mastodon.fullname" . }}-create-admin + {{- with .Values.jobAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} spec: restartPolicy: Never {{- if (not .Values.mastodon.s3.enabled) }} diff --git a/chart/templates/job-db-migrate.yaml b/chart/templates/job-db-migrate.yaml index 41ece64a2a..8e4f70dfb1 100644 --- a/chart/templates/job-db-migrate.yaml +++ b/chart/templates/job-db-migrate.yaml @@ -12,6 +12,10 @@ spec: template: metadata: name: {{ include "mastodon.fullname" . }}-db-migrate + {{- with .Values.jobAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} spec: restartPolicy: Never {{- if (not .Values.mastodon.s3.enabled) }} diff --git a/chart/values.yaml b/chart/values.yaml index bd723567f0..4b18a9dfa5 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -281,8 +281,14 @@ serviceAccount: # If not set and create is true, a name is generated using the fullname template name: "" +# Kubernetes manages pods for jobs and pods for deployments differently, so you might +# need to apply different annotations to the two different sets of pods. The annotations +# set with podAnnotations will be added to all deployment-managed pods. podAnnotations: {} +# The annotations set with jobAnnotations will be added to all job pods. +jobAnnotations: {} + resources: {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little diff --git a/config/environments/production.rb b/config/environments/production.rb index 69f80667e4..dd73bb4e1b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -47,7 +47,7 @@ Rails.application.configure do config.force_ssl = true config.ssl_options = { redirect: { - exclude: -> request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') } + exclude: -> request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') || request.headers["Host"].end_with?('.i2p') } } } diff --git a/config/initializers/http_client_proxy.rb b/config/initializers/http_client_proxy.rb index 7a9b7b86d7..b29e9edd75 100644 --- a/config/initializers/http_client_proxy.rb +++ b/config/initializers/http_client_proxy.rb @@ -18,5 +18,22 @@ Rails.application.configure do }.compact end + if ENV['http_hidden_proxy'].present? + proxy = URI.parse(ENV['http_hidden_proxy']) + + raise "Unsupported proxy type: #{proxy.scheme}" unless %w(http https).include? proxy.scheme + raise "No proxy host" unless proxy.host + + host = proxy.host + host = host[1...-1] if host[0] == '[' # for IPv6 address + + config.x.http_client_hidden_proxy[:proxy] = { + proxy_address: host, + proxy_port: proxy.port, + proxy_username: proxy.user, + proxy_password: proxy.password, + }.compact + end + config.x.access_to_hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2cd4f45ac2..596cc1a284 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1181,6 +1181,8 @@ en: edit: add_keyword: Add keyword keywords: Keywords + statuses: Individual posts + statuses_hint_html: This filter applies to select individual posts regardless of whether they match the keywords below. You can review these posts and remove them from the filter by clicking here. title: Edit filter errors: deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface. @@ -1194,10 +1196,23 @@ en: keywords: one: "%{count} keyword" other: "%{count} keywords" + statuses: + one: "%{count} post" + other: "%{count} posts" + statuses_long: + one: "%{count} individual post hidden" + other: "%{count} individual posts hidden" title: Filters new: save: Save new filter title: Add new filter + statuses: + back_to_filter: Back to filter + batch: + remove: Remove from filter + index: + hint: This filter applies to select individual posts regardless of other criteria. You can add more posts to this filter from the Web interface. + title: Filtered posts footer: developers: Developers more: More… diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c17a62cbea..28f78d5000 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -85,6 +85,7 @@ en: ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out! severities: no_access: Block access to all resources + sign_up_block: New sign-ups will not be possible sign_up_requires_approval: New sign-ups will require your approval severity: Choose what will happen with requests from this IP rule: @@ -219,6 +220,7 @@ en: ip: IP severities: no_access: Block access + sign_up_block: Block sign-ups sign_up_requires_approval: Limit sign-ups severity: Rule notification_emails: diff --git a/config/routes.rb b/config/routes.rb index 52ba0956a2..6057852c0e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -180,7 +180,14 @@ Rails.application.routes.draw do resources :tags, only: [:show] resources :emojis, only: [:show] resources :invites, only: [:index, :create, :destroy] - resources :filters, except: [:show] + resources :filters, except: [:show] do + resources :statuses, only: [:index], controller: 'filters/statuses' do + collection do + post :batch + end + end + end + resource :relationships, only: [:show, :update] resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update] @@ -470,12 +477,14 @@ Rails.application.routes.draw do resources :trends, only: [:index], controller: 'trends/tags' resources :filters, only: [:index, :create, :show, :update, :destroy] do resources :keywords, only: [:index, :create], controller: 'filters/keywords' + resources :statuses, only: [:index, :create], controller: 'filters/statuses' end resources :endorsements, only: [:index] resources :markers, only: [:index, :create] namespace :filters do resources :keywords, only: [:show, :update, :destroy] + resources :statuses, only: [:show, :destroy] end namespace :apps do diff --git a/db/migrate/20220808101323_create_custom_filter_statuses.rb b/db/migrate/20220808101323_create_custom_filter_statuses.rb new file mode 100644 index 0000000000..52f7037491 --- /dev/null +++ b/db/migrate/20220808101323_create_custom_filter_statuses.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateCustomFilterStatuses < ActiveRecord::Migration[6.1] + def change + create_table :custom_filter_statuses do |t| + t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false + t.belongs_to :status, foreign_key: { on_delete: :cascade }, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7a8262dfa0..868303faf8 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: 2022_07_14_171049) do +ActiveRecord::Schema.define(version: 2022_08_08_101323) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -348,6 +348,15 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id" end + create_table "custom_filter_statuses", force: :cascade do |t| + t.bigint "custom_filter_id", null: false + t.bigint "status_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["custom_filter_id"], name: "index_custom_filter_statuses_on_custom_filter_id" + t.index ["status_id"], name: "index_custom_filter_statuses_on_status_id" + end + create_table "custom_filters", force: :cascade do |t| t.bigint "account_id" t.datetime "expires_at" @@ -1116,6 +1125,8 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade + add_foreign_key "custom_filter_statuses", "custom_filters", on_delete: :cascade + add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade add_foreign_key "devices", "accounts", on_delete: :cascade add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade diff --git a/lib/mastodon/media_cli.rb b/lib/mastodon/media_cli.rb index 36ca71844f..4904cc5eb9 100644 --- a/lib/mastodon/media_cli.rb +++ b/lib/mastodon/media_cli.rb @@ -187,6 +187,7 @@ module Mastodon option :account, type: :string option :domain, type: :string option :status, type: :numeric + option :days, type: :numeric option :concurrency, type: :numeric, default: 5, aliases: [:c] option :verbose, type: :boolean, default: false, aliases: [:v] option :dry_run, type: :boolean, default: false @@ -204,6 +205,8 @@ module Mastodon Use the --domain option to download attachments from a specific domain. + Use the --days option to limit attachments created within days. + By default, attachments that are believed to be already downloaded will not be re-downloaded. To force re-download of every URL, use --force. DESC @@ -224,10 +227,16 @@ module Mastodon scope = MediaAttachment.where(account_id: account.id) elsif options[:domain] scope = MediaAttachment.joins(:account).merge(Account.by_domain_and_subdomains(options[:domain])) + elsif options[:days].present? + scope = MediaAttachment.remote else exit(1) end + if options[:days].present? + scope = scope.where('id > ?', Mastodon::Snowflake.id_at(options[:days].days.ago, with_random: false)) + end + processed, aggregate = parallelize_with_progress(scope) do |media_attachment| next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?) next if DomainBlock.reject_media?(media_attachment.account.domain) diff --git a/package.json b/package.json index ecc063a462..30f5dbd69a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "private": true, "dependencies": { - "@babel/core": "^7.18.10", + "@babel/core": "^7.18.13", "@babel/plugin-proposal-decorators": "^7.18.10", "@babel/plugin-transform-react-inline-elements": "^7.18.6", "@babel/plugin-transform-runtime": "^7.18.10", @@ -121,7 +121,7 @@ "requestidlecallback": "^0.3.0", "reselect": "^4.1.6", "rimraf": "^3.0.2", - "sass": "^1.54.4", + "sass": "^1.54.5", "sass-loader": "^10.2.0", "stacktrace-js": "^2.0.2", "stringz": "^2.1.0", @@ -134,7 +134,7 @@ "uuid": "^8.3.1", "webpack": "^4.46.0", "webpack-assets-manifest": "^4.0.6", - "webpack-bundle-analyzer": "^4.5.0", + "webpack-bundle-analyzer": "^4.6.1", "webpack-cli": "^3.3.12", "webpack-merge": "^5.8.0", "wicg-inert": "^3.1.2", @@ -157,7 +157,7 @@ "raf": "^3.4.1", "react-intl-translations-manager": "^5.0.3", "react-test-renderer": "^16.14.0", - "stylelint": "^14.10.0", + "stylelint": "^14.11.0", "stylelint-config-standard-scss": "^4.0.0", "webpack-dev-server": "^3.11.3", "yargs": "^17.5.1" diff --git a/spec/controllers/api/v1/filters/statuses_controller_spec.rb b/spec/controllers/api/v1/filters/statuses_controller_spec.rb new file mode 100644 index 0000000000..3b2399dd89 --- /dev/null +++ b/spec/controllers/api/v1/filters/statuses_controller_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +RSpec.describe Api::V1::Filters::StatusesController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:other_user) { Fabricate(:user) } + let(:other_filter) { Fabricate(:custom_filter, account: other_user.account) } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let(:scopes) { 'read:filters' } + let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) } + + it 'returns http success' do + get :index, params: { filter_id: filter.id } + expect(response).to have_http_status(200) + end + + context "when trying to access another's user filters" do + it 'returns http not found' do + get :index, params: { filter_id: other_filter.id } + expect(response).to have_http_status(404) + end + end + end + + describe 'POST #create' do + let(:scopes) { 'write:filters' } + let(:filter_id) { filter.id } + let!(:status) { Fabricate(:status) } + + before do + post :create, params: { filter_id: filter_id, status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns a status filter' do + json = body_as_json + expect(json[:status_id]).to eq status.id.to_s + end + + it 'creates a status filter' do + filter = user.account.custom_filters.first + expect(filter).to_not be_nil + expect(filter.statuses.pluck(:status_id)).to eq [status.id] + end + + context "when trying to add to another another's user filters" do + let(:filter_id) { other_filter.id } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'GET #show' do + let(:scopes) { 'read:filters' } + let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) } + + before do + get :show, params: { id: status_filter.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns expected data' do + json = body_as_json + expect(json[:status_id]).to eq status_filter.status_id.to_s + end + + context "when trying to access another user's filter keyword" do + let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE #destroy' do + let(:scopes) { 'write:filters' } + let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) } + + before do + delete :destroy, params: { id: status_filter.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'removes the filter' do + expect { status_filter.reload }.to raise_error ActiveRecord::RecordNotFound + end + + context "when trying to update another user's filter keyword" do + let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index 4d104a198d..24810a5d27 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -47,6 +47,33 @@ RSpec.describe Api::V1::StatusesController, type: :controller do end end + context 'when post is explicitly filtered' do + let(:status) { Fabricate(:status, text: 'hello world') } + + before do + filter = user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide) + filter.statuses.create!(status_id: status.id) + end + + it 'returns http success' do + get :show, params: { id: status.id } + expect(response).to have_http_status(200) + end + + it 'returns filter information' do + get :show, params: { id: status.id } + json = body_as_json + expect(json[:filtered][0]).to include({ + filter: a_hash_including({ + id: user.account.custom_filters.first.id.to_s, + title: 'filter1', + filter_action: 'hide', + }), + status_matches: [status.id.to_s], + }) + end + end + context 'when reblog includes filtered terms' do let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) } diff --git a/spec/fabricators/custom_filter_status_fabricator.rb b/spec/fabricators/custom_filter_status_fabricator.rb new file mode 100644 index 0000000000..d082b81c5e --- /dev/null +++ b/spec/fabricators/custom_filter_status_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:custom_filter_status) do + custom_filter + status +end diff --git a/spec/models/email_domain_block_spec.rb b/spec/models/email_domain_block_spec.rb index 567a32c328..e23116888c 100644 --- a/spec/models/email_domain_block_spec.rb +++ b/spec/models/email_domain_block_spec.rb @@ -12,16 +12,29 @@ RSpec.describe EmailDomainBlock, type: :model do let(:input) { nil } context 'given an e-mail address' do - let(:input) { 'nyarn@example.com' } + let(:input) { "foo@#{domain}" } - it 'returns true if the domain is blocked' do - Fabricate(:email_domain_block, domain: 'example.com') - expect(EmailDomainBlock.block?(input)).to be true + context do + let(:domain) { 'example.com' } + + it 'returns true if the domain is blocked' do + Fabricate(:email_domain_block, domain: 'example.com') + expect(EmailDomainBlock.block?(input)).to be true + end + + it 'returns false if the domain is not blocked' do + Fabricate(:email_domain_block, domain: 'other-example.com') + expect(EmailDomainBlock.block?(input)).to be false + end end - it 'returns false if the domain is not blocked' do - Fabricate(:email_domain_block, domain: 'other-example.com') - expect(EmailDomainBlock.block?(input)).to be false + context do + let(:domain) { 'mail.example.com' } + + it 'returns true if it is a subdomain of a blocked domain' do + Fabricate(:email_domain_block, domain: 'example.com') + expect(described_class.block?(input)).to be true + end end end diff --git a/spec/presenters/status_relationships_presenter_spec.rb b/spec/presenters/status_relationships_presenter_spec.rb index 5cd4929a63..eaab922fd9 100644 --- a/spec/presenters/status_relationships_presenter_spec.rb +++ b/spec/presenters/status_relationships_presenter_spec.rb @@ -94,5 +94,32 @@ RSpec.describe StatusRelationshipsPresenter do expect(matched_filters[0].keyword_matches).to eq ['irrelevant'] end end + + context 'when post includes filtered individual statuses' do + let(:statuses) { [Fabricate(:status, text: 'hello world'), Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about an irrelevant word'))] } + let(:options) { {} } + + before do + filter = Account.find(current_account_id).custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide) + filter.statuses.create!(status_id: statuses[0].id) + filter.statuses.create!(status_id: statuses[1].reblog_of_id) + end + + it 'sets @filters_map to filter top-level status' do + matched_filters = presenter.filters_map[statuses[0].id] + expect(matched_filters.size).to eq 1 + + expect(matched_filters[0].filter.title).to eq 'filter1' + expect(matched_filters[0].status_matches).to eq [statuses[0].id] + end + + it 'sets @filters_map to filter reblogged status' do + matched_filters = presenter.filters_map[statuses[1].reblog_of_id] + expect(matched_filters.size).to eq 1 + + expect(matched_filters[0].filter.title).to eq 'filter1' + expect(matched_filters[0].status_matches).to eq [statuses[1].reblog_of_id] + end + end end end diff --git a/spec/services/app_sign_up_service_spec.rb b/spec/services/app_sign_up_service_spec.rb index e0c83b7041..8ec4d4a7a6 100644 --- a/spec/services/app_sign_up_service_spec.rb +++ b/spec/services/app_sign_up_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe AppSignUpService, type: :service do it 'returns nil when registrations are closed' do tmp = Setting.registrations_mode Setting.registrations_mode = 'none' - expect(subject.call(app, remote_ip, good_params)).to be_nil + expect { subject.call(app, remote_ip, good_params) }.to raise_error Mastodon::NotPermittedError Setting.registrations_mode = tmp end diff --git a/yarn.lock b/yarn.lock index 6dfc046834..32df3709d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -33,21 +33,21 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.18.10", "@babel/core@^7.7.2": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" - integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw== +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.18.13", "@babel/core@^7.7.2": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.13.tgz#9be8c44512751b05094a4d3ab05fc53a47ce00ac" + integrity sha512-ZisbOvRRusFktksHSG6pjj1CSvkPkcZq/KHD45LAkVP/oiHJkNBZWfpvlLmX8OtHDG8IuzsFlVRWo08w7Qxn0A== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.10" + "@babel/generator" "^7.18.13" "@babel/helper-compilation-targets" "^7.18.9" "@babel/helper-module-transforms" "^7.18.9" "@babel/helpers" "^7.18.9" - "@babel/parser" "^7.18.10" + "@babel/parser" "^7.18.13" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.18.10" - "@babel/types" "^7.18.10" + "@babel/traverse" "^7.18.13" + "@babel/types" "^7.18.13" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -63,12 +63,12 @@ eslint-visitor-keys "^2.1.0" semver "^6.3.0" -"@babel/generator@^7.18.10", "@babel/generator@^7.7.2": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.10.tgz#794f328bfabdcbaf0ebf9bf91b5b57b61fa77a2a" - integrity sha512-0+sW7e3HjQbiHbj1NeU/vN8ornohYlacAfZIaXhdoGweQqgcNy69COVciYYqEXJ/v+9OBA7Frxm4CVAuNqKeNA== +"@babel/generator@^7.18.13", "@babel/generator@^7.7.2": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.13.tgz#59550cbb9ae79b8def15587bdfbaa388c4abf212" + integrity sha512-CkPg8ySSPuHTYPJYo7IRALdqyjM9HCbt/3uOBEFbzyGVP6Mn8bwFPB0jX6982JVNBlYzM1nnPkfjuXSOPtQeEQ== dependencies: - "@babel/types" "^7.18.10" + "@babel/types" "^7.18.13" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -337,10 +337,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.10.tgz#94b5f8522356e69e8277276adf67ed280c90ecc1" - integrity sha512-TYk3OA0HKL6qNryUayb5UUEhM/rkOQozIBEA5ITXh5DWrSp0TlUQXMyZmnWxG/DizSWBeeQ0Zbc5z8UGaaqoeg== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.13": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.13.tgz#5b2dd21cae4a2c5145f1fbd8ca103f9313d3b7e4" + integrity sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -1085,26 +1085,26 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.6", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.10.tgz#37ad97d1cb00efa869b91dd5d1950f8a6cf0cb08" - integrity sha512-J7ycxg0/K9XCtLyHf0cz2DqDihonJeIo+z+HEdRe9YuT8TY4A66i+Ab2/xZCEW7Ro60bPCBBfqqboHSamoV3+g== +"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.13", "@babel/traverse@^7.18.6", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.13.tgz#5ab59ef51a997b3f10c4587d648b9696b6cb1a68" + integrity sha512-N6kt9X1jRMLPxxxPYWi7tgvJRH/rtoU+dbKAPDM44RFHiMH8igdsaSBgFeskhSl/kLWLDUvIh1RXCrTmg0/zvA== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.10" + "@babel/generator" "^7.18.13" "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-function-name" "^7.18.9" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.18.10" - "@babel/types" "^7.18.10" + "@babel/parser" "^7.18.13" + "@babel/types" "^7.18.13" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" - integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== +"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.13", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.13.tgz#30aeb9e514f4100f7c1cb6e5ba472b30e48f519a" + integrity sha512-ePqfTihzW0W6XAU+aMw2ykilisStJfDnsejDCXRchCcMJ4O0+8DhPXf2YUbZ6wjBlsEmZwLK/sPweWtu8hcJYQ== dependencies: "@babel/helper-string-parser" "^7.18.10" "@babel/helper-validator-identifier" "^7.18.6" @@ -2139,12 +2139,7 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.0.4: - version "8.3.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.3.0.tgz#1193f9b96c4e8232f00b11a9edff81b2c8b98b88" - integrity sha512-tqPKHZ5CaBJw0Xmy0ZZvLs1qTV+BNFSyvn77ASXkpBNfIRk8ev26fKrD9iLGwGA9zedPao52GSHzq8lyZG0NUw== - -acorn@^8.5.0, acorn@^8.7.1: +acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1: version "8.7.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== @@ -3371,10 +3366,10 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.2" -colord@^2.9.2: - version "2.9.2" - resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1" - integrity sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ== +colord@^2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" + integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw== colorette@^1.2.2: version "1.2.2" @@ -9755,10 +9750,10 @@ sass-loader@^10.2.0: schema-utils "^3.0.0" semver "^7.3.2" -sass@^1.54.4: - version "1.54.4" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.4.tgz#803ff2fef5525f1dd01670c3915b4b68b6cba72d" - integrity sha512-3tmF16yvnBwtlPrNBHw/H907j8MlOX8aTBnlNX1yrKx24RKcJGPyLhFUwkoKBKesR3unP93/2z14Ll8NicwQUA== +sass@^1.54.5: + version "1.54.5" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.5.tgz#93708f5560784f6ff2eab8542ade021a4a947b3a" + integrity sha512-p7DTOzxkUPa/63FU0R3KApkRHwcVZYC0PLnLm5iyZACyp15qSi32x7zVUhRdABAATmkALqgGrjCJAcWvobmhHw== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" @@ -10524,14 +10519,14 @@ stylelint-scss@^4.0.0: postcss-selector-parser "^6.0.6" postcss-value-parser "^4.1.0" -stylelint@^14.10.0: - version "14.10.0" - resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-14.10.0.tgz#c588f5cd47cd214cf1acee5bc165961b6a3ad836" - integrity sha512-VAmyKrEK+wNFh9R8mNqoxEFzaa4gsHGhcT4xgkQDuOA5cjF6CaNS8loYV7gpi4tIZBPUyXesotPXzJAMN8VLOQ== +stylelint@^14.11.0: + version "14.11.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-14.11.0.tgz#e2ecb28bbacab05e1fbeb84cbba23883b27499cc" + integrity sha512-OTLjLPxpvGtojEfpESWM8Ir64Z01E89xsisaBMUP/ngOx1+4VG2DPRcUyCCiin9Rd3kPXPsh/uwHd9eqnvhsYA== dependencies: "@csstools/selector-specificity" "^2.0.2" balanced-match "^2.0.0" - colord "^2.9.2" + colord "^2.9.3" cosmiconfig "^7.0.1" css-functions-list "^3.1.0" debug "^4.3.4" @@ -10566,7 +10561,7 @@ stylelint@^14.10.0: svg-tags "^1.0.0" table "^6.8.0" v8-compile-cache "^2.3.0" - write-file-atomic "^4.0.1" + write-file-atomic "^4.0.2" stylis@4.0.13: version "4.0.13" @@ -11353,10 +11348,10 @@ webpack-assets-manifest@^4.0.6: tapable "^1.0" webpack-sources "^1.0" -webpack-bundle-analyzer@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz#1b0eea2947e73528754a6f9af3e91b2b6e0f79d5" - integrity sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ== +webpack-bundle-analyzer@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.6.1.tgz#bee2ee05f4ba4ed430e4831a319126bb4ed9f5a6" + integrity sha512-oKz9Oz9j3rUciLNfpGFjOb49/jEpXNmWdVH8Ls//zNcnLlQdTGXQQMsBbb/gR7Zl8WNLxVCq+0Hqbx3zv6twBw== dependencies: acorn "^8.0.4" acorn-walk "^8.0.0" @@ -11629,10 +11624,10 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -write-file-atomic@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.1.tgz#9faa33a964c1c85ff6f849b80b42a88c2c537c8f" - integrity sha512-nSKUxgAbyioruk6hU87QzVbY279oYT6uiwgDoujth2ju4mJ+TZau7SQBhtbTmUyuNYTuXnSyRn66FV0+eCgcrQ== +write-file-atomic@^4.0.1, write-file-atomic@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" + integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== dependencies: imurmurhash "^0.1.4" signal-exit "^3.0.7"