From d878e3e945e549c795d2d91f4d54adc59831e4d3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 29 Jun 2018 15:34:36 +0200 Subject: [PATCH] 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 --- app/controllers/api/v1/filters_controller.rb | 48 +++++++++++ app/controllers/filters_controller.rb | 57 +++++++++++++ app/javascript/mastodon/actions/filters.js | 26 ++++++ app/javascript/mastodon/actions/streaming.js | 4 + app/javascript/mastodon/components/status.js | 15 ++++ .../mastodon/components/status_list.js | 5 +- .../mastodon/containers/status_container.js | 2 +- .../components/column_settings.js | 16 +--- .../containers/reply_indicator_container.js | 2 +- .../features/direct_timeline/index.js | 5 +- .../components/column_settings.js | 16 +--- .../mastodon/features/status/index.js | 3 +- .../ui/containers/status_list_container.js | 14 ---- app/javascript/mastodon/features/ui/index.js | 2 + app/javascript/mastodon/reducers/filters.js | 11 +++ app/javascript/mastodon/reducers/index.js | 2 + app/javascript/mastodon/selectors/index.js | 42 ++++++++-- .../styles/mastodon/components.scss | 14 ++++ app/lib/feed_manager.rb | 16 ++++ app/models/account.rb | 1 + app/models/concerns/expireable.rb | 24 ++++++ app/models/custom_filter.rb | 55 +++++++++++++ app/models/invite.rb | 18 +---- app/serializers/rest/filter_serializer.rb | 5 ++ app/views/filters/_fields.html.haml | 11 +++ app/views/filters/edit.html.haml | 8 ++ app/views/filters/index.html.haml | 20 +++++ app/views/filters/new.html.haml | 8 ++ config/locales/en.yml | 16 ++++ config/locales/simple_form.en.yml | 6 ++ config/navigation.rb | 1 + config/routes.rb | 2 + .../20180628181026_create_custom_filters.rb | 13 +++ db/schema.rb | 14 +++- .../api/v1/filter_controller_spec.rb | 81 +++++++++++++++++++ spec/fabricators/custom_filter_fabricator.rb | 6 ++ spec/lib/feed_manager_spec.rb | 8 ++ spec/models/custom_filter_spec.rb | 5 ++ 38 files changed, 530 insertions(+), 72 deletions(-) create mode 100644 app/controllers/api/v1/filters_controller.rb create mode 100644 app/controllers/filters_controller.rb create mode 100644 app/javascript/mastodon/actions/filters.js create mode 100644 app/javascript/mastodon/reducers/filters.js create mode 100644 app/models/concerns/expireable.rb create mode 100644 app/models/custom_filter.rb create mode 100644 app/serializers/rest/filter_serializer.rb create mode 100644 app/views/filters/_fields.html.haml create mode 100644 app/views/filters/edit.html.haml create mode 100644 app/views/filters/index.html.haml create mode 100644 app/views/filters/new.html.haml create mode 100644 db/migrate/20180628181026_create_custom_filters.rb create mode 100644 spec/controllers/api/v1/filter_controller_spec.rb create mode 100644 spec/fabricators/custom_filter_fabricator.rb create mode 100644 spec/models/custom_filter_spec.rb diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb new file mode 100644 index 0000000000..c89722b853 --- /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 0000000000..03403a1ba1 --- /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 0000000000..7fa1c9a70d --- /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 f56853bffb..32fc67e67f 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 fd08ff3b7c..922b609ece 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 ( + +
+ +
+
+ ); + } + if (featured) { prepend = (
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 1c34d06408..68c9eef54a 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 3e7b5215be..eb6329fdcd 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 3a1d19aa81..f4325f58d3 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 (
} />
- - - -
- -
); } 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 73f394c1af..5eb1eb72a4 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 fda57f69af..63dc41d9e7 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} - > - - + /> @@ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent {
} />
- - - -
- -
); } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index ca792043f6..3c66536d43 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 e5b1edc4a0..3df5b7beac 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 3c1a266e30..56a8562303 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 0000000000..33f0c67328 --- /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 3d9a6a1329..4a981fada9 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 e47ec5183c..56eca1f02a 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 c16cf3437b..5fa73d58a2 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 c18c07b338..ee9185d344 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 c3eea79cc7..40a45b1f88 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 0000000000..444ccdfdbe --- /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 0000000000..2c1a54375f --- /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 d0cc427c45..fe23224625 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 0000000000..07f2516f8a --- /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 0000000000..af5d648b80 --- /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 0000000000..e971215ac6 --- /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 0000000000..18ebee5707 --- /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 0000000000..05bec343f8 --- /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 01e5dd2f8d..5cb81ebe9e 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. Your private statuses are delivered to all instances where you have followers. 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 6783f00452..59133ea733 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: 1 character left other: %{count} 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: 1 character left other: %{count} 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 2bee5a4f96..3f2e913c62 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 a3cba24fcd..5fdd3b390e 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 0000000000..d19cf2e9d6 --- /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 2853aef942..661fc81793 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 0000000000..3ffd8f784a --- /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 0000000000..64297a7e30 --- /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 6ead5bbd93..d1b8476755 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 0000000000..1024542e7b --- /dev/null +++ b/spec/models/custom_filter_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe CustomFilter, type: :model do + +end