diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2018-06-29 15:34:36 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-06-29 15:34:36 +0200 |
commit | cdb101340a20183a82889f811d9311c370c855e5 (patch) | |
tree | daacd56d1edbf359b0d97a926e90b999ba7f6129 | |
parent | fbee9b5ac898e571e384792a92b40fa1524cf127 (diff) |
Keyword/phrase filtering (#7905)
* Add keyword filtering GET|POST /api/v1/filters GET|PUT|DELETE /api/v1/filters/:id - Irreversible filters can drop toots from home or notifications - Other filters can hide toots through the client app - Filters use a phrase valid in particular contexts, expiration * Make sure expired filters don't get applied client-side * Add missing API methods * Remove "regex filter" from column settings * Add tests * Add test for FeedManager * Add CustomFilter test * Add UI for managing filters * Add streaming API event to allow syncing filters * Fix tests
38 files changed, 530 insertions, 72 deletions
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb new file mode 100644 index 000000000..c89722b85 --- /dev/null +++ b/app/controllers/api/v1/filters_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Api::V1::FiltersController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write }, except: [:index, :show] + before_action :require_user! + before_action :set_filters, only: :index + before_action :set_filter, only: [:show, :update, :destroy] + + respond_to :json + + def index + render json: @filters, each_serializer: REST::FilterSerializer + end + + def create + @filter = current_account.custom_filters.create!(resource_params) + render json: @filter, serializer: REST::FilterSerializer + end + + def show + render json: @filter, serializer: REST::FilterSerializer + end + + def update + @filter.update!(resource_params) + render json: @filter, serializer: REST::FilterSerializer + end + + def destroy + @filter.destroy! + render_empty + end + + private + + def set_filters + @filters = current_account.custom_filters + end + + def set_filter + @filter = current_account.custom_filters.find(params[:id]) + end + + def resource_params + params.permit(:phrase, :expires_at, :irreversible, context: []) + end +end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb new file mode 100644 index 000000000..03403a1ba --- /dev/null +++ b/app/controllers/filters_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class FiltersController < ApplicationController + include Authorization + + layout 'admin' + + before_action :set_filters, only: :index + before_action :set_filter, only: [:edit, :update, :destroy] + + def index + @filters = current_account.custom_filters + end + + def new + @filter = current_account.custom_filters.build + end + + def create + @filter = current_account.custom_filters.build(resource_params) + + if @filter.save + redirect_to filters_path + else + render action: :new + end + end + + def edit; end + + def update + if @filter.update(resource_params) + redirect_to filters_path + else + render action: :edit + end + end + + def destroy + @filter.destroy + redirect_to filters_path + end + + private + + def set_filters + @filters = current_account.custom_filters + end + + def set_filter + @filter = current_account.custom_filters.find(params[:id]) + end + + def resource_params + params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, context: []) + end +end diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js new file mode 100644 index 000000000..7fa1c9a70 --- /dev/null +++ b/app/javascript/mastodon/actions/filters.js @@ -0,0 +1,26 @@ +import api from '../api'; + +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 fetchFilters = () => (dispatch, getState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v1/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); +}; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index f56853bff..32fc67e67 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -6,6 +6,7 @@ import { disconnectTimeline, } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; +import { fetchFilters } from './filters'; import { getLocale } from '../locales'; const { messages } = getLocale(); @@ -30,6 +31,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) case 'notification': dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); break; + case 'filters_changed': + dispatch(fetchFilters()); + break; } }, }; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index fd08ff3b7..922b609ec 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -157,6 +157,21 @@ export default class Status extends ImmutablePureComponent { ); } + if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { + const minHandlers = this.props.muted ? {} : { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + return ( + <HotKeys handlers={minHandlers}> + <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'> + <FormattedMessage id='status.filtered' defaultMessage='Filtered' /> + </div> + </HotKeys> + ); + } + if (featured) { prepend = ( <div className='status__prepend'> diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 1c34d0640..68c9eef54 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent { prepend: PropTypes.node, emptyMessage: PropTypes.node, alwaysPrepend: PropTypes.bool, + timelineId: PropTypes.string.isRequired, }; static defaultProps = { @@ -70,7 +71,7 @@ export default class StatusList extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, onLoadMore, ...other } = this.props; + const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props; const { isLoading, isPartial } = other; if (isPartial) { @@ -102,6 +103,7 @@ export default class StatusList extends ImmutablePureComponent { id={statusId} onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + contextType={timelineId} /> )) ) : null; @@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent { featured onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + contextType={timelineId} /> )).concat(scrollableContent); } diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 3e7b5215b..eb6329fdc 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -42,7 +42,7 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const mapStateToProps = (state, props) => ({ - status: getStatus(state, props.id), + status: getStatus(state, props), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js index 3a1d19aa8..f4325f58d 100644 --- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js @@ -1,15 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import SettingText from '../../../components/setting_text'; +import { injectIntl, FormattedMessage } from 'react-intl'; import SettingToggle from '../../notifications/components/setting_toggle'; -const messages = defineMessages({ - filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, - settings: { id: 'home.settings', defaultMessage: 'Column settings' }, -}); - @injectIntl export default class ColumnSettings extends React.PureComponent { @@ -21,19 +15,13 @@ export default class ColumnSettings extends React.PureComponent { }; render () { - const { settings, onChange, intl } = this.props; + const { settings, onChange } = this.props; return ( <div> <div className='column-settings__row'> <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> </div> - - <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> - - <div className='column-settings__row'> - <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> - </div> </div> ); } diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js index 73f394c1a..5eb1eb72a 100644 --- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -7,7 +7,7 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const mapStateToProps = state => ({ - status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js index fda57f69a..63dc41d9e 100644 --- a/app/javascript/mastodon/features/direct_timeline/index.js +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -7,7 +7,6 @@ import ColumnHeader from '../../components/column_header'; import { expandDirectTimeline } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import ColumnSettingsContainer from './containers/column_settings_container'; import { connectDirectStream } from '../../actions/streaming'; const messages = defineMessages({ @@ -86,9 +85,7 @@ export default class DirectTimeline extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} - > - <ColumnSettingsContainer /> - </ColumnHeader> + /> <StatusListContainer trackScroll={!pinned} diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js index 0c0c4fa36..932ac2049 100644 --- a/app/javascript/mastodon/features/home_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js @@ -1,14 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { injectIntl, FormattedMessage } from 'react-intl'; import SettingToggle from '../../notifications/components/setting_toggle'; -import SettingText from '../../../components/setting_text'; - -const messages = defineMessages({ - filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, - settings: { id: 'home.settings', defaultMessage: 'Column settings' }, -}); @injectIntl export default class ColumnSettings extends React.PureComponent { @@ -20,7 +14,7 @@ export default class ColumnSettings extends React.PureComponent { }; render () { - const { settings, onChange, intl } = this.props; + const { settings, onChange } = this.props; return ( <div> @@ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent { <div className='column-settings__row'> <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> </div> - - <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> - - <div className='column-settings__row'> - <SettingText prefix='home_timeline' settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> - </div> </div> ); } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index ca792043f..3c66536d4 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -58,7 +58,7 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const mapStateToProps = (state, props) => { - const status = getStatus(state, props.params.statusId); + const status = getStatus(state, { id: props.params.statusId }); let ancestorsIds = Immutable.List(); let descendantsIds = Immutable.List(); @@ -336,6 +336,7 @@ export default class Status extends ImmutablePureComponent { id={id} onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + contextType='thread' /> )); } diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index e5b1edc4a..3df5b7bea 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -11,15 +11,6 @@ const makeGetStatusIds = () => createSelector([ (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), (state) => state.get('statuses'), ], (columnSettings, statusIds, statuses) => { - const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim(); - let regex = null; - - try { - regex = rawRegex && new RegExp(rawRegex, 'i'); - } catch (e) { - // Bad regex, don't affect filters - } - return statusIds.filter(id => { if (id === null) return true; @@ -34,11 +25,6 @@ const makeGetStatusIds = () => createSelector([ showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); } - if (showStatus && regex && statusForId.get('account') !== me) { - const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index'); - showStatus = !regex.test(searchIndex); - } - return showStatus; }); }); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 3c1a266e3..56a856230 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -12,6 +12,7 @@ import { debounce } from 'lodash'; import { uploadCompose, resetCompose } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; +import { fetchFilters } from '../../actions/filters'; import { clearHeight } from '../../actions/height_cache'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; @@ -297,6 +298,7 @@ export default class UI extends React.PureComponent { this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); + setTimeout(() => this.props.dispatch(fetchFilters()), 500); } componentDidMount () { diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js new file mode 100644 index 000000000..33f0c6732 --- /dev/null +++ b/app/javascript/mastodon/reducers/filters.js @@ -0,0 +1,11 @@ +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; +import { List as ImmutableList, fromJS } from 'immutable'; + +export default function filters(state = ImmutableList(), action) { + switch(action.type) { + case FILTERS_FETCH_SUCCESS: + return fromJS(action.filters); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 3d9a6a132..4a981fada 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -26,6 +26,7 @@ import height_cache from './height_cache'; import custom_emojis from './custom_emojis'; import lists from './lists'; import listEditor from './list_editor'; +import filters from './filters'; const reducers = { dropdown_menu, @@ -55,6 +56,7 @@ const reducers = { custom_emojis, lists, listEditor, + filters, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index e47ec5183..56eca1f02 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -19,16 +19,44 @@ export const makeGetAccount = () => { }); }; +const toServerSideType = columnType => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; + +const escapeRegExp = string => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + +const regexFromFilters = filters => { + if (filters.size === 0) { + return null; + } + + return new RegExp(filters.map(filter => escapeRegExp(filter.get('phrase'))).join('|'), 'i'); +}; + export const makeGetStatus = () => { return createSelector( [ - (state, id) => state.getIn(['statuses', id]), - (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), - (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), - (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + (state, { id }) => state.getIn(['statuses', id]), + (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))), ], - (statusBase, statusReblog, accountBase, accountReblog) => { + (statusBase, statusReblog, accountBase, accountReblog, filters) => { if (!statusBase) { return null; } @@ -39,9 +67,13 @@ export const makeGetStatus = () => { statusReblog = null; } + const regex = regexFromFilters(filters); + const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index')); + return statusBase.withMutations(map => { map.set('reblog', statusReblog); map.set('account', accountBase); + map.set('filtered', filtered); }); } ); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index c16cf3437..5fa73d58a 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -725,6 +725,20 @@ vertical-align: middle; } +.status__wrapper--filtered { + color: $dark-text-color; + border: 0; + font-size: inherit; + text-align: center; + line-height: inherit; + margin: 0; + padding: 15px; + box-sizing: border-box; + width: 100%; + clear: both; + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + .status__prepend-icon-wrapper { left: -26px; position: absolute; diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index c18c07b33..ee9185d34 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -153,6 +153,7 @@ class FeedManager def filter_from_home?(status, receiver_id) return false if receiver_id == status.account_id return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) + return true if phrase_filtered?(status, receiver_id, :home) check_for_blocks = status.mentions.pluck(:account_id) check_for_blocks.concat([status.account_id]) @@ -177,6 +178,7 @@ class FeedManager def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id + return true if phrase_filtered?(status, receiver_id, :notifications) # This filter is called from NotifyService, but already after the sender of # the notification has been checked for mute/block. Therefore, it's not @@ -190,6 +192,20 @@ class FeedManager should_filter end + def phrase_filtered?(status, receiver_id, context) + active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a + + active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? } + active_filters.map! { |filter| Regexp.new(Regexp.escape(filter.phrase), true) } + + return false if active_filters.empty? + + combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) } + + !combined_regex.match(status.text).nil? || + (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) + end + # Adds a status to an account's feed, returning true if a status was # added, and false if it was not added to the feed. Note that this is # an internal helper: callers must call trim or push updates if diff --git a/app/models/account.rb b/app/models/account.rb index c3eea79cc..40a45b1f8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -99,6 +99,7 @@ class Account < ApplicationRecord has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id has_many :report_notes, dependent: :destroy + has_many :custom_filters, inverse_of: :account, dependent: :destroy # Moderation notes has_many :account_moderation_notes, dependent: :destroy diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb new file mode 100644 index 000000000..444ccdfdb --- /dev/null +++ b/app/models/concerns/expireable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Expireable + extend ActiveSupport::Concern + + included do + scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } + + attr_reader :expires_in + + def expires_in=(interval) + self.expires_at = interval.to_i.seconds.from_now unless interval.blank? + @expires_in = interval + end + + def expire! + touch(:expires_at) + end + + def expired? + !expires_at.nil? && expires_at < Time.now.utc + end + end +end diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb new file mode 100644 index 000000000..2c1a54375 --- /dev/null +++ b/app/models/custom_filter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_filters +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# expires_at :datetime +# phrase :text default(""), not null +# context :string default([]), not null, is an Array +# irreversible :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomFilter < ApplicationRecord + VALID_CONTEXTS = %w( + home + notifications + public + thread + ).freeze + + include Expireable + + belongs_to :account + + validates :phrase, :context, presence: true + validate :context_must_be_valid + validate :irreversible_must_be_within_context + + scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) } + + before_validation :clean_up_contexts + after_commit :remove_cache + + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).map(&:presence).compact + end + + def remove_cache + Rails.cache.delete("filters:#{account_id}") + Redis.current.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed)) + end + + def context_must_be_valid + errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } + end + + def irreversible_must_be_within_context + errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications') + end +end diff --git a/app/models/invite.rb b/app/models/invite.rb index d0cc427c4..fe2322462 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -15,33 +15,19 @@ # class Invite < ApplicationRecord + include Expireable + belongs_to :user has_many :users, inverse_of: :invite scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) } - scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } before_validation :set_code - attr_reader :expires_in - - def expires_in=(interval) - self.expires_at = interval.to_i.seconds.from_now unless interval.blank? - @expires_in = interval - end - def valid_for_use? (max_uses.nil? || uses < max_uses) && !expired? end - def expire! - touch(:expires_at) - end - - def expired? - !expires_at.nil? && expires_at < Time.now.utc - end - private def set_code diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb new file mode 100644 index 000000000..07f2516f8 --- /dev/null +++ b/app/serializers/rest/filter_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::FilterSerializer < ActiveModel::Serializer + attributes :id, :phrase, :context, :expires_at +end diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml new file mode 100644 index 000000000..af5d648b8 --- /dev/null +++ b/app/views/filters/_fields.html.haml @@ -0,0 +1,11 @@ +.fields-group + = f.input :phrase, as: :string, wrapper: :with_block_label + +.fields-group + = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false + +.fields-group + = f.input :irreversible, wrapper: :with_label + +.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') diff --git a/app/views/filters/edit.html.haml b/app/views/filters/edit.html.haml new file mode 100644 index 000000000..e971215ac --- /dev/null +++ b/app/views/filters/edit.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('filters.edit.title') + += simple_form_for @filter, url: filter_path(@filter), method: :put do |f| + = render 'fields', f: f + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/filters/index.html.haml b/app/views/filters/index.html.haml new file mode 100644 index 000000000..18ebee570 --- /dev/null +++ b/app/views/filters/index.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('filters.index.title') + +.table-wrapper + %table.table + %thead + %tr + %th= t('simple_form.labels.defaults.phrase') + %th= t('simple_form.labels.defaults.context') + %th + %tbody + - @filters.each do |filter| + %tr + %td= filter.phrase + %td= filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ') + %td + = table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter) + = table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete + += link_to t('filters.new.title'), new_filter_path, class: 'button' diff --git a/app/views/filters/new.html.haml b/app/views/filters/new.html.haml new file mode 100644 index 000000000..05bec343f --- /dev/null +++ b/app/views/filters/new.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('filters.new.title') + += simple_form_for @filter, url: filters_path do |f| + = render 'fields', f: f + + .actions + = f.button :button, t('filters.new.title'), type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index 01e5dd2f8..5cb81ebe9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -474,6 +474,22 @@ en: follows: You follow mutes: You mute storage: Media storage + filters: + contexts: + home: Home timeline + notifications: Notifications + public: Public timelines + thread: Conversations + edit: + title: Edit filter + errors: + invalid_context: None or invalid context supplied + invalid_irreversible: Irreversible filtering only works with home or notifications context + index: + delete: Delete + title: Filters + new: + title: Add new filter followers: domain: Domain explanation_html: If you want to ensure the privacy of your statuses, you must be aware of who is following you. <strong>Your private statuses are delivered to all instances where you have followers</strong>. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those instances. diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 6783f0045..59133ea73 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -6,17 +6,20 @@ en: autofollow: People who sign up through the invite will automatically follow you avatar: PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px bot: This account mainly performs automated actions and might not be monitored + context: One or multiple contexts where the filter should apply digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence display_name: one: <span class="name-counter">1</span> character left other: <span class="name-counter">%{count}</span> characters left fields: You can have up to 4 items displayed as a table on your profile header: PNG, GIF or JPG. At most 2MB. Will be downscaled to 700x335px + irreversible: Filtered toots will disappear irreversibly, even if filter is later removed locale: The language of the user interface, e-mails and push notifications locked: Requires you to manually approve followers note: one: <span class="note-counter">1</span> character left other: <span class="note-counter">%{count}</span> characters left + phrase: Will be matched regardless of casing in text or content warning of a toot setting_default_language: The language of your toots can be detected automatically, but it's not always accurate setting_hide_network: Who you follow and who follows you will not be shown on your profile setting_noindex: Affects your public profile and status pages @@ -39,6 +42,7 @@ en: chosen_languages: Filter languages confirm_new_password: Confirm new password confirm_password: Confirm password + context: Filter contexts current_password: Current password data: Data display_name: Display name @@ -46,6 +50,7 @@ en: expires_in: Expire after fields: Profile metadata header: Header + irreversible: Drop instead of hide locale: Interface language locked: Lock account max_uses: Max number of uses @@ -53,6 +58,7 @@ en: note: Bio otp_attempt: Two-factor code password: Password + phrase: Keyword or phrase setting_auto_play_gif: Auto-play animated GIFs setting_boost_modal: Show confirmation dialog before boosting setting_default_language: Posting language diff --git a/config/navigation.rb b/config/navigation.rb index 2bee5a4f9..3f2e913c6 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -16,6 +16,7 @@ SimpleNavigation::Configuration.run do |navigation| settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url end + primary.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters} primary.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' } primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development| diff --git a/config/routes.rb b/config/routes.rb index a3cba24fc..5fdd3b390 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -114,6 +114,7 @@ Rails.application.routes.draw do resources :tags, only: [:show] resources :emojis, only: [:show] resources :invites, only: [:index, :create, :destroy] + resources :filters, except: [:show] get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy @@ -254,6 +255,7 @@ Rails.application.routes.draw do resources :mutes, only: [:index] resources :favourites, only: [:index] resources :reports, only: [:index, :create] + resources :filters, only: [:index, :create, :show, :update, :destroy] namespace :apps do get :verify_credentials, to: 'credentials#show' diff --git a/db/migrate/20180628181026_create_custom_filters.rb b/db/migrate/20180628181026_create_custom_filters.rb new file mode 100644 index 000000000..d19cf2e9d --- /dev/null +++ b/db/migrate/20180628181026_create_custom_filters.rb @@ -0,0 +1,13 @@ +class CreateCustomFilters < ActiveRecord::Migration[5.2] + def change + create_table :custom_filters do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.datetime :expires_at + t.text :phrase, null: false, default: '' + t.string :context, array: true, null: false, default: [] + t.boolean :irreversible, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 2853aef94..661fc8179 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: 2018_06_17_162849) do +ActiveRecord::Schema.define(version: 2018_06_28_181026) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -143,6 +143,17 @@ ActiveRecord::Schema.define(version: 2018_06_17_162849) do t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true end + create_table "custom_filters", force: :cascade do |t| + t.bigint "account_id" + t.datetime "expires_at" + t.text "phrase", default: "", null: false + t.string "context", default: [], null: false, array: true + t.boolean "irreversible", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_custom_filters_on_account_id" + end + create_table "domain_blocks", force: :cascade do |t| t.string "domain", default: "", null: false t.datetime "created_at", null: false @@ -561,6 +572,7 @@ ActiveRecord::Schema.define(version: 2018_06_17_162849) do add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade 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_filters", "accounts", on_delete: :cascade add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade diff --git a/spec/controllers/api/v1/filter_controller_spec.rb b/spec/controllers/api/v1/filter_controller_spec.rb new file mode 100644 index 000000000..3ffd8f784 --- /dev/null +++ b/spec/controllers/api/v1/filter_controller_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe Api::V1::FiltersController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let!(:filter) { Fabricate(:custom_filter, account: user.account) } + + it 'returns http success' do + get :index + expect(response).to have_http_status(200) + end + end + + describe 'POST #create' do + before do + post :create, params: { phrase: 'magic', context: %w(home), irreversible: true } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'creates a filter' do + filter = user.account.custom_filters.first + expect(filter).to_not be_nil + expect(filter.phrase).to eq 'magic' + expect(filter.context).to eq %w(home) + expect(filter.irreversible?).to be true + expect(filter.expires_at).to be_nil + end + end + + describe 'GET #show' do + let(:filter) { Fabricate(:custom_filter, account: user.account) } + + it 'returns http success' do + get :show, params: { id: filter.id } + expect(response).to have_http_status(200) + end + end + + describe 'PUT #update' do + let(:filter) { Fabricate(:custom_filter, account: user.account) } + + before do + put :update, params: { id: filter.id, phrase: 'updated' } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the filter' do + expect(filter.reload.phrase).to eq 'updated' + end + end + + describe 'DELETE #destroy' do + let(:filter) { Fabricate(:custom_filter, account: user.account) } + + before do + delete :destroy, params: { id: filter.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'removes the filter' do + expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound + end + end +end diff --git a/spec/fabricators/custom_filter_fabricator.rb b/spec/fabricators/custom_filter_fabricator.rb new file mode 100644 index 000000000..64297a7e3 --- /dev/null +++ b/spec/fabricators/custom_filter_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:custom_filter) do + account + expires_at nil + phrase 'discourse' + context %w(home notifications) +end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 6ead5bbd9..d1b847675 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -126,6 +126,14 @@ RSpec.describe FeedManager do reblog = Fabricate(:status, reblog: status, account: jeff) expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true end + + it 'returns true if status contains irreversibly muted phrase' do + alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true) + alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true) + alice.follow!(jeff) + status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff) + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + end end context 'for mentions feed' do diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb new file mode 100644 index 000000000..1024542e7 --- /dev/null +++ b/spec/models/custom_filter_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe CustomFilter, type: :model do + +end |