Revamp post filtering system (#18058)
* Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
This commit is contained in:
parent
0c87562143
commit
90e5a9bd98
60 changed files with 1292 additions and 250 deletions
|
@ -133,6 +133,12 @@ jobs:
|
|||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate VERSION=20180707154237
|
||||
name: Run migrations up to v2.4.3
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4_3
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all remaining migrations
|
||||
|
@ -167,14 +173,22 @@ jobs:
|
|||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate VERSION=20180707154237
|
||||
name: Run migrations up to v2.4.3
|
||||
environment:
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4_3
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all pre-deployment migrations
|
||||
name: Run all remaining pre-deployment migrations
|
||||
environment:
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all post-deployment remaining migrations
|
||||
name: Run all post-deployment migrations
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:check_database
|
||||
name: Check migration result
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -153,3 +153,5 @@ gem 'concurrent-ruby', require: false
|
|||
gem 'connection_pool', require: false
|
||||
|
||||
gem 'xorcist', '~> 1.1'
|
||||
|
||||
gem 'cocoon', '~> 1.2'
|
||||
|
|
|
@ -163,6 +163,7 @@ GEM
|
|||
elasticsearch-dsl
|
||||
chunky_png (1.4.0)
|
||||
climate_control (0.2.0)
|
||||
cocoon (1.2.15)
|
||||
coderay (1.1.3)
|
||||
color_diff (0.1)
|
||||
concurrent-ruby (1.1.10)
|
||||
|
@ -746,6 +747,7 @@ DEPENDENCIES
|
|||
charlock_holmes (~> 0.7.7)
|
||||
chewy (~> 7.2)
|
||||
climate_control (~> 0.2)
|
||||
cocoon (~> 1.2)
|
||||
color_diff (~> 0.1)
|
||||
concurrent-ruby
|
||||
connection_pool
|
||||
|
|
50
app/controllers/api/v1/filters/keywords_controller.rb
Normal file
50
app/controllers/api/v1/filters/keywords_controller.rb
Normal file
|
@ -0,0 +1,50 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Filters::KeywordsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
|
||||
before_action :require_user!
|
||||
|
||||
before_action :set_keywords, only: :index
|
||||
before_action :set_keyword, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
render json: @keywords, each_serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
@keyword = current_account.custom_filters.find(params[:filter_id]).keywords.create!(resource_params)
|
||||
|
||||
render json: @keyword, serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @keyword, serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@keyword.update!(resource_params)
|
||||
|
||||
render json: @keyword, serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
@keyword.destroy!
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_keywords
|
||||
filter = current_account.custom_filters.includes(:keywords).find(params[:filter_id])
|
||||
@keywords = filter.keywords
|
||||
end
|
||||
|
||||
def set_keyword
|
||||
@keyword = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:keyword, :whole_word)
|
||||
end
|
||||
end
|
|
@ -8,21 +8,32 @@ class Api::V1::FiltersController < Api::BaseController
|
|||
before_action :set_filter, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
render json: @filters, each_serializer: REST::FilterSerializer
|
||||
render json: @filters, each_serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
@filter = current_account.custom_filters.create!(resource_params)
|
||||
render json: @filter, serializer: REST::FilterSerializer
|
||||
ApplicationRecord.transaction do
|
||||
filter_category = current_account.custom_filters.create!(resource_params)
|
||||
@filter = filter_category.keywords.create!(keyword_params)
|
||||
end
|
||||
|
||||
render json: @filter, serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @filter, serializer: REST::FilterSerializer
|
||||
render json: @filter, serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@filter.update!(resource_params)
|
||||
render json: @filter, serializer: REST::FilterSerializer
|
||||
ApplicationRecord.transaction do
|
||||
@filter.update!(keyword_params)
|
||||
@filter.custom_filter.assign_attributes(filter_params)
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1
|
||||
|
||||
@filter.custom_filter.save!
|
||||
end
|
||||
|
||||
render json: @filter, serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -33,14 +44,22 @@ class Api::V1::FiltersController < Api::BaseController
|
|||
private
|
||||
|
||||
def set_filters
|
||||
@filters = current_account.custom_filters
|
||||
@filters = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account })
|
||||
end
|
||||
|
||||
def set_filter
|
||||
@filter = current_account.custom_filters.find(params[:id])
|
||||
@filter = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: [])
|
||||
end
|
||||
|
||||
def filter_params
|
||||
resource_params.slice(:expires_in, :irreversible, :context)
|
||||
end
|
||||
|
||||
def keyword_params
|
||||
resource_params.slice(:phrase, :whole_word)
|
||||
end
|
||||
end
|
||||
|
|
48
app/controllers/api/v2/filters_controller.rb
Normal file
48
app/controllers/api/v2/filters_controller.rb
Normal file
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V2::FiltersController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
|
||||
before_action :require_user!
|
||||
before_action :set_filters, only: :index
|
||||
before_action :set_filter, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
render json: @filters, each_serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def create
|
||||
@filter = current_account.custom_filters.create!(resource_params)
|
||||
|
||||
render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def update
|
||||
@filter.update!(resource_params)
|
||||
|
||||
render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def destroy
|
||||
@filter.destroy!
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_filters
|
||||
@filters = current_account.custom_filters.includes(:keywords)
|
||||
end
|
||||
|
||||
def set_filter
|
||||
@filter = current_account.custom_filters.find(params[:id])
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
|
||||
end
|
||||
end
|
|
@ -4,16 +4,16 @@ class FiltersController < ApplicationController
|
|||
layout 'admin'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_filters, only: :index
|
||||
before_action :set_filter, only: [:edit, :update, :destroy]
|
||||
before_action :set_body_classes
|
||||
|
||||
def index
|
||||
@filters = current_account.custom_filters.order(:phrase)
|
||||
@filters = current_account.custom_filters.includes(:keywords).order(:phrase)
|
||||
end
|
||||
|
||||
def new
|
||||
@filter = current_account.custom_filters.build
|
||||
@filter = current_account.custom_filters.build(action: :warn)
|
||||
@filter.keywords.build
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -43,16 +43,12 @@ class FiltersController < ApplicationController
|
|||
|
||||
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, :whole_word, context: [])
|
||||
params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
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,
|
||||
}));
|
||||
};
|
|
@ -5,6 +5,7 @@ export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
|||
export const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
export const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
|
||||
|
||||
function pushUnique(array, object) {
|
||||
if (array.every(element => element.id !== object.id)) {
|
||||
|
@ -28,6 +29,10 @@ export function importStatuses(statuses) {
|
|||
return { type: STATUSES_IMPORT, statuses };
|
||||
}
|
||||
|
||||
export function importFilters(filters) {
|
||||
return { type: FILTERS_IMPORT, filters };
|
||||
}
|
||||
|
||||
export function importPolls(polls) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
|
@ -61,11 +66,16 @@ export function importFetchedStatuses(statuses) {
|
|||
const accounts = [];
|
||||
const normalStatuses = [];
|
||||
const polls = [];
|
||||
const filters = [];
|
||||
|
||||
function processStatus(status) {
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
|
||||
pushUnique(accounts, status.account);
|
||||
|
||||
if (status.filtered) {
|
||||
status.filtered.forEach(result => pushUnique(filters, result.filter));
|
||||
}
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
processStatus(status.reblog);
|
||||
}
|
||||
|
@ -80,6 +90,7 @@ export function importFetchedStatuses(statuses) {
|
|||
dispatch(importPolls(polls));
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
dispatch(importFilters(filters));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,14 @@ export function normalizeAccount(account) {
|
|||
return account;
|
||||
}
|
||||
|
||||
export function normalizeFilterResult(result) {
|
||||
const normalResult = { ...result };
|
||||
|
||||
normalResult.filter = normalResult.filter.id;
|
||||
|
||||
return normalResult;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus) {
|
||||
const normalStatus = { ...status };
|
||||
normalStatus.account = status.account.id;
|
||||
|
@ -54,6 +62,10 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.poll = status.poll.id;
|
||||
}
|
||||
|
||||
if (status.filtered) {
|
||||
normalStatus.filtered = status.filtered.map(normalizeFilterResult);
|
||||
}
|
||||
|
||||
// Only calculate these values when status first encountered and
|
||||
// when the underlying values change. Otherwise keep the ones
|
||||
// already in the reducer
|
||||
|
|
|
@ -12,10 +12,8 @@ import { saveSettings } from './settings';
|
|||
import { defineMessages } from 'react-intl';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { getFiltersRegex } from '../selectors';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
|
||||
import { requestNotificationPermission } from '../utils/notifications';
|
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||
|
@ -62,20 +60,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
|
||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||
const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
|
||||
|
||||
let filtered = false;
|
||||
|
||||
if (['mention', 'status'].includes(notification.type)) {
|
||||
const dropRegex = filters[0];
|
||||
const regex = filters[1];
|
||||
const searchIndex = searchTextFromRawStatus(notification.status);
|
||||
if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
|
||||
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
||||
|
||||
if (dropRegex && dropRegex.test(searchIndex)) {
|
||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
||||
return;
|
||||
}
|
||||
|
||||
filtered = regex && regex.test(searchIndex);
|
||||
filtered = filters.length > 0;
|
||||
}
|
||||
|
||||
if (['follow_request'].includes(notification.type)) {
|
||||
|
|
|
@ -21,7 +21,6 @@ import {
|
|||
updateReaction as updateAnnouncementsReaction,
|
||||
deleteAnnouncement,
|
||||
} from './announcements';
|
||||
import { fetchFilters } from './filters';
|
||||
import { getLocale } from '../locales';
|
||||
|
||||
const { messages } = getLocale();
|
||||
|
@ -97,9 +96,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||
case 'conversation':
|
||||
dispatch(updateConversations(JSON.parse(data.payload)));
|
||||
break;
|
||||
case 'filters_changed':
|
||||
dispatch(fetchFilters());
|
||||
break;
|
||||
case 'announcement':
|
||||
dispatch(updateAnnouncements(JSON.parse(data.payload)));
|
||||
break;
|
||||
|
|
|
@ -116,6 +116,7 @@ class Status extends ImmutablePureComponent {
|
|||
state = {
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
statusId: undefined,
|
||||
forceFilter: undefined,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
|
@ -277,6 +278,15 @@ class Status extends ImmutablePureComponent {
|
|||
this.handleToggleMediaVisibility();
|
||||
}
|
||||
|
||||
handleUnfilterClick = e => {
|
||||
this.setState({ forceFilter: false });
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.setState({ forceFilter: true });
|
||||
}
|
||||
|
||||
_properStatus () {
|
||||
const { status } = this.props;
|
||||
|
||||
|
@ -328,7 +338,8 @@ class Status extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
|
||||
const matchedFilters = status.get('filtered') || status.getIn(['reblog', 'filtered']);
|
||||
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
|
||||
const minHandlers = this.props.muted ? {} : {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -337,7 +348,11 @@ class Status extends ImmutablePureComponent {
|
|||
return (
|
||||
<HotKeys handlers={minHandlers}>
|
||||
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
|
||||
{' '}
|
||||
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
|
||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
||||
</button>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
|
@ -496,7 +511,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
{media}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters && this.handleFilterClick} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
|
@ -38,6 +38,7 @@ const messages = defineMessages({
|
|||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
|
||||
hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
|
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
|
@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
onFilter: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
withCounters: PropTypes.bool,
|
||||
scrollKey: PropTypes.string,
|
||||
|
@ -207,6 +209,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.props.onMuteConversation(this.props.status);
|
||||
}
|
||||
|
||||
handleFilter = () => {
|
||||
this.props.onFilter();
|
||||
}
|
||||
|
||||
handleCopy = () => {
|
||||
const url = this.props.status.get('url');
|
||||
const textarea = document.createElement('textarea');
|
||||
|
@ -226,6 +232,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.props.onFilter();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
|
||||
|
@ -329,6 +340,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
|
||||
const filterButton = this.props.onFilter && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||
|
@ -337,6 +352,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
{shareButton}
|
||||
|
||||
{filterButton}
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
|
|
|
@ -13,7 +13,6 @@ import { debounce } from 'lodash';
|
|||
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
import { expandNotifications } from '../../actions/notifications';
|
||||
import { fetchFilters } from '../../actions/filters';
|
||||
import { fetchRules } from '../../actions/rules';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
||||
|
@ -368,7 +367,7 @@ class UI extends React.PureComponent {
|
|||
this.props.dispatch(fetchMarkers());
|
||||
this.props.dispatch(expandHomeTimeline());
|
||||
this.props.dispatch(expandNotifications());
|
||||
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
|
||||
|
||||
setTimeout(() => this.props.dispatch(fetchRules()), 3000);
|
||||
|
||||
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
|
||||
|
|
|
@ -1,10 +1,34 @@
|
|||
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
|
||||
import { List as ImmutableList, fromJS } from 'immutable';
|
||||
import { FILTERS_IMPORT } from '../actions/importer';
|
||||
import { Map as ImmutableMap, is, fromJS } from 'immutable';
|
||||
|
||||
export default function filters(state = ImmutableList(), action) {
|
||||
const normalizeFilter = (state, filter) => {
|
||||
const normalizedFilter = fromJS({
|
||||
id: filter.id,
|
||||
title: filter.title,
|
||||
context: filter.context,
|
||||
filter_action: filter.filter_action,
|
||||
expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
|
||||
});
|
||||
|
||||
if (is(state.get(filter.id), normalizedFilter)) {
|
||||
return state;
|
||||
} else {
|
||||
return state.set(filter.id, normalizedFilter);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeFilters = (state, filters) => {
|
||||
filters.forEach(filter => {
|
||||
state = normalizeFilter(state, filter);
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default function filters(state = ImmutableMap(), action) {
|
||||
switch(action.type) {
|
||||
case FILTERS_FETCH_SUCCESS:
|
||||
return fromJS(action.filters);
|
||||
case FILTERS_IMPORT:
|
||||
return normalizeFilters(state, action.filters);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -40,15 +40,15 @@ const toServerSideType = columnType => {
|
|||
const escapeRegExp = string =>
|
||||
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
|
||||
const regexFromFilters = filters => {
|
||||
if (filters.size === 0) {
|
||||
const regexFromKeywords = keywords => {
|
||||
if (keywords.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RegExp(filters.map(filter => {
|
||||
let expr = escapeRegExp(filter.get('phrase'));
|
||||
return new RegExp(keywords.map(keyword_filter => {
|
||||
let expr = escapeRegExp(keyword_filter.get('keyword'));
|
||||
|
||||
if (filter.get('whole_word')) {
|
||||
if (keyword_filter.get('whole_word')) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
}
|
||||
|
@ -62,27 +62,15 @@ const regexFromFilters = filters => {
|
|||
}).join('|'), 'i');
|
||||
};
|
||||
|
||||
// Memoize the filter regexps for each valid server contextType
|
||||
const makeGetFiltersRegex = () => {
|
||||
let memo = {};
|
||||
const getFilters = (state, { contextType }) => {
|
||||
if (!contextType) return null;
|
||||
|
||||
return (state, { contextType }) => {
|
||||
if (!contextType) return ImmutableList();
|
||||
const serverSideType = toServerSideType(contextType);
|
||||
const now = new Date();
|
||||
|
||||
const serverSideType = toServerSideType(contextType);
|
||||
const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
|
||||
|
||||
if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
|
||||
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
|
||||
const regex = regexFromFilters(filters);
|
||||
memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
|
||||
}
|
||||
return memo[serverSideType].results;
|
||||
};
|
||||
return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
|
||||
};
|
||||
|
||||
export const getFiltersRegex = makeGetFiltersRegex();
|
||||
|
||||
export const makeGetStatus = () => {
|
||||
return createSelector(
|
||||
[
|
||||
|
@ -90,10 +78,10 @@ export const makeGetStatus = () => {
|
|||
(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'])]),
|
||||
getFiltersRegex,
|
||||
getFilters,
|
||||
],
|
||||
|
||||
(statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
|
||||
(statusBase, statusReblog, accountBase, accountReblog, filters) => {
|
||||
if (!statusBase) {
|
||||
return null;
|
||||
}
|
||||
|
@ -104,14 +92,17 @@ export const makeGetStatus = () => {
|
|||
statusReblog = null;
|
||||
}
|
||||
|
||||
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
|
||||
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
|
||||
return null;
|
||||
let filtered = false;
|
||||
if ((accountReblog || accountBase).get('id') !== me && filters) {
|
||||
let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
|
||||
if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
|
||||
return null;
|
||||
}
|
||||
if (!filterResults.isEmpty()) {
|
||||
filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
|
||||
}
|
||||
}
|
||||
|
||||
const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
|
||||
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);
|
||||
|
|
|
@ -4,6 +4,7 @@ import loadPolyfills from '../mastodon/load_polyfills';
|
|||
import ready from '../mastodon/ready';
|
||||
import { start } from '../mastodon/common';
|
||||
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
||||
import 'cocoon-js-vanilla';
|
||||
|
||||
start();
|
||||
|
||||
|
|
|
@ -915,7 +915,8 @@ a.name-tag,
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.applications-list__item {
|
||||
.applications-list__item,
|
||||
.filters-list__item {
|
||||
padding: 15px 0;
|
||||
background: $ui-base-color;
|
||||
border: 1px solid lighten($ui-base-color, 4%);
|
||||
|
@ -923,7 +924,8 @@ a.name-tag,
|
|||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.announcements-list {
|
||||
.announcements-list,
|
||||
.filters-list {
|
||||
border: 1px solid lighten($ui-base-color, 4%);
|
||||
border-radius: 4px;
|
||||
|
||||
|
@ -976,6 +978,33 @@ a.name-tag,
|
|||
}
|
||||
}
|
||||
|
||||
.filters-list__item {
|
||||
&__title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__permissions {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.expiration {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&.expired {
|
||||
.expiration {
|
||||
color: lighten($error-red, 12%);
|
||||
}
|
||||
|
||||
.permissions-list__item__icon {
|
||||
color: $dark-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard__counters.admin-account-counters {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
|
|
@ -959,6 +959,21 @@
|
|||
width: 100%;
|
||||
clear: both;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
|
||||
&__button {
|
||||
display: inline;
|
||||
color: lighten($ui-highlight-color, 8%);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__prepend-icon-wrapper {
|
||||
|
|
|
@ -1070,3 +1070,34 @@ code {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keywords-table {
|
||||
thead {
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
tfoot {
|
||||
td {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input.string {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label_input__wrapper {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.table-action-link {
|
||||
margin-top: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -352,7 +352,6 @@ class FeedManager
|
|||
def filter_from_home?(status, receiver_id, crutches)
|
||||
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 = crutches[:active_mentions][status.id] || []
|
||||
check_for_blocks.concat([status.account_id])
|
||||
|
@ -388,7 +387,6 @@ class FeedManager
|
|||
# @return [Boolean]
|
||||
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
|
||||
|
@ -418,34 +416,6 @@ class FeedManager
|
|||
false
|
||||
end
|
||||
|
||||
# Check if the status hits a phrase filter
|
||||
# @param [Status] status
|
||||
# @param [Integer] receiver_id
|
||||
# @param [Symbol] context
|
||||
# @return [Boolean]
|
||||
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! do |filter|
|
||||
if filter.whole_word
|
||||
sb = /\A[[:word:]]/.match?(filter.phrase) ? '\b' : ''
|
||||
eb = /[[:word:]]\z/.match?(filter.phrase) ? '\b' : ''
|
||||
|
||||
/(?mix:#{sb}#{Regexp.escape(filter.phrase)}#{eb})/
|
||||
else
|
||||
/#{Regexp.escape(filter.phrase)}/i
|
||||
end
|
||||
end
|
||||
|
||||
return false if active_filters.empty?
|
||||
|
||||
combined_regex = Regexp.union(active_filters)
|
||||
|
||||
combined_regex.match?(status.proper.searchable_text)
|
||||
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
|
||||
|
|
|
@ -247,6 +247,19 @@ module AccountInteractions
|
|||
account_pins.where(target_account: account).exists?
|
||||
end
|
||||
|
||||
def status_matches_filters(status)
|
||||
active_filters = CustomFilter.cached_filters_for(id)
|
||||
|
||||
filter_matches = active_filters.filter_map do |filter, rules|
|
||||
next if rules[:keywords].blank?
|
||||
|
||||
match = rules[:keywords].match(status.proper.searchable_text)
|
||||
FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
|
||||
end
|
||||
|
||||
filter_matches
|
||||
end
|
||||
|
||||
def followers_for_local_distribution
|
||||
followers.local
|
||||
.joins(:user)
|
||||
|
|
|
@ -3,18 +3,22 @@
|
|||
#
|
||||
# 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
|
||||
# whole_word :boolean default(TRUE), not null
|
||||
# irreversible :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint not null, primary key
|
||||
# account_id :bigint
|
||||
# expires_at :datetime
|
||||
# phrase :text default(""), not null
|
||||
# context :string default([]), not null, is an Array
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# action :integer default(0), not null
|
||||
#
|
||||
|
||||
class CustomFilter < ApplicationRecord
|
||||
self.ignored_columns = %w(whole_word irreversible)
|
||||
|
||||
alias_attribute :title, :phrase
|
||||
alias_attribute :filter_action, :action
|
||||
|
||||
VALID_CONTEXTS = %w(
|
||||
home
|
||||
notifications
|
||||
|
@ -26,16 +30,20 @@ class CustomFilter < ApplicationRecord
|
|||
include Expireable
|
||||
include Redisable
|
||||
|
||||
enum action: [:warn, :hide], _suffix: :action
|
||||
|
||||
belongs_to :account
|
||||
has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
|
||||
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
|
||||
|
||||
validates :phrase, :context, presence: true
|
||||
validates :title, :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
|
||||
|
||||
before_save :prepare_cache_invalidation!
|
||||
before_destroy :prepare_cache_invalidation!
|
||||
after_commit :invalidate_cache!
|
||||
|
||||
def expires_in
|
||||
return @expires_in if defined?(@expires_in)
|
||||
|
@ -44,22 +52,55 @@ class CustomFilter < ApplicationRecord
|
|||
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
|
||||
end
|
||||
|
||||
def irreversible=(value)
|
||||
self.action = value ? :hide : :warn
|
||||
end
|
||||
|
||||
def irreversible?
|
||||
hide_action?
|
||||
end
|
||||
|
||||
def self.cached_filters_for(account_id)
|
||||
active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
|
||||
scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
|
||||
scope.to_a.group_by(&:custom_filter).map do |filter, keywords|
|
||||
keywords.map! do |keyword|
|
||||
if keyword.whole_word
|
||||
sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
|
||||
eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : ''
|
||||
|
||||
/(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/
|
||||
else
|
||||
/#{Regexp.escape(keyword.keyword)}/i
|
||||
end
|
||||
end
|
||||
[filter, { keywords: Regexp.union(keywords) }]
|
||||
end
|
||||
end.to_a
|
||||
|
||||
active_filters.select { |custom_filter, _| !custom_filter.expired? }
|
||||
end
|
||||
|
||||
def prepare_cache_invalidation!
|
||||
@should_invalidate_cache = true
|
||||
end
|
||||
|
||||
def invalidate_cache!
|
||||
return unless @should_invalidate_cache
|
||||
@should_invalidate_cache = false
|
||||
|
||||
Rails.cache.delete("filters:v3:#{account_id}")
|
||||
redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
|
||||
redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clean_up_contexts
|
||||
self.context = Array(context).map(&:strip).filter_map(&:presence)
|
||||
end
|
||||
|
||||
def remove_cache
|
||||
Rails.cache.delete("filters:#{account_id}")
|
||||
redis.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
|
||||
|
|
34
app/models/custom_filter_keyword.rb
Normal file
34
app/models/custom_filter_keyword.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: custom_filter_keywords
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# custom_filter_id :bigint not null
|
||||
# keyword :text default(""), not null
|
||||
# whole_word :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class CustomFilterKeyword < ApplicationRecord
|
||||
belongs_to :custom_filter
|
||||
|
||||
validates :keyword, presence: true
|
||||
|
||||
alias_attribute :phrase, :keyword
|
||||
|
||||
before_save :prepare_cache_invalidation!
|
||||
before_destroy :prepare_cache_invalidation!
|
||||
after_commit :invalidate_cache!
|
||||
|
||||
private
|
||||
|
||||
def prepare_cache_invalidation!
|
||||
custom_filter.prepare_cache_invalidation!
|
||||
end
|
||||
|
||||
def invalidate_cache!
|
||||
custom_filter.invalidate_cache!
|
||||
end
|
||||
end
|
5
app/presenters/filter_result_presenter.rb
Normal file
5
app/presenters/filter_result_presenter.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FilterResultPresenter < ActiveModelSerializers::Model
|
||||
attributes :filter, :keyword_matches
|
||||
end
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
class StatusRelationshipsPresenter
|
||||
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
|
||||
:bookmarks_map
|
||||
:bookmarks_map, :filters_map
|
||||
|
||||
def initialize(statuses, current_account_id = nil, **options)
|
||||
if current_account_id.nil?
|
||||
|
@ -11,12 +11,14 @@ class StatusRelationshipsPresenter
|
|||
@bookmarks_map = {}
|
||||
@mutes_map = {}
|
||||
@pins_map = {}
|
||||
@filters_map = {}
|
||||
else
|
||||
statuses = statuses.compact
|
||||
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
|
||||
conversation_ids = statuses.filter_map(&:conversation_id).uniq
|
||||
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }
|
||||
|
||||
@filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
|
||||
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
|
||||
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
|
||||
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
|
||||
|
@ -24,4 +26,24 @@ class StatusRelationshipsPresenter
|
|||
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_filters_map(statuses, current_account_id)
|
||||
active_filters = CustomFilter.cached_filters_for(current_account_id)
|
||||
|
||||
@filters_map = statuses.each_with_object({}) do |status, h|
|
||||
filter_matches = active_filters.filter_map do |filter, rules|
|
||||
next if rules[:keywords].blank?
|
||||
|
||||
match = rules[:keywords].match(status.proper.searchable_text)
|
||||
FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
|
||||
end
|
||||
|
||||
unless filter_matches.empty?
|
||||
h[status.id] = filter_matches
|
||||
h[status.reblog_of_id] = filter_matches if status.reblog?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
9
app/serializers/rest/filter_keyword_serializer.rb
Normal file
9
app/serializers/rest/filter_keyword_serializer.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::FilterKeywordSerializer < ActiveModel::Serializer
|
||||
attributes :id, :keyword, :whole_word
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
end
|
6
app/serializers/rest/filter_result_serializer.rb
Normal file
6
app/serializers/rest/filter_result_serializer.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::FilterResultSerializer < ActiveModel::Serializer
|
||||
belongs_to :filter, serializer: REST::FilterSerializer
|
||||
has_many :keyword_matches
|
||||
end
|
|
@ -1,10 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::FilterSerializer < ActiveModel::Serializer
|
||||
attributes :id, :phrase, :context, :whole_word, :expires_at,
|
||||
:irreversible
|
||||
attributes :id, :title, :context, :expires_at, :filter_action
|
||||
has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested?
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def rules_requested?
|
||||
instance_options[:rules_requested]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,6 +13,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
attribute :muted, if: :current_user?
|
||||
attribute :bookmarked, if: :current_user?
|
||||
attribute :pinned, if: :pinnable?
|
||||
has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user?
|
||||
|
||||
attribute :content, unless: :source_requested?
|
||||
attribute :text, if: :source_requested?
|
||||
|
@ -120,6 +121,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
end
|
||||
end
|
||||
|
||||
def filtered
|
||||
if instance_options && instance_options[:relationships]
|
||||
instance_options[:relationships].filters_map[object.id] || []
|
||||
else
|
||||
current_user.account.status_matches_filters(object)
|
||||
end
|
||||
end
|
||||
|
||||
def pinnable?
|
||||
current_user? &&
|
||||
current_user.account_id == object.account_id &&
|
||||
|
|
26
app/serializers/rest/v1/filter_serializer.rb
Normal file
26
app/serializers/rest/v1/filter_serializer.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::V1::FilterSerializer < ActiveModel::Serializer
|
||||
attributes :id, :phrase, :context, :whole_word, :expires_at,
|
||||
:irreversible
|
||||
|
||||
delegate :context, :expires_at, to: :custom_filter
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def phrase
|
||||
object.keyword
|
||||
end
|
||||
|
||||
def irreversible
|
||||
custom_filter.irreversible?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def custom_filter
|
||||
object.custom_filter
|
||||
end
|
||||
end
|
|
@ -1,16 +0,0 @@
|
|||
.fields-row
|
||||
.fields-row__column.fields-row__column-6.fields-group
|
||||
= f.input :phrase, as: :string, wrapper: :with_label, hint: false
|
||||
.fields-row__column.fields-row__column-6.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}") }, include_blank: I18n.t('invites.expires_in_prompt')
|
||||
|
||||
.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
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
.fields-group
|
||||
= f.input :irreversible, wrapper: :with_label
|
||||
|
||||
.fields-group
|
||||
= f.input :whole_word, wrapper: :with_label
|
32
app/views/filters/_filter.html.haml
Normal file
32
app/views/filters/_filter.html.haml
Normal file
|
@ -0,0 +1,32 @@
|
|||
.filters-list__item{ class: [filter.expired? && 'expired'] }
|
||||
= link_to edit_filter_path(filter), class: 'filters-list__item__title' do
|
||||
= filter.title
|
||||
|
||||
- if filter.expires?
|
||||
.expiration{ title: t('filters.index.expires_on', date: l(filter.expires_at)) }
|
||||
- if filter.expired?
|
||||
= t('invites.expired')
|
||||
- else
|
||||
= t('filters.index.expires_in', distance: distance_of_time_in_words_to_now(filter.expires_at))
|
||||
|
||||
.filters-list__item__permissions
|
||||
%ul.permissions-list
|
||||
- unless filter.keywords.empty?
|
||||
%li.permissions-list__item
|
||||
.permissions-list__item__icon
|
||||
= fa_icon('paragraph')
|
||||
.permissions-list__item__text
|
||||
.permissions-list__item__text__title
|
||||
= t('filters.index.keywords', count: filter.keywords.size)
|
||||
.permissions-list__item__text__type
|
||||
- keywords = filter.keywords.map(&:keyword)
|
||||
- keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO
|
||||
= keywords.join(', ')
|
||||
|
||||
.announcements-list__item__action-bar
|
||||
.announcements-list__item__meta
|
||||
= t('filters.index.contexts', contexts: filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', '))
|
||||
|
||||
%div
|
||||
= 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, data: { confirm: t('admin.accounts.are_you_sure') }
|
33
app/views/filters/_filter_fields.html.haml
Normal file
33
app/views/filters/_filter_fields.html.haml
Normal file
|
@ -0,0 +1,33 @@
|
|||
.fields-row
|
||||
.fields-row__column.fields-row__column-6.fields-group
|
||||
= f.input :title, as: :string, wrapper: :with_label, hint: false
|
||||
.fields-row__column.fields-row__column-6.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}") }, include_blank: I18n.t('invites.expires_in_prompt')
|
||||
|
||||
.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
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
.fields-group
|
||||
= f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
%h4= t('filters.edit.keywords')
|
||||
|
||||
.table-wrapper
|
||||
%table.table.keywords-table
|
||||
%thead
|
||||
%tr
|
||||
%th= t('simple_form.labels.defaults.phrase')
|
||||
%th= t('simple_form.labels.defaults.whole_word')
|
||||
%th
|
||||
%tbody
|
||||
= f.simple_fields_for :keywords do |keyword|
|
||||
= render 'keyword_fields', f: keyword
|
||||
%tfoot
|
||||
%tr
|
||||
%td{ colspan: 3}
|
||||
= link_to_add_association f, :keywords, class: 'table-action-link', partial: 'keyword_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do
|
||||
= safe_join([fa_icon('plus'), t('filters.edit.add_keyword')])
|
8
app/views/filters/_keyword_fields.html.haml
Normal file
8
app/views/filters/_keyword_fields.html.haml
Normal file
|
@ -0,0 +1,8 @@
|
|||
%tr.nested-fields
|
||||
%td= f.input :keyword, as: :string
|
||||
%td
|
||||
.label_input__wrapper= f.input_field :whole_word
|
||||
%td
|
||||
= f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the <tr/>
|
||||
= link_to_remove_association(f, class: 'table-action-link') do
|
||||
= safe_join([fa_icon('times'), t('filters.index.delete')])
|
|
@ -2,7 +2,7 @@
|
|||
= t('filters.edit.title')
|
||||
|
||||
= simple_form_for @filter, url: filter_path(@filter), method: :put do |f|
|
||||
= render 'fields', f: f
|
||||
= render 'filter_fields', f: f
|
||||
|
||||
.actions
|
||||
= f.button :button, t('generic.save_changes'), type: :submit
|
||||
|
|
|
@ -7,18 +7,5 @@
|
|||
- if @filters.empty?
|
||||
%div.muted-hint.center-text= t 'filters.index.empty'
|
||||
- else
|
||||
.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
|
||||
.applications-list
|
||||
= render partial: 'filter', collection: @filters
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
= t('filters.new.title')
|
||||
|
||||
= simple_form_for @filter, url: filters_path do |f|
|
||||
= render 'fields', f: f
|
||||
= render 'filter_fields', f: f
|
||||
|
||||
.actions
|
||||
= f.button :button, t('filters.new.title'), type: :submit
|
||||
= f.button :button, t('filters.new.save'), type: :submit
|
||||
|
|
|
@ -1124,15 +1124,24 @@ en:
|
|||
public: Public timelines
|
||||
thread: Conversations
|
||||
edit:
|
||||
add_keyword: Add keyword
|
||||
keywords: Keywords
|
||||
title: Edit filter
|
||||
errors:
|
||||
deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface.
|
||||
invalid_context: None or invalid context supplied
|
||||
invalid_irreversible: Irreversible filtering only works with home or notifications context
|
||||
index:
|
||||
contexts: Filters in %{contexts}
|
||||
delete: Delete
|
||||
empty: You have no filters.
|
||||
expires_in: Expires in %{distance}
|
||||
expires_on: Expires on %{date}
|
||||
keywords:
|
||||
one: "%{count} keyword"
|
||||
other: "%{count} keywords"
|
||||
title: Filters
|
||||
new:
|
||||
save: Save new filter
|
||||
title: Add new filter
|
||||
footer:
|
||||
developers: Developers
|
||||
|
|
|
@ -68,6 +68,11 @@ en:
|
|||
with_dns_records: An attempt to resolve the given domain's DNS records will be made and the results will also be blocked
|
||||
featured_tag:
|
||||
name: 'You might want to use one of these:'
|
||||
filters:
|
||||
action: Chose which action to perform when a post matches the filter
|
||||
actions:
|
||||
hide: Completely hide the filtered content, behaving as if it did not exist
|
||||
warn: Hide the filtered content behind a warning mentioning the filter's title
|
||||
form_challenge:
|
||||
current_password: You are entering a secure area
|
||||
imports:
|
||||
|
@ -181,6 +186,7 @@ en:
|
|||
setting_use_pending_items: Slow mode
|
||||
severity: Severity
|
||||
sign_in_token_attempt: Security code
|
||||
title: Title
|
||||
type: Import type
|
||||
username: Username
|
||||
username_or_email: Username or Email
|
||||
|
@ -189,6 +195,10 @@ en:
|
|||
with_dns_records: Include MX records and IPs of the domain
|
||||
featured_tag:
|
||||
name: Hashtag
|
||||
filters:
|
||||
actions:
|
||||
hide: Hide completely
|
||||
warn: Hide with a warning
|
||||
interactions:
|
||||
must_be_follower: Block notifications from non-followers
|
||||
must_be_following: Block notifications from people you don't follow
|
||||
|
|
|
@ -451,10 +451,16 @@ Rails.application.routes.draw do
|
|||
resources :bookmarks, only: [:index]
|
||||
resources :reports, only: [:create]
|
||||
resources :trends, only: [:index], controller: 'trends/tags'
|
||||
resources :filters, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :filters, only: [:index, :create, :show, :update, :destroy] do
|
||||
resources :keywords, only: [:index, :create], controller: 'filters/keywords'
|
||||
end
|
||||
resources :endorsements, only: [:index]
|
||||
resources :markers, only: [:index, :create]
|
||||
|
||||
namespace :filters do
|
||||
resources :keywords, only: [:show, :update, :destroy]
|
||||
end
|
||||
|
||||
namespace :apps do
|
||||
get :verify_credentials, to: 'credentials#show'
|
||||
end
|
||||
|
@ -589,6 +595,7 @@ Rails.application.routes.draw do
|
|||
resources :media, only: [:create]
|
||||
get '/search', to: 'search#index', as: :search
|
||||
resources :suggestions, only: [:index]
|
||||
resources :filters, only: [:index, :create, :show, :update, :destroy]
|
||||
|
||||
namespace :admin do
|
||||
resources :accounts, only: [:index]
|
||||
|
|
13
db/migrate/20220613110628_create_custom_filter_keywords.rb
Normal file
13
db/migrate/20220613110628_create_custom_filter_keywords.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateCustomFilterKeywords < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :custom_filter_keywords do |t|
|
||||
t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false
|
||||
t.text :keyword, null: false, default: ''
|
||||
t.boolean :whole_word, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
34
db/migrate/20220613110711_migrate_custom_filters.rb
Normal file
34
db/migrate/20220613110711_migrate_custom_filters.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MigrateCustomFilters < ActiveRecord::Migration[6.1]
|
||||
def up
|
||||
# Preserve IDs as much as possible to not confuse existing clients.
|
||||
# As long as this migration is irreversible, we do not have to deal with conflicts.
|
||||
safety_assured do
|
||||
execute <<-SQL.squish
|
||||
INSERT INTO custom_filter_keywords (id, custom_filter_id, keyword, whole_word, created_at, updated_at)
|
||||
SELECT id, id, phrase, whole_word, created_at, updated_at
|
||||
FROM custom_filters
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# Copy back changes from custom filters guaranteed to be from the old API
|
||||
safety_assured do
|
||||
execute <<-SQL.squish
|
||||
UPDATE custom_filters
|
||||
SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word
|
||||
FROM custom_filter_keywords
|
||||
WHERE custom_filters.id = custom_filter_keywords.id AND custom_filters.id = custom_filter_keywords.custom_filter_id
|
||||
SQL
|
||||
end
|
||||
|
||||
# Drop every keyword as we can't safely provide a 1:1 mapping
|
||||
safety_assured do
|
||||
execute <<-SQL.squish
|
||||
TRUNCATE custom_filter_keywords RESTART IDENTITY
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
20
db/migrate/20220613110834_add_action_to_custom_filters.rb
Normal file
20
db/migrate/20220613110834_add_action_to_custom_filters.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
|
||||
|
||||
class AddActionToCustomFilters < ActiveRecord::Migration[6.1]
|
||||
include Mastodon::MigrationHelpers
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
safety_assured do
|
||||
add_column_with_default :custom_filters, :action, :integer, allow_null: false, default: 0
|
||||
execute 'UPDATE custom_filters SET action = 1 WHERE irreversible IS TRUE'
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
execute 'UPDATE custom_filters SET irreversible = (action = 1)'
|
||||
remove_column :custom_filters, :action
|
||||
end
|
||||
end
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
|
||||
|
||||
class RemoveWholeWordFromCustomFilters < ActiveRecord::Migration[6.1]
|
||||
include Mastodon::MigrationHelpers
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
safety_assured do
|
||||
remove_column :custom_filters, :whole_word
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
safety_assured do
|
||||
add_column_with_default :custom_filters, :whole_word, :boolean, default: true, allow_null: false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
|
||||
|
||||
class RemoveIrreversibleFromCustomFilters < ActiveRecord::Migration[6.1]
|
||||
include Mastodon::MigrationHelpers
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
safety_assured do
|
||||
remove_column :custom_filters, :irreversible
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
safety_assured do
|
||||
add_column_with_default :custom_filters, :irreversible, :boolean, allow_null: false, default: false
|
||||
end
|
||||
end
|
||||
end
|
15
db/schema.rb
15
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2022_06_06_044941) do
|
||||
ActiveRecord::Schema.define(version: 2022_06_13_110903) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -339,15 +339,23 @@ ActiveRecord::Schema.define(version: 2022_06_06_044941) do
|
|||
t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
|
||||
end
|
||||
|
||||
create_table "custom_filter_keywords", force: :cascade do |t|
|
||||
t.bigint "custom_filter_id", null: false
|
||||
t.text "keyword", default: "", null: false
|
||||
t.boolean "whole_word", default: true, null: false
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id"
|
||||
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.boolean "whole_word", default: true, null: false
|
||||
t.integer "action", default: 0, null: false
|
||||
t.index ["account_id"], name: "index_custom_filters_on_account_id"
|
||||
end
|
||||
|
||||
|
@ -1082,6 +1090,7 @@ ActiveRecord::Schema.define(version: 2022_06_06_044941) do
|
|||
add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id", 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_filter_keywords", "custom_filters", on_delete: :cascade
|
||||
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
|
||||
add_foreign_key "devices", "accounts", on_delete: :cascade
|
||||
add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
|
||||
|
|
|
@ -38,10 +38,26 @@ namespace :tests do
|
|||
puts 'Instance actor does not have a private key'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
unless Account.find_by(username: 'user', domain: nil).custom_filters.map { |filter| filter.keywords.pluck(:keyword) } == [['test'], ['take']]
|
||||
puts 'CustomFilterKeyword records not created as expected'
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Populate the database with test data for 2.4.3'
|
||||
task populate_v2_4_3: :environment do # rubocop:disable Naming/VariableNumber
|
||||
ActiveRecord::Base.connection.execute(<<~SQL)
|
||||
INSERT INTO "custom_filters"
|
||||
(id, account_id, phrase, context, whole_word, irreversible, created_at, updated_at)
|
||||
VALUES
|
||||
(1, 2, 'test', '{ "home", "public" }', true, true, now(), now()),
|
||||
(2, 2, 'take', '{ "home" }', false, false, now(), now());
|
||||
SQL
|
||||
end
|
||||
|
||||
desc 'Populate the database with test data for 2.4.0'
|
||||
task populate_v2_4: :environment do
|
||||
task populate_v2_4: :environment do # rubocop:disable Naming/VariableNumber
|
||||
ActiveRecord::Base.connection.execute(<<~SQL)
|
||||
INSERT INTO "settings"
|
||||
(id, thing_type, thing_id, var, value, created_at, updated_at)
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
||||
"blurhash": "^1.1.5",
|
||||
"classnames": "^2.3.1",
|
||||
"cocoon-js-vanilla": "^1.2.0",
|
||||
"color-blend": "^3.0.1",
|
||||
"compression-webpack-plugin": "^6.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
|
@ -71,6 +72,7 @@
|
|||
"intl-relativeformat": "^6.4.3",
|
||||
"is-nan": "^1.3.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsdom": "^20.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mark-loader": "^0.1.6",
|
||||
"marky": "^1.2.4",
|
||||
|
|
142
spec/controllers/api/v1/filters/keywords_controller_spec.rb
Normal file
142
spec/controllers/api/v1/filters/keywords_controller_spec.rb
Normal file
|
@ -0,0 +1,142 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::V1::Filters::KeywordsController, type: :controller do
|
||||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
||||
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let(:other_user) { Fabricate(:user) }
|
||||
let(:other_filter) { Fabricate(:custom_filter, account: other_user.account) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:doorkeeper_token) { token }
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
let(:scopes) { 'read:filters' }
|
||||
let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
|
||||
|
||||
it 'returns http success' do
|
||||
get :index, params: { filter_id: filter.id }
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
context "when trying to access another's user filters" do
|
||||
it 'returns http not found' do
|
||||
get :index, params: { filter_id: other_filter.id }
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:scopes) { 'write:filters' }
|
||||
let(:filter_id) { filter.id }
|
||||
|
||||
before do
|
||||
post :create, params: { filter_id: filter_id, keyword: 'magic', whole_word: false }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'returns a keyword' do
|
||||
json = body_as_json
|
||||
expect(json[:keyword]).to eq 'magic'
|
||||
expect(json[:whole_word]).to eq false
|
||||
end
|
||||
|
||||
it 'creates a keyword' do
|
||||
filter = user.account.custom_filters.first
|
||||
expect(filter).to_not be_nil
|
||||
expect(filter.keywords.pluck(:keyword)).to eq ['magic']
|
||||
end
|
||||
|
||||
context "when trying to add to another another's user filters" do
|
||||
let(:filter_id) { other_filter.id }
|
||||
|
||||
it 'returns http not found' do
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
let(:scopes) { 'read:filters' }
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, keyword: 'foo', whole_word: false, custom_filter: filter) }
|
||||
|
||||
before do
|
||||
get :show, params: { id: keyword.id }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'returns expected data' do
|
||||
json = body_as_json
|
||||
expect(json[:keyword]).to eq 'foo'
|
||||
expect(json[:whole_word]).to eq false
|
||||
end
|
||||
|
||||
context "when trying to access another user's filter keyword" do
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) }
|
||||
|
||||
it 'returns http not found' do
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT #update' do
|
||||
let(:scopes) { 'write:filters' }
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
|
||||
|
||||
before do
|
||||
get :update, params: { id: keyword.id, keyword: 'updated' }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'updates the keyword' do
|
||||
expect(keyword.reload.keyword).to eq 'updated'
|
||||
end
|
||||
|
||||
context "when trying to update another user's filter keyword" do
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) }
|
||||
|
||||
it 'returns http not found' do
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy' do
|
||||
let(:scopes) { 'write:filters' }
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
|
||||
|
||||
before do
|
||||
delete :destroy, params: { id: keyword.id }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'removes the filter' do
|
||||
expect { keyword.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
context "when trying to update another user's filter keyword" do
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) }
|
||||
|
||||
it 'returns http not found' do
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -34,7 +34,7 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
|
|||
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.keywords.pluck(:keyword)).to eq ['magic']
|
||||
expect(filter.context).to eq %w(home)
|
||||
expect(filter.irreversible?).to be true
|
||||
expect(filter.expires_at).to be_nil
|
||||
|
@ -42,21 +42,23 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
|
|||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
let(:scopes) { 'read:filters' }
|
||||
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let(:scopes) { 'read:filters' }
|
||||
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
|
||||
|
||||
it 'returns http success' do
|
||||
get :show, params: { id: filter.id }
|
||||
get :show, params: { id: keyword.id }
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT #update' do
|
||||
let(:scopes) { 'write:filters' }
|
||||
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let(:scopes) { 'write:filters' }
|
||||
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
|
||||
|
||||
before do
|
||||
put :update, params: { id: filter.id, phrase: 'updated' }
|
||||
put :update, params: { id: keyword.id, phrase: 'updated' }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
|
@ -64,16 +66,17 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
|
|||
end
|
||||
|
||||
it 'updates the filter' do
|
||||
expect(filter.reload.phrase).to eq 'updated'
|
||||
expect(keyword.reload.phrase).to eq 'updated'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy' do
|
||||
let(:scopes) { 'write:filters' }
|
||||
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let(:scopes) { 'write:filters' }
|
||||
let(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
|
||||
|
||||
before do
|
||||
delete :destroy, params: { id: filter.id }
|
||||
delete :destroy, params: { id: keyword.id }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
|
@ -81,7 +84,7 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
|
|||
end
|
||||
|
||||
it 'removes the filter' do
|
||||
expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||
expect { keyword.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,6 +20,58 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
|
|||
get :show, params: { id: status.id }
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
context 'when post includes filtered terms' do
|
||||
let(:status) { Fabricate(:status, text: 'this toot is about that banned word') }
|
||||
|
||||
before do
|
||||
user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
get :show, params: { id: status.id }
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'returns filter information' do
|
||||
get :show, params: { id: status.id }
|
||||
json = body_as_json
|
||||
expect(json[:filtered][0]).to include({
|
||||
filter: a_hash_including({
|
||||
id: user.account.custom_filters.first.id.to_s,
|
||||
title: 'filter1',
|
||||
filter_action: 'hide',
|
||||
}),
|
||||
keyword_matches: ['banned'],
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reblog includes filtered terms' do
|
||||
let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) }
|
||||
|
||||
before do
|
||||
user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
get :show, params: { id: status.id }
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'returns filter information' do
|
||||
get :show, params: { id: status.id }
|
||||
json = body_as_json
|
||||
expect(json[:reblog][:filtered][0]).to include({
|
||||
filter: a_hash_including({
|
||||
id: user.account.custom_filters.first.id.to_s,
|
||||
title: 'filter1',
|
||||
filter_action: 'hide',
|
||||
}),
|
||||
keyword_matches: ['banned'],
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #context' do
|
||||
|
|
121
spec/controllers/api/v2/filters_controller_spec.rb
Normal file
121
spec/controllers/api/v2/filters_controller_spec.rb
Normal file
|
@ -0,0 +1,121 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::V2::FiltersController, type: :controller do
|
||||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:doorkeeper_token) { token }
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
let(:scopes) { 'read:filters' }
|
||||
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
|
||||
let(:scopes) { 'write:filters' }
|
||||
|
||||
before do
|
||||
post :create, params: { title: 'magic', context: %w(home), filter_action: 'hide', keywords_attributes: [keyword: 'magic'] }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'returns a filter with keywords' do
|
||||
json = body_as_json
|
||||
expect(json[:title]).to eq 'magic'
|
||||
expect(json[:filter_action]).to eq 'hide'
|
||||
expect(json[:context]).to eq ['home']
|
||||
expect(json[:keywords].map { |keyword| keyword.slice(:keyword, :whole_word) }).to eq [{ keyword: 'magic', whole_word: true }]
|
||||
end
|
||||
|
||||
it 'creates a filter' do
|
||||
filter = user.account.custom_filters.first
|
||||
expect(filter).to_not be_nil
|
||||
expect(filter.keywords.pluck(:keyword)).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(:scopes) { 'read:filters' }
|
||||
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(:scopes) { 'write:filters' }
|
||||
let!(:filter) { Fabricate(:custom_filter, account: user.account) }
|
||||
let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
|
||||
|
||||
context 'updating filter parameters' do
|
||||
before do
|
||||
put :update, params: { id: filter.id, title: 'updated', context: %w(home public) }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'updates the filter title' do
|
||||
expect(filter.reload.title).to eq 'updated'
|
||||
end
|
||||
|
||||
it 'updates the filter context' do
|
||||
expect(filter.reload.context).to eq %w(home public)
|
||||
end
|
||||
end
|
||||
|
||||
context 'updating keywords in bulk' do
|
||||
before do
|
||||
allow(redis).to receive_messages(publish: nil)
|
||||
put :update, params: { id: filter.id, keywords_attributes: [{ id: keyword.id, keyword: 'updated' }] }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
|
||||
it 'updates the keyword' do
|
||||
expect(keyword.reload.keyword).to eq 'updated'
|
||||
end
|
||||
|
||||
it 'sends exactly one filters_changed event' do
|
||||
expect(redis).to have_received(:publish).with("timeline:#{user.account.id}", Oj.dump(event: :filters_changed)).once
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy' do
|
||||
let(:scopes) { 'write:filters' }
|
||||
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
|
4
spec/fabricators/custom_filter_keyword_fabricator.rb
Normal file
4
spec/fabricators/custom_filter_keyword_fabricator.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
Fabricator(:custom_filter_keyword) do
|
||||
custom_filter
|
||||
keyword 'discourse'
|
||||
end
|
|
@ -127,38 +127,6 @@ RSpec.describe FeedManager do
|
|||
reblog = Fabricate(:status, reblog: status, account: jeff)
|
||||
expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true
|
||||
end
|
||||
|
||||
context 'for irreversibly muted phrases' do
|
||||
it 'considers word boundaries when matching' do
|
||||
alice.custom_filters.create!(phrase: 'bob', context: %w(home), irreversible: true)
|
||||
alice.follow!(jeff)
|
||||
status = Fabricate(:status, text: 'bobcats', account: jeff)
|
||||
expect(FeedManager.instance.filter?(:home, status, alice)).to be_falsy
|
||||
end
|
||||
|
||||
it 'returns true if phrase is contained' 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)).to be true
|
||||
end
|
||||
|
||||
it 'matches substrings if whole_word is false' do
|
||||
alice.custom_filters.create!(phrase: 'take', context: %w(home), whole_word: false, irreversible: true)
|
||||
alice.follow!(jeff)
|
||||
status = Fabricate(:status, text: 'shiitake', account: jeff)
|
||||
expect(FeedManager.instance.filter?(:home, status, alice)).to be true
|
||||
end
|
||||
|
||||
it 'returns true if phrase is contained in a poll option' 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: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff)
|
||||
expect(FeedManager.instance.filter?(:home, status, alice)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for mentions feed' do
|
||||
|
|
4
spec/models/custom_filter_keyword_spec.rb
Normal file
4
spec/models/custom_filter_keyword_spec.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CustomFilterKeyword, type: :model do
|
||||
end
|
|
@ -5,7 +5,7 @@ require 'rails_helper'
|
|||
RSpec.describe StatusRelationshipsPresenter do
|
||||
describe '.initialize' do
|
||||
before do
|
||||
allow(Status).to receive(:reblogs_map).with(status_ids, current_account_id).and_return(default_map)
|
||||
allow(Status).to receive(:reblogs_map).with(match_array(status_ids), current_account_id).and_return(default_map)
|
||||
allow(Status).to receive(:favourites_map).with(status_ids, current_account_id).and_return(default_map)
|
||||
allow(Status).to receive(:bookmarks_map).with(status_ids, current_account_id).and_return(default_map)
|
||||
allow(Status).to receive(:mutes_map).with(anything, current_account_id).and_return(default_map)
|
||||
|
@ -15,7 +15,7 @@ RSpec.describe StatusRelationshipsPresenter do
|
|||
let(:presenter) { StatusRelationshipsPresenter.new(statuses, current_account_id, **options) }
|
||||
let(:current_account_id) { Fabricate(:account).id }
|
||||
let(:statuses) { [Fabricate(:status)] }
|
||||
let(:status_ids) { statuses.map(&:id) }
|
||||
let(:status_ids) { statuses.map(&:id) + statuses.map(&:reblog_of_id).compact }
|
||||
let(:default_map) { { 1 => true } }
|
||||
|
||||
context 'options are not set' do
|
||||
|
@ -69,5 +69,30 @@ RSpec.describe StatusRelationshipsPresenter do
|
|||
expect(presenter.pins_map).to eq default_map.merge(options[:pins_map])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when post includes filtered terms' do
|
||||
let(:statuses) { [Fabricate(:status, text: 'this toot is about that banned word'), Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about an irrelevant word'))] }
|
||||
let(:options) { {} }
|
||||
|
||||
before do
|
||||
Account.find(current_account_id).custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
|
||||
end
|
||||
|
||||
it 'sets @filters_map to filter top-level status' do
|
||||
matched_filters = presenter.filters_map[statuses[0].id]
|
||||
expect(matched_filters.size).to eq 1
|
||||
|
||||
expect(matched_filters[0].filter.title).to eq 'filter1'
|
||||
expect(matched_filters[0].keyword_matches).to eq ['banned']
|
||||
end
|
||||
|
||||
it 'sets @filters_map to filter reblogged status' do
|
||||
matched_filters = presenter.filters_map[statuses[1].reblog_of_id]
|
||||
expect(matched_filters.size).to eq 1
|
||||
|
||||
expect(matched_filters[0].filter.title).to eq 'filter1'
|
||||
expect(matched_filters[0].keyword_matches).to eq ['irrelevant']
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,6 +12,7 @@ const url = require('url');
|
|||
const uuid = require('uuid');
|
||||
const fs = require('fs');
|
||||
const WebSocket = require('ws');
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const alwaysRequireAuth = process.env.LIMITED_FEDERATION_MODE === 'true' || process.env.WHITELIST_MODE === 'true' || process.env.AUTHORIZED_FETCH === 'true';
|
||||
|
@ -503,6 +504,9 @@ const startWorker = async (workerId) => {
|
|||
if (event === 'kill') {
|
||||
log.verbose(req.requestId, `Closing connection for ${req.accountId} due to expired access token`);
|
||||
eventHandlers.onKill();
|
||||
} else if (event === 'filters_changed') {
|
||||
log.verbose(req.requestId, `Invalidating filters cache for ${req.accountId}`);
|
||||
req.cachedFilters = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -512,7 +516,8 @@ const startWorker = async (workerId) => {
|
|||
* @param {any} res
|
||||
*/
|
||||
const subscribeHttpToSystemChannel = (req, res) => {
|
||||
const systemChannelId = `timeline:access_token:${req.accessTokenId}`;
|
||||
const accessTokenChannelId = `timeline:access_token:${req.accessTokenId}`;
|
||||
const systemChannelId = `timeline:system:${req.accountId}`;
|
||||
|
||||
const listener = createSystemMessageListener(req, {
|
||||
|
||||
|
@ -523,9 +528,11 @@ const startWorker = async (workerId) => {
|
|||
});
|
||||
|
||||
res.on('close', () => {
|
||||
unsubscribe(`${redisPrefix}${accessTokenChannelId}`, listener);
|
||||
unsubscribe(`${redisPrefix}${systemChannelId}`, listener);
|
||||
});
|
||||
|
||||
subscribe(`${redisPrefix}${accessTokenChannelId}`, listener);
|
||||
subscribe(`${redisPrefix}${systemChannelId}`, listener);
|
||||
};
|
||||
|
||||
|
@ -674,17 +681,84 @@ const startWorker = async (workerId) => {
|
|||
queries.push(client.query('SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2', [req.accountId, accountDomain]));
|
||||
}
|
||||
|
||||
if (!unpackedPayload.filter_results && !req.cachedFilters) {
|
||||
queries.push(client.query('SELECT filter.id AS id, filter.phrase AS title, filter.context AS context, filter.expires_at AS expires_at, filter.action AS filter_action, keyword.keyword AS keyword, keyword.whole_word AS whole_word FROM custom_filter_keywords keyword JOIN custom_filters filter ON keyword.custom_filter_id = filter.id WHERE filter.account_id = $1 AND filter.expires_at IS NULL OR filter.expires_at > NOW()', [req.accountId]));
|
||||
}
|
||||
|
||||
Promise.all(queries).then(values => {
|
||||
done();
|
||||
|
||||
if (values[0].rows.length > 0 || (values.length > 1 && values[1].rows.length > 0)) {
|
||||
if (values[0].rows.length > 0 || (accountDomain && values[1].rows.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unpackedPayload.filter_results && !req.cachedFilters) {
|
||||
const filterRows = values[accountDomain ? 2 : 1].rows;
|
||||
|
||||
req.cachedFilters = filterRows.reduce((cache, row) => {
|
||||
if (cache[row.id]) {
|
||||
cache[row.id].keywords.push([row.keyword, row.whole_word]);
|
||||
} else {
|
||||
cache[row.id] = {
|
||||
keywords: [[row.keyword, row.whole_word]],
|
||||
expires_at: row.expires_at,
|
||||
repr: {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
context: row.context,
|
||||
expires_at: row.expires_at,
|
||||
filter_action: row.filter_action,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return cache;
|
||||
}, {});
|
||||
|
||||
Object.keys(req.cachedFilters).forEach((key) => {
|
||||
req.cachedFilters[key].regexp = new RegExp(req.cachedFilters[key].keywords.map(([keyword, whole_word]) => {
|
||||
let expr = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');;
|
||||
|
||||
if (whole_word) {
|
||||
if (/^[\w]/.test(expr)) {
|
||||
expr = `\\b${expr}`;
|
||||
}
|
||||
|
||||
if (/[\w]$/.test(expr)) {
|
||||
expr = `${expr}\\b`;
|
||||
}
|
||||
}
|
||||
|
||||
return expr;
|
||||
}).join('|'), 'i');
|
||||
});
|
||||
}
|
||||
|
||||
// Check filters
|
||||
if (req.cachedFilters && !unpackedPayload.filter_results) {
|
||||
const status = unpackedPayload;
|
||||
const searchContent = ([status.spoiler_text || '', status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
const searchIndex = JSDOM.fragment(searchContent).textContent;
|
||||
|
||||
const now = new Date();
|
||||
payload.filter_results = [];
|
||||
Object.values(req.cachedFilters).forEach((cachedFilter) => {
|
||||
if ((cachedFilter.expires_at === null || cachedFilter.expires_at > now)) {
|
||||
const keyword_matches = searchIndex.match(cachedFilter.regexp);
|
||||
if (keyword_matches) {
|
||||
payload.filter_results.push({
|
||||
filter: cachedFilter.repr,
|
||||
keyword_matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
transmit();
|
||||
}).catch(err => {
|
||||
done();
|
||||
log.error(err);
|
||||
done();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -1009,7 +1083,8 @@ const startWorker = async (workerId) => {
|
|||
* @param {WebSocketSession} session
|
||||
*/
|
||||
const subscribeWebsocketToSystemChannel = ({ socket, request, subscriptions }) => {
|
||||
const systemChannelId = `timeline:access_token:${request.accessTokenId}`;
|
||||
const accessTokenChannelId = `timeline:access_token:${request.accessTokenId}`;
|
||||
const systemChannelId = `timeline:system:${request.accountId}`;
|
||||
|
||||
const listener = createSystemMessageListener(request, {
|
||||
|
||||
|
@ -1019,8 +1094,15 @@ const startWorker = async (workerId) => {
|
|||
|
||||
});
|
||||
|
||||
subscribe(`${redisPrefix}${accessTokenChannelId}`, listener);
|
||||
subscribe(`${redisPrefix}${systemChannelId}`, listener);
|
||||
|
||||
subscriptions[accessTokenChannelId] = {
|
||||
listener,
|
||||
stopHeartbeat: () => {
|
||||
},
|
||||
};
|
||||
|
||||
subscriptions[systemChannelId] = {
|
||||
listener,
|
||||
stopHeartbeat: () => {
|
||||
|
|
67
yarn.lock
67
yarn.lock
|
@ -1486,7 +1486,7 @@
|
|||
"@types/yargs" "^16.0.0"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@jest/types@^28.1.0", "@jest/types@^28.1.1":
|
||||
"@jest/types@^28.1.1":
|
||||
version "28.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.1.tgz#d059bbc80e6da6eda9f081f293299348bd78ee0b"
|
||||
integrity sha512-vRXVqSg1VhDnB8bWcmvLzmg0Bt9CRKVgHPXqYwvWMX3TvAjeO+nRuK6+VdTKCtWOvYlmkF/HqNAL/z+N3B53Kw==
|
||||
|
@ -2153,7 +2153,7 @@ acorn@^8.0.4:
|
|||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.3.0.tgz#1193f9b96c4e8232f00b11a9edff81b2c8b98b88"
|
||||
integrity sha512-tqPKHZ5CaBJw0Xmy0ZZvLs1qTV+BNFSyvn77ASXkpBNfIRk8ev26fKrD9iLGwGA9zedPao52GSHzq8lyZG0NUw==
|
||||
|
||||
acorn@^8.5.0:
|
||||
acorn@^8.5.0, acorn@^8.7.1:
|
||||
version "8.7.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
|
||||
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
|
||||
|
@ -3304,6 +3304,11 @@ coa@^2.0.2:
|
|||
chalk "^2.4.1"
|
||||
q "^1.1.2"
|
||||
|
||||
cocoon-js-vanilla@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/cocoon-js-vanilla/-/cocoon-js-vanilla-1.2.0.tgz#595348499d315d3b5828dd77a20974756cf59321"
|
||||
integrity sha512-qLomIVL0Krfc983WLgaYPPktMjMtBN+F/CV15NPVDc9U9BCe2OL5WyAIYkPrVhDRphoYBmHCdIlZkq+vSBI4xg==
|
||||
|
||||
collect-v8-coverage@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59"
|
||||
|
@ -3892,7 +3897,7 @@ damerau-levenshtein@^1.0.7:
|
|||
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.7.tgz#64368003512a1a6992593741a09a9d31a836f55d"
|
||||
integrity sha512-VvdQIPGdWP0SqFXghj79Wf/5LArmreyMsGLa6FG6iC4t3j7j5s71TrwWmT/4akbDQIqjfACkLZmjXhA7g2oUZw==
|
||||
|
||||
data-urls@^3.0.1:
|
||||
data-urls@^3.0.1, data-urls@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"
|
||||
integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==
|
||||
|
@ -4338,6 +4343,11 @@ entities@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f"
|
||||
integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==
|
||||
|
||||
entities@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.0.tgz#62915f08d67353bb4eb67e3d62641a4059aec656"
|
||||
integrity sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==
|
||||
|
||||
errno@^0.1.3, errno@~0.1.7:
|
||||
version "0.1.7"
|
||||
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
|
||||
|
@ -5700,7 +5710,7 @@ https-browserify@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
|
||||
integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=
|
||||
|
||||
https-proxy-agent@^5.0.0:
|
||||
https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
|
||||
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
|
||||
|
@ -6728,7 +6738,7 @@ jest-snapshot@^28.1.1:
|
|||
pretty-format "^28.1.1"
|
||||
semver "^7.3.5"
|
||||
|
||||
jest-util@^28.1.0, jest-util@^28.1.1:
|
||||
jest-util@^28.1.1:
|
||||
version "28.1.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-28.1.1.tgz#ff39e436a1aca397c0ab998db5a51ae2b7080d05"
|
||||
integrity sha512-FktOu7ca1DZSyhPAxgxB6hfh2+9zMoJ7aEQA759Z6p45NuO8mWcqujH+UdHlCm/V6JTWwDztM2ITCzU1ijJAfw==
|
||||
|
@ -6852,6 +6862,39 @@ jsdom@^19.0.0:
|
|||
ws "^8.2.3"
|
||||
xml-name-validator "^4.0.0"
|
||||
|
||||
jsdom@^20.0.0:
|
||||
version "20.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.0.tgz#882825ac9cc5e5bbee704ba16143e1fa78361ebf"
|
||||
integrity sha512-x4a6CKCgx00uCmP+QakBDFXwjAJ69IkkIWHmtmjd3wvXPcdOS44hfX2vqkOQrVrq8l9DhNNADZRXaCEWvgXtVA==
|
||||
dependencies:
|
||||
abab "^2.0.6"
|
||||
acorn "^8.7.1"
|
||||
acorn-globals "^6.0.0"
|
||||
cssom "^0.5.0"
|
||||
cssstyle "^2.3.0"
|
||||
data-urls "^3.0.2"
|
||||
decimal.js "^10.3.1"
|
||||
domexception "^4.0.0"
|
||||
escodegen "^2.0.0"
|
||||
form-data "^4.0.0"
|
||||
html-encoding-sniffer "^3.0.0"
|
||||
http-proxy-agent "^5.0.0"
|
||||
https-proxy-agent "^5.0.1"
|
||||
is-potential-custom-element-name "^1.0.1"
|
||||
nwsapi "^2.2.0"
|
||||
parse5 "^7.0.0"
|
||||
saxes "^6.0.0"
|
||||
symbol-tree "^3.2.4"
|
||||
tough-cookie "^4.0.0"
|
||||
w3c-hr-time "^1.0.2"
|
||||
w3c-xmlserializer "^3.0.0"
|
||||
webidl-conversions "^7.0.0"
|
||||
whatwg-encoding "^2.0.0"
|
||||
whatwg-mimetype "^3.0.0"
|
||||
whatwg-url "^11.0.0"
|
||||
ws "^8.8.0"
|
||||
xml-name-validator "^4.0.0"
|
||||
|
||||
jsesc@^2.5.1:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
|
||||
|
@ -8141,6 +8184,13 @@ parse5@6.0.1:
|
|||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
|
||||
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
|
||||
|
||||
parse5@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.0.0.tgz#51f74a5257f5fcc536389e8c2d0b3802e1bfa91a"
|
||||
integrity sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g==
|
||||
dependencies:
|
||||
entities "^4.3.0"
|
||||
|
||||
parseurl@~1.3.2, parseurl@~1.3.3:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
|
@ -9771,6 +9821,13 @@ saxes@^5.0.1:
|
|||
dependencies:
|
||||
xmlchars "^2.2.0"
|
||||
|
||||
saxes@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
|
||||
integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
|
||||
dependencies:
|
||||
xmlchars "^2.2.0"
|
||||
|
||||
scheduler@^0.19.1:
|
||||
version "0.19.1"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196"
|
||||
|
|
Loading…
Reference in a new issue