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
lolsob-rspec
Eugen Rochko 2018-06-29 15:34:36 +02:00 committed by GitHub
parent 751276484d
commit d878e3e945
38 changed files with 530 additions and 72 deletions

View File

@ -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

View File

@ -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

View File

@ -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,
}));
};

View File

@ -6,6 +6,7 @@ import {
disconnectTimeline, disconnectTimeline,
} from './timelines'; } from './timelines';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
import { fetchFilters } from './filters';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
const { messages } = getLocale(); const { messages } = getLocale();
@ -30,6 +31,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
case 'notification': case 'notification':
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break; break;
case 'filters_changed':
dispatch(fetchFilters());
break;
} }
}, },
}; };

View File

@ -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) { if (featured) {
prepend = ( prepend = (
<div className='status__prepend'> <div className='status__prepend'>

View File

@ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent {
prepend: PropTypes.node, prepend: PropTypes.node,
emptyMessage: PropTypes.node, emptyMessage: PropTypes.node,
alwaysPrepend: PropTypes.bool, alwaysPrepend: PropTypes.bool,
timelineId: PropTypes.string.isRequired,
}; };
static defaultProps = { static defaultProps = {
@ -70,7 +71,7 @@ export default class StatusList extends ImmutablePureComponent {
} }
render () { render () {
const { statusIds, featuredStatusIds, onLoadMore, ...other } = this.props; const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
const { isLoading, isPartial } = other; const { isLoading, isPartial } = other;
if (isPartial) { if (isPartial) {
@ -102,6 +103,7 @@ export default class StatusList extends ImmutablePureComponent {
id={statusId} id={statusId}
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
contextType={timelineId}
/> />
)) ))
) : null; ) : null;
@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent {
featured featured
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
contextType={timelineId}
/> />
)).concat(scrollableContent); )).concat(scrollableContent);
} }

View File

@ -42,7 +42,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
status: getStatus(state, props.id), status: getStatus(state, props),
}); });
return mapStateToProps; return mapStateToProps;

View File

@ -1,15 +1,9 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { injectIntl, FormattedMessage } from 'react-intl';
import SettingText from '../../../components/setting_text';
import SettingToggle from '../../notifications/components/setting_toggle'; 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 @injectIntl
export default class ColumnSettings extends React.PureComponent { export default class ColumnSettings extends React.PureComponent {
@ -21,19 +15,13 @@ export default class ColumnSettings extends React.PureComponent {
}; };
render () { render () {
const { settings, onChange, intl } = this.props; const { settings, onChange } = this.props;
return ( return (
<div> <div>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
</div> </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> </div>
); );
} }

View File

@ -7,7 +7,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const mapStateToProps = state => ({ const mapStateToProps = state => ({
status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
}); });
return mapStateToProps; return mapStateToProps;

View File

@ -7,7 +7,6 @@ import ColumnHeader from '../../components/column_header';
import { expandDirectTimeline } from '../../actions/timelines'; import { expandDirectTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from '../../actions/streaming'; import { connectDirectStream } from '../../actions/streaming';
const messages = defineMessages({ const messages = defineMessages({
@ -86,9 +85,7 @@ export default class DirectTimeline extends React.PureComponent {
onClick={this.handleHeaderClick} onClick={this.handleHeaderClick}
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
> />
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer <StatusListContainer
trackScroll={!pinned} trackScroll={!pinned}

View File

@ -1,14 +1,8 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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 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 @injectIntl
export default class ColumnSettings extends React.PureComponent { export default class ColumnSettings extends React.PureComponent {
@ -20,7 +14,7 @@ export default class ColumnSettings extends React.PureComponent {
}; };
render () { render () {
const { settings, onChange, intl } = this.props; const { settings, onChange } = this.props;
return ( return (
<div> <div>
@ -33,12 +27,6 @@ export default class ColumnSettings extends React.PureComponent {
<div className='column-settings__row'> <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' />} /> <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div> </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> </div>
); );
} }

View File

@ -58,7 +58,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {
const status = getStatus(state, props.params.statusId); const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List(); let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List(); let descendantsIds = Immutable.List();
@ -336,6 +336,7 @@ export default class Status extends ImmutablePureComponent {
id={id} id={id}
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
contextType='thread'
/> />
)); ));
} }

View File

@ -11,15 +11,6 @@ const makeGetStatusIds = () => createSelector([
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
(state) => state.get('statuses'), (state) => state.get('statuses'),
], (columnSettings, statusIds, 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 => { return statusIds.filter(id => {
if (id === null) return true; 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); 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; return showStatus;
}); });
}); });

View File

@ -12,6 +12,7 @@ import { debounce } from 'lodash';
import { uploadCompose, resetCompose } from '../../actions/compose'; import { uploadCompose, resetCompose } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications'; import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area'; import UploadArea from './components/upload_area';
@ -297,6 +298,7 @@ export default class UI extends React.PureComponent {
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
} }
componentDidMount () { componentDidMount () {

View File

@ -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;
}
};

View File

@ -26,6 +26,7 @@ import height_cache from './height_cache';
import custom_emojis from './custom_emojis'; import custom_emojis from './custom_emojis';
import lists from './lists'; import lists from './lists';
import listEditor from './list_editor'; import listEditor from './list_editor';
import filters from './filters';
const reducers = { const reducers = {
dropdown_menu, dropdown_menu,
@ -55,6 +56,7 @@ const reducers = {
custom_emojis, custom_emojis,
lists, lists,
listEditor, listEditor,
filters,
}; };
export default combineReducers(reducers); export default combineReducers(reducers);

View File

@ -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 = () => { export const makeGetStatus = () => {
return createSelector( return createSelector(
[ [
(state, id) => state.getIn(['statuses', id]), (state, { id }) => state.getIn(['statuses', id]),
(state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), (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', id, 'account'])]),
(state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), '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) { if (!statusBase) {
return null; return null;
} }
@ -39,9 +67,13 @@ export const makeGetStatus = () => {
statusReblog = null; 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 => { return statusBase.withMutations(map => {
map.set('reblog', statusReblog); map.set('reblog', statusReblog);
map.set('account', accountBase); map.set('account', accountBase);
map.set('filtered', filtered);
}); });
} }
); );

View File

@ -725,6 +725,20 @@
vertical-align: middle; 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 { .status__prepend-icon-wrapper {
left: -26px; left: -26px;
position: absolute; position: absolute;

View File

@ -153,6 +153,7 @@ class FeedManager
def filter_from_home?(status, receiver_id) def filter_from_home?(status, receiver_id)
return false if receiver_id == status.account_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 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 = status.mentions.pluck(:account_id)
check_for_blocks.concat([status.account_id]) check_for_blocks.concat([status.account_id])
@ -177,6 +178,7 @@ class FeedManager
def filter_from_mentions?(status, receiver_id) def filter_from_mentions?(status, receiver_id)
return true if receiver_id == status.account_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 # This filter is called from NotifyService, but already after the sender of
# the notification has been checked for mute/block. Therefore, it's not # the notification has been checked for mute/block. Therefore, it's not
@ -190,6 +192,20 @@ class FeedManager
should_filter should_filter
end 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 # 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 # 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 # an internal helper: callers must call trim or push updates if

View File

@ -99,6 +99,7 @@ class Account < ApplicationRecord
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id
has_many :report_notes, dependent: :destroy has_many :report_notes, dependent: :destroy
has_many :custom_filters, inverse_of: :account, dependent: :destroy
# Moderation notes # Moderation notes
has_many :account_moderation_notes, dependent: :destroy has_many :account_moderation_notes, dependent: :destroy

View File

@ -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

View File

@ -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

View File

@ -15,33 +15,19 @@
# #
class Invite < ApplicationRecord class Invite < ApplicationRecord
include Expireable
belongs_to :user belongs_to :user
has_many :users, inverse_of: :invite has_many :users, inverse_of: :invite
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) } 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 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? def valid_for_use?
(max_uses.nil? || uses < max_uses) && !expired? (max_uses.nil? || uses < max_uses) && !expired?
end end
def expire!
touch(:expires_at)
end
def expired?
!expires_at.nil? && expires_at < Time.now.utc
end
private private
def set_code def set_code

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class REST::FilterSerializer < ActiveModel::Serializer
attributes :id, :phrase, :context, :expires_at
end

View File

@ -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')

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -474,6 +474,22 @@ en:
follows: You follow follows: You follow
mutes: You mute mutes: You mute
storage: Media storage 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: followers:
domain: Domain 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. 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.

View File

@ -6,17 +6,20 @@ en:
autofollow: People who sign up through the invite will automatically follow you 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 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 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 digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
display_name: display_name:
one: <span class="name-counter">1</span> character left one: <span class="name-counter">1</span> character left
other: <span class="name-counter">%{count}</span> characters 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 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 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 locale: The language of the user interface, e-mails and push notifications
locked: Requires you to manually approve followers locked: Requires you to manually approve followers
note: note:
one: <span class="note-counter">1</span> character left one: <span class="note-counter">1</span> character left
other: <span class="note-counter">%{count}</span> characters 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_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_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 setting_noindex: Affects your public profile and status pages
@ -39,6 +42,7 @@ en:
chosen_languages: Filter languages chosen_languages: Filter languages
confirm_new_password: Confirm new password confirm_new_password: Confirm new password
confirm_password: Confirm password confirm_password: Confirm password
context: Filter contexts
current_password: Current password current_password: Current password
data: Data data: Data
display_name: Display name display_name: Display name
@ -46,6 +50,7 @@ en:
expires_in: Expire after expires_in: Expire after
fields: Profile metadata fields: Profile metadata
header: Header header: Header
irreversible: Drop instead of hide
locale: Interface language locale: Interface language
locked: Lock account locked: Lock account
max_uses: Max number of uses max_uses: Max number of uses
@ -53,6 +58,7 @@ en:
note: Bio note: Bio
otp_attempt: Two-factor code otp_attempt: Two-factor code
password: Password password: Password
phrase: Keyword or phrase
setting_auto_play_gif: Auto-play animated GIFs setting_auto_play_gif: Auto-play animated GIFs
setting_boost_modal: Show confirmation dialog before boosting setting_boost_modal: Show confirmation dialog before boosting
setting_default_language: Posting language setting_default_language: Posting language

View File

@ -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 settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url
end 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 :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| primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development|

View File

@ -114,6 +114,7 @@ Rails.application.routes.draw do
resources :tags, only: [:show] resources :tags, only: [:show]
resources :emojis, only: [:show] resources :emojis, only: [:show]
resources :invites, only: [:index, :create, :destroy] resources :invites, only: [:index, :create, :destroy]
resources :filters, except: [:show]
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy 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 :mutes, only: [:index]
resources :favourites, only: [:index] resources :favourites, only: [:index]
resources :reports, only: [:index, :create] resources :reports, only: [:index, :create]
resources :filters, only: [:index, :create, :show, :update, :destroy]
namespace :apps do namespace :apps do
get :verify_credentials, to: 'credentials#show' get :verify_credentials, to: 'credentials#show'

View File

@ -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

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" 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 t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
end 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| create_table "domain_blocks", force: :cascade do |t|
t.string "domain", default: "", null: false t.string "domain", default: "", null: false
t.datetime "created_at", 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 "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", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", 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", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", 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 add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade

View File

@ -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

View File

@ -0,0 +1,6 @@
Fabricator(:custom_filter) do
account
expires_at nil
phrase 'discourse'
context %w(home notifications)
end

View File

@ -126,6 +126,14 @@ RSpec.describe FeedManager do
reblog = Fabricate(:status, reblog: status, account: jeff) reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
end 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 end
context 'for mentions feed' do context 'for mentions feed' do

View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe CustomFilter, type: :model do
end