Merge branch 'main' into glitch-soc/merge-upstream

main
Claire 2 years ago
commit 2d1d4210f9

@ -613,7 +613,7 @@ GEM
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
semantic_range (3.0.0) semantic_range (3.0.0)
sidekiq (6.5.4) sidekiq (6.5.5)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.5.0) redis (>= 4.5.0)
@ -651,7 +651,7 @@ GEM
sshkit (1.21.2) sshkit (1.21.2)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stackprof (0.2.20) stackprof (0.2.21)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stoplight (3.0.0) stoplight (3.0.0)
strong_migrations (0.7.9) strong_migrations (0.7.9)

@ -0,0 +1,44 @@
# frozen_string_literal: true
class Api::V1::Filters::StatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
before_action :require_user!
before_action :set_status_filters, only: :index
before_action :set_status_filter, only: [:show, :destroy]
def index
render json: @status_filters, each_serializer: REST::FilterStatusSerializer
end
def create
@status_filter = current_account.custom_filters.find(params[:filter_id]).statuses.create!(resource_params)
render json: @status_filter, serializer: REST::FilterStatusSerializer
end
def show
render json: @status_filter, serializer: REST::FilterStatusSerializer
end
def destroy
@status_filter.destroy!
render_empty
end
private
def set_status_filters
filter = current_account.custom_filters.includes(:statuses).find(params[:filter_id])
@status_filters = filter.statuses
end
def set_status_filter
@status_filter = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
end
def resource_params
params.permit(:status_id)
end
end

@ -83,7 +83,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end end
def check_enabled_registrations def check_enabled_registrations
redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations? redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations? || ip_blocked?
end end
def allowed_registrations? def allowed_registrations?
@ -94,6 +94,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
ENV['OMNIAUTH_ONLY'] == 'true' ENV['OMNIAUTH_ONLY'] == 'true'
end end
def ip_blocked?
IpBlock.where(severity: :sign_up_block).where('ip >>= ?', request.remote_ip.to_s).exists?
end
def invite_code def invite_code
if params[:user] if params[:user]
params[:user][:invite_code] params[:user][:invite_code]

@ -0,0 +1,49 @@
# frozen_string_literal: true
class Filters::StatusesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_filter
before_action :set_status_filters
before_action :set_body_classes
PER_PAGE = 20
def index
@status_filter_batch_action = Form::StatusFilterBatchAction.new
end
def batch
@status_filter_batch_action = Form::StatusFilterBatchAction.new(status_filter_batch_action_params.merge(current_account: current_account, filter_id: params[:filter_id], type: action_from_button))
@status_filter_batch_action.save!
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
ensure
redirect_to edit_filter_path(@filter)
end
private
def set_filter
@filter = current_account.custom_filters.find(params[:filter_id])
end
def set_status_filters
@status_filters = @filter.statuses.preload(:status).page(params[:page]).per(PER_PAGE)
end
def status_filter_batch_action_params
params.require(:form_status_filter_batch_action).permit(status_filter_ids: [])
end
def action_from_button
if params[:remove]
'remove'
end
end
def set_body_classes
@body_classes = 'admin'
end
end

@ -9,7 +9,7 @@ class FiltersController < ApplicationController
before_action :set_body_classes before_action :set_body_classes
def index def index
@filters = current_account.custom_filters.includes(:keywords).order(:phrase) @filters = current_account.custom_filters.includes(:keywords, :statuses).order(:phrase)
end end
def new def new

@ -0,0 +1,93 @@
import api from '../api';
import { openModal } from './modal';
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
export const FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST';
export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS';
export const FILTERS_STATUS_CREATE_FAIL = 'FILTERS_STATUS_CREATE_FAIL';
export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
export const initAddFilter = (status, { contextType }) => dispatch =>
dispatch(openModal('FILTER', {
statusId: status?.get('id'),
contextType: contextType,
}));
export const fetchFilters = () => (dispatch, getState) => {
dispatch({
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
});
api(getState)
.get('/api/v2/filters')
.then(({ data }) => dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
};
export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterStatusRequest());
api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => {
dispatch(createFilterStatusSuccess(response.data));
if (onSuccess) onSuccess();
}).catch(error => {
dispatch(createFilterStatusFail(error));
if (onFail) onFail();
});
};
export const createFilterStatusRequest = () => ({
type: FILTERS_STATUS_CREATE_REQUEST,
});
export const createFilterStatusSuccess = filter_status => ({
type: FILTERS_STATUS_CREATE_SUCCESS,
filter_status,
});
export const createFilterStatusFail = error => ({
type: FILTERS_STATUS_CREATE_FAIL,
error,
});
export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterRequest());
api(getState).post('/api/v2/filters', params).then(response => {
dispatch(createFilterSuccess(response.data));
if (onSuccess) onSuccess(response.data);
}).catch(error => {
dispatch(createFilterFail(error));
if (onFail) onFail();
});
};
export const createFilterRequest = () => ({
type: FILTERS_CREATE_REQUEST,
});
export const createFilterSuccess = filter => ({
type: FILTERS_CREATE_SUCCESS,
filter,
});
export const createFilterFail = error => ({
type: FILTERS_CREATE_FAIL,
error,
});

@ -141,13 +141,13 @@ const excludeTypesFromFilter = filter => {
const noOp = () => {}; const noOp = () => {};
export function expandNotifications({ maxId } = {}, done = noOp) { export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) {
return (dispatch, getState) => { return (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications'); const notifications = getState().get('notifications');
const isLoadingMore = !!maxId; const isLoadingMore = !!maxId;
if (notifications.get('isLoading')) { if (notifications.get('isLoading') && !forceLoad) {
done(); done();
return; return;
} }
@ -243,7 +243,7 @@ export function setFilter (filterType) {
path: ['notifications', 'quickFilter', 'active'], path: ['notifications', 'quickFilter', 'active'],
value: filterType, value: filterType,
}); });
dispatch(expandNotifications()); dispatch(expandNotifications({ forceLoad: true }));
dispatch(saveSettings()); dispatch(saveSettings());
}; };
}; };

@ -42,9 +42,9 @@ export function fetchStatusRequest(id, skipLoading) {
}; };
}; };
export function fetchStatus(id) { export function fetchStatus(id, forceFetch = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
const skipLoading = getState().getIn(['statuses', id], null) !== null; const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
dispatch(fetchContext(id)); dispatch(fetchContext(id));

@ -75,18 +75,18 @@ export const unfollowHashtag = name => (dispatch, getState) => {
}; };
export const unfollowHashtagRequest = name => ({ export const unfollowHashtagRequest = name => ({
type: HASHTAG_FETCH_REQUEST, type: HASHTAG_UNFOLLOW_REQUEST,
name, name,
}); });
export const unfollowHashtagSuccess = (name, tag) => ({ export const unfollowHashtagSuccess = (name, tag) => ({
type: HASHTAG_FETCH_SUCCESS, type: HASHTAG_UNFOLLOW_SUCCESS,
name, name,
tag, tag,
}); });
export const unfollowHashtagFail = (name, error) => ({ export const unfollowHashtagFail = (name, error) => ({
type: HASHTAG_FETCH_FAIL, type: HASHTAG_UNFOLLOW_FAIL,
name, name,
error, error,
}); });

@ -131,17 +131,9 @@ export default class IconButton extends React.PureComponent {
</React.Fragment> </React.Fragment>
); );
if (href) { if (href && !this.prop) {
return ( contents = (
<a <a href={href} target='_blank' rel='noopener noreferrer'>
href={href}
aria-label={title}
title={title}
target='_blank'
rel='noopener noreferrer'
className={classes}
style={style}
>
{contents} {contents}
</a> </a>
); );

@ -80,6 +80,7 @@ class Status extends ImmutablePureComponent {
onOpenMedia: PropTypes.func, onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func, onOpenVideo: PropTypes.func,
onBlock: PropTypes.func, onBlock: PropTypes.func,
onAddFilter: PropTypes.func,
onEmbed: PropTypes.func, onEmbed: PropTypes.func,
onHeightChange: PropTypes.func, onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func, onToggleHidden: PropTypes.func,
@ -515,7 +516,7 @@ class Status extends ImmutablePureComponent {
{media} {media}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters && this.handleFilterClick} {...other} /> <StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div> </div>
</div> </div>
</HotKeys> </HotKeys>

@ -44,6 +44,7 @@ const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
}); });
const mapStateToProps = (state, { status }) => ({ const mapStateToProps = (state, { status }) => ({
@ -80,6 +81,7 @@ class StatusActionBar extends ImmutablePureComponent {
onPin: PropTypes.func, onPin: PropTypes.func,
onBookmark: PropTypes.func, onBookmark: PropTypes.func,
onFilter: PropTypes.func, onFilter: PropTypes.func,
onAddFilter: PropTypes.func,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
withCounters: PropTypes.bool, withCounters: PropTypes.bool,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
@ -211,8 +213,8 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onMuteConversation(this.props.status); this.props.onMuteConversation(this.props.status);
} }
handleFilter = () => { handleFilterClick = () => {
this.props.onFilter(); this.props.onAddFilter(this.props.status);
} }
handleCopy = () => { handleCopy = () => {
@ -235,7 +237,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
handleFilterClick = () => { handleHideClick = () => {
this.props.onFilter(); this.props.onFilter();
} }
@ -294,6 +296,12 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
} }
if (!this.props.onFilter) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport });
if (account.get('acct') !== account.get('username')) { if (account.get('acct') !== account.get('username')) {
@ -343,7 +351,7 @@ class StatusActionBar extends ImmutablePureComponent {
); );
const filterButton = this.props.onFilter && ( const filterButton = this.props.onFilter && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
); );
return ( return (

@ -34,6 +34,9 @@ import {
blockDomain, blockDomain,
unblockDomain, unblockDomain,
} from '../actions/domain_blocks'; } from '../actions/domain_blocks';
import {
initAddFilter,
} from '../actions/filters';
import { initMuteModal } from '../actions/mutes'; import { initMuteModal } from '../actions/mutes';
import { initBlockModal } from '../actions/blocks'; import { initBlockModal } from '../actions/blocks';
import { initBoostModal } from '../actions/boosts'; import { initBoostModal } from '../actions/boosts';
@ -66,7 +69,7 @@ const makeMapStateToProps = () => {
return mapStateToProps; return mapStateToProps;
}; };
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
onReply (status, router) { onReply (status, router) {
dispatch((_, getState) => { dispatch((_, getState) => {
@ -176,6 +179,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(initReport(status.get('account'), status)); dispatch(initReport(status.get('account'), status));
}, },
onAddFilter (status) {
dispatch(initAddFilter(status, { contextType }));
},
onMute (account) { onMute (account) {
dispatch(initMuteModal(account)); dispatch(initMuteModal(account));
}, },

@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import { languages as preloadedLanguages } from 'mastodon/initial_state'; import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
import fuzzysort from 'fuzzysort'; import fuzzysort from 'fuzzysort';
const messages = defineMessages({ const messages = defineMessages({
@ -16,22 +17,6 @@ const messages = defineMessages({
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
}); });
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
const icons = {
loupe: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
),
delete: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
),
};
const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class LanguageDropdownMenu extends React.PureComponent { class LanguageDropdownMenu extends React.PureComponent {
@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
<div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}> <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className='emoji-mart-search'> <div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus /> <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button> <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div> </div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>

@ -0,0 +1,102 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import { toServerSideType } from 'mastodon/utils/filters';
import Button from 'mastodon/components/button';
import { connect } from 'react-redux';
const mapStateToProps = (state, { filterId }) => ({
filter: state.getIn(['filters', filterId]),
});
export default @connect(mapStateToProps)
class AddedToFilter extends React.PureComponent {
static propTypes = {
onClose: PropTypes.func.isRequired,
contextType: PropTypes.string,
filter: ImmutablePropTypes.map.isRequired,
dispatch: PropTypes.func.isRequired,
};
handleCloseClick = () => {
const { onClose } = this.props;
onClose();
};
render () {
const { filter, contextType } = this.props;
let expiredMessage = null;
if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
expiredMessage = (
<React.Fragment>
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.expired_explanation'
defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.'
/>
</p>
</React.Fragment>
);
}
let contextMismatchMessage = null;
if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
contextMismatchMessage = (
<React.Fragment>
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.context_mismatch_explanation'
defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.'
/>
</p>
</React.Fragment>
);
}
const settings_link = (
<a href={`/filters/${filter.get('id')}/edit`}>
<FormattedMessage
id='filter_modal.added.settings_link'
defaultMessage='settings page'
/>
</a>
);
return (
<React.Fragment>
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.short_explanation'
defaultMessage='This post has been added to the following filter category: {title}.'
values={{ title: filter.get('title') }}
/>
</p>
{expiredMessage}
{contextMismatchMessage}
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.review_and_configure'
defaultMessage='To review and further configure this filter category, go to the {settings_link}.'
values={{ settings_link }}
/>
</p>
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
</div>
</React.Fragment>
);
}
}

@ -0,0 +1,192 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { toServerSideType } from 'mastodon/utils/filters';
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
import Icon from 'mastodon/components/icon';
import fuzzysort from 'fuzzysort';
const messages = defineMessages({
search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});
const mapStateToProps = (state, { contextType }) => ({
filters: Array.from(state.get('filters').values()).map((filter) => [
filter.get('id'),
filter.get('title'),
filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
filter.get('expires_at') && filter.get('expires_at') < new Date(),
contextType && !filter.get('context').includes(toServerSideType(contextType)),
]),
});
export default @connect(mapStateToProps)
@injectIntl
class SelectFilter extends React.PureComponent {
static propTypes = {
onSelectFilter: PropTypes.func.isRequired,
onNewFilter: PropTypes.func.isRequired,
filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
intl: PropTypes.object.isRequired,
};
state = {
searchValue: '',
};
search () {
const { filters } = this.props;
const { searchValue } = this.state;
if (searchValue === '') {
return filters;
}
return fuzzysort.go(searchValue, filters, {
keys: ['1', '2'],
limit: 5,
threshold: -10000,
}).map(result => result.obj);
}
renderItem = filter => {
let warning = null;
if (filter[3] || filter[4]) {
warning = (
<span className='language-dropdown__dropdown__results__item__common-name'>
(
{filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
{filter[3] && filter[4] && ', '}
{filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
)
</span>
);
}
return (
<div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
<span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
</div>
);
}
renderCreateNew (name) {
return (
<div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
<Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
</div>
);
}
handleSearchChange = ({ target }) => {
this.setState({ searchValue: target.value });
}
setListRef = c => {
this.listNode = c;
}
handleKeyDown = e => {
const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
let element = null;
switch(e.key) {
case ' ':
case 'Enter':
e.currentTarget.click();
break;
case 'ArrowDown':
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
break;
case 'ArrowUp':
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
} else {
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
}
break;
case 'Home':
element = this.listNode.firstChild;
break;
case 'End':
element = this.listNode.lastChild;
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
}
handleSearchKeyDown = e => {
let element = null;
switch(e.key) {
case 'Tab':
case 'ArrowDown':
element = this.listNode.firstChild;
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
}
}
handleClear = () => {
this.setState({ searchValue: '' });
}
handleItemClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onSelectFilter(value);
}
handleNewFilterClick = e => {
e.preventDefault();
this.props.onNewFilter(this.state.searchValue);
};
render () {
const { intl } = this.props;
const { searchValue } = this.state;
const isSearching = searchValue !== '';
const results = this.search();
return (
<React.Fragment>
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
<p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
{results.map(this.renderItem)}
{isSearching && this.renderCreateNew(searchValue) }
</div>
</React.Fragment>
);
}
}

@ -0,0 +1,134 @@
import React from 'react';
import { connect } from 'react-redux';
import { fetchStatus } from 'mastodon/actions/statuses';
import { fetchFilters, createFilter, createFilterStatus } from 'mastodon/actions/filters';
import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IconButton from 'mastodon/components/icon_button';
import SelectFilter from 'mastodon/features/filters/select_filter';
import AddedToFilter from 'mastodon/features/filters/added_to_filter';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @connect(undefined)
@injectIntl
class FilterModal extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
contextType: PropTypes.string,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
step: 'select',
filterId: null,
isSubmitting: false,
isSubmitted: false,
};
handleNewFilterSuccess = (result) => {
this.handleSelectFilter(result.id);
};
handleSuccess = () => {
const { dispatch, statusId } = this.props;
dispatch(fetchStatus(statusId, true));
this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
};
handleFail = () => {
this.setState({ isSubmitting: false });
};
handleNextStep = step => {
this.setState({ step });
};
handleSelectFilter = (filterId) => {
const { dispatch, statusId } = this.props;
this.setState({ isSubmitting: true, filterId });
dispatch(createFilterStatus({
filter_id: filterId,
status_id: statusId,
}, this.handleSuccess, this.handleFail));
};
handleNewFilter = (title) => {
const { dispatch } = this.props;
this.setState({ isSubmitting: true });
dispatch(createFilter({
title,
context: ['home', 'notifications', 'public', 'thread', 'account'],
action: 'warn',
}, this.handleNewFilterSuccess, this.handleFail));
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchFilters());
}
render () {
const {
intl,
statusId,
contextType,
onClose,
} = this.props;
const {
step,
filterId,
} = this.state;
let stepComponent;
switch(step) {
case 'select':
stepComponent = (
<SelectFilter
contextType={contextType}
onSelectFilter={this.handleSelectFilter}
onNewFilter={this.handleNewFilter}
/>
);
break;
case 'create':
stepComponent = null;
break;
case 'submitted':
stepComponent = (
<AddedToFilter
contextType={contextType}
filterId={filterId}
statusId={statusId}
onClose={onClose}
/>
);
}
return (
<div className='modal-root__modal report-dialog-modal'>
<div className='report-modal__target'>
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
<FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' />
</div>
<div className='report-dialog-modal__container'>
{stepComponent}
</div>
</div>
);
}
}

@ -20,6 +20,7 @@ import {
ListEditor, ListEditor,
ListAdder, ListAdder,
CompareHistoryModal, CompareHistoryModal,
FilterModal,
} from 'mastodon/features/ui/util/async-components'; } from 'mastodon/features/ui/util/async-components';
const MODAL_COMPONENTS = { const MODAL_COMPONENTS = {
@ -37,6 +38,7 @@ const MODAL_COMPONENTS = {
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder, 'LIST_ADDER': ListAdder,
'COMPARE_HISTORY': CompareHistoryModal, 'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {

@ -161,3 +161,7 @@ export function CompareHistoryModal () {
export function Explore () { export function Explore () {
return import(/* webpackChunkName: "features/explore" */'../../explore'); return import(/* webpackChunkName: "features/explore" */'../../explore');
} }
export function FilterModal () {
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
}

@ -1,4 +1,5 @@
import { FILTERS_IMPORT } from '../actions/importer'; import { FILTERS_IMPORT } from '../actions/importer';
import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters';
import { Map as ImmutableMap, is, fromJS } from 'immutable'; import { Map as ImmutableMap, is, fromJS } from 'immutable';
const normalizeFilter = (state, filter) => { const normalizeFilter = (state, filter) => {
@ -7,13 +8,17 @@ const normalizeFilter = (state, filter) => {
title: filter.title, title: filter.title,
context: filter.context, context: filter.context,
filter_action: filter.filter_action, filter_action: filter.filter_action,
keywords: filter.keywords,
expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null, expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
}); });
if (is(state.get(filter.id), normalizedFilter)) { if (is(state.get(filter.id), normalizedFilter)) {
return state; return state;
} else { } else {
return state.set(filter.id, normalizedFilter); // Do not overwrite keywords when receiving a partial filter
return state.update(filter.id, ImmutableMap(), (old) => (
old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter)
));
} }
}; };
@ -27,6 +32,10 @@ const normalizeFilters = (state, filters) => {
export default function filters(state = ImmutableMap(), action) { export default function filters(state = ImmutableMap(), action) {
switch(action.type) { switch(action.type) {
case FILTERS_CREATE_SUCCESS:
return normalizeFilter(state, action.filter);
case FILTERS_FETCH_SUCCESS:
//TODO: handle deleting obsolete filters
case FILTERS_IMPORT: case FILTERS_IMPORT:
return normalizeFilters(state, action.filters); return normalizeFilters(state, action.filters);
default: default:

@ -41,7 +41,7 @@ const initialState = ImmutableMap({
lastReadId: '0', lastReadId: '0',
readMarkerId: '0', readMarkerId: '0',
isTabVisible: true, isTabVisible: true,
isLoading: false, isLoading: 0,
browserSupport: false, browserSupport: false,
browserPermission: 'default', browserPermission: 'default',
}); });
@ -115,7 +115,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
} }
} }
mutable.set('isLoading', false); mutable.update('isLoading', (nbLoading) => nbLoading - 1);
}); });
}; };
@ -214,9 +214,9 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_LOAD_PENDING: case NOTIFICATIONS_LOAD_PENDING:
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
case NOTIFICATIONS_EXPAND_REQUEST: case NOTIFICATIONS_EXPAND_REQUEST:
return state.set('isLoading', true); return state.update('isLoading', (nbLoading) => nbLoading + 1);
case NOTIFICATIONS_EXPAND_FAIL: case NOTIFICATIONS_EXPAND_FAIL:
return state.set('isLoading', false); return state.update('isLoading', (nbLoading) => nbLoading - 1);
case NOTIFICATIONS_FILTER_SET: case NOTIFICATIONS_FILTER_SET:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true); return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true);
case NOTIFICATIONS_SCROLL_TOP: case NOTIFICATIONS_SCROLL_TOP:

@ -1,5 +1,6 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { toServerSideType } from 'mastodon/utils/filters';
import { me } from '../initial_state'; import { me } from '../initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null); const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
@ -20,23 +21,6 @@ export const makeGetAccount = () => {
}); });
}; };
const toServerSideType = columnType => {
switch (columnType) {
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
}
};
const getFilters = (state, { contextType }) => { const getFilters = (state, { contextType }) => {
if (!contextType) return null; if (!contextType) return null;
@ -73,6 +57,7 @@ export const makeGetStatus = () => {
if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
return null; return null;
} }
filterResults = filterResults.filter(result => filters.has(result.get('filter')));
if (!filterResults.isEmpty()) { if (!filterResults.isEmpty()) {
filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
} }

@ -0,0 +1,16 @@
export const toServerSideType = columnType => {
switch (columnType) {
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
}
};

@ -0,0 +1,13 @@
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
export const loupeIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
);
export const deleteIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
);

@ -5233,6 +5233,16 @@ a.status-card.compact:hover {
line-height: 22px; line-height: 22px;
color: lighten($inverted-text-color, 16%); color: lighten($inverted-text-color, 16%);
margin-bottom: 30px; margin-bottom: 30px;
a {
text-decoration: none;
color: $inverted-text-color;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
} }
&__actions { &__actions {
@ -5379,6 +5389,14 @@ a.status-card.compact:hover {
background: transparent; background: transparent;
margin: 15px 0; margin: 15px 0;
} }
.emoji-mart-search {
padding-right: 10px;
}
.emoji-mart-search-icon {
right: 10px + 5px;
}
} }
.report-modal__container { .report-modal__container {

@ -31,7 +31,7 @@ class Request
@url = Addressable::URI.parse(url).normalize @url = Addressable::URI.parse(url).normalize
@http_client = options.delete(:http_client) @http_client = options.delete(:http_client)
@options = options.merge(socket_class: use_proxy? ? ProxySocket : Socket) @options = options.merge(socket_class: use_proxy? ? ProxySocket : Socket)
@options = @options.merge(Rails.configuration.x.http_client_proxy) if use_proxy? @options = @options.merge(proxy_url) if use_proxy?
@headers = {} @headers = {}
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service? raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
@ -141,11 +141,23 @@ class Request
end end
def use_proxy? def use_proxy?
Rails.configuration.x.http_client_proxy.present? proxy_url.present?
end
def proxy_url
if hidden_service? && Rails.configuration.x.http_client_hidden_proxy.present?
Rails.configuration.x.http_client_hidden_proxy
else
Rails.configuration.x.http_client_proxy
end
end end
def block_hidden_service? def block_hidden_service?
!Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match?(@url.host) !Rails.configuration.x.access_to_hidden_service && hidden_service?
end
def hidden_service?
/\.(onion|i2p)$/.match?(@url.host)
end end
module ClientLimit module ClientLimit

@ -249,15 +249,7 @@ module AccountInteractions
def status_matches_filters(status) def status_matches_filters(status)
active_filters = CustomFilter.cached_filters_for(id) active_filters = CustomFilter.cached_filters_for(id)
CustomFilter.apply_cached_filters(active_filters, status)
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 end
def followers_for_local_distribution def followers_for_local_distribution

@ -34,6 +34,7 @@ class CustomFilter < ApplicationRecord
belongs_to :account belongs_to :account
has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
validates :title, :context, presence: true validates :title, :context, presence: true
@ -62,8 +63,10 @@ class CustomFilter < ApplicationRecord
def self.cached_filters_for(account_id) def self.cached_filters_for(account_id)
active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
filters_hash = {}
scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
scope.to_a.group_by(&:custom_filter).map do |filter, keywords| scope.to_a.group_by(&:custom_filter).each do |filter, keywords|
keywords.map! do |keyword| keywords.map! do |keyword|
if keyword.whole_word if keyword.whole_word
sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : '' sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
@ -74,13 +77,34 @@ class CustomFilter < ApplicationRecord
/#{Regexp.escape(keyword.keyword)}/i /#{Regexp.escape(keyword.keyword)}/i
end end
end end
[filter, { keywords: Regexp.union(keywords) }]
filters_hash[filter.id] = { keywords: Regexp.union(keywords), filter: filter }
end.to_h
scope = CustomFilterStatus.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
scope.to_a.group_by(&:custom_filter).each do |filter, statuses|
filters_hash[filter.id] ||= { filter: filter }
filters_hash[filter.id].merge!(status_ids: statuses.map(&:status_id))
end end
filters_hash.values.map { |cache| [cache.delete(:filter), cache] }
end.to_a end.to_a
active_filters.select { |custom_filter, _| !custom_filter.expired? } active_filters.select { |custom_filter, _| !custom_filter.expired? }
end end
def self.apply_cached_filters(cached_filters, status)
cached_filters.filter_map do |filter, rules|
match = rules[:keywords].match(status.proper.searchable_text) if rules[:keywords].present?
keyword_matches = [match.to_s] unless match.nil?
status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present?
next if keyword_matches.blank? && status_matches.blank?
FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches)
end
end
def prepare_cache_invalidation! def prepare_cache_invalidation!
@should_invalidate_cache = true @should_invalidate_cache = true
end end

@ -0,0 +1,37 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: custom_filter_statuses
#
# id :bigint(8) not null, primary key
# custom_filter_id :bigint(8) not null
# status_id :bigint(8) default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class CustomFilterStatus < ApplicationRecord
belongs_to :custom_filter
belongs_to :status
validates :status, uniqueness: { scope: :custom_filter }
validate :validate_status_access
before_save :prepare_cache_invalidation!
before_destroy :prepare_cache_invalidation!
after_commit :invalidate_cache!
private
def validate_status_access
errors.add(:status_id, :invalid) unless StatusPolicy.new(custom_filter.account, status).show?
end
def prepare_cache_invalidation!
custom_filter.prepare_cache_invalidation!
end
def invalidate_cache!
custom_filter.invalidate_cache!
end
end

@ -30,32 +30,56 @@ class EmailDomainBlock < ApplicationRecord
@history ||= Trends::History.new('email_domain_blocks', id) @history ||= Trends::History.new('email_domain_blocks', id)
end end
def self.block?(domain_or_domains, attempt_ip: nil) class Matcher
domains = Array(domain_or_domains).map do |str| def initialize(domain_or_domains, attempt_ip: nil)
domain = begin @uris = extract_uris(domain_or_domains)
if str.include?('@') @attempt_ip = attempt_ip
str.split('@', 2).last end
else
str
end
end
TagManager.instance.normalize_domain(domain) if domain.present? def match?
rescue Addressable::URI::InvalidURIError blocking? || invalid_uri?
nil
end end
# If some of the inputs passed in are invalid, we definitely want to private
# block the attempt, but we also want to register hits against any
# other valid matches
blocked = domains.any?(&:nil?) def invalid_uri?
@uris.any?(&:nil?)
end
where(domain: domains).find_each do |block| def blocking?
blocked = true blocks = EmailDomainBlock.where(domain: domains_with_variants).order(Arel.sql('char_length(domain) desc'))
block.history.add(attempt_ip) if attempt_ip.present? blocks.each { |block| block.history.add(@attempt_ip) } if @attempt_ip.present?
blocks.any?
end end
blocked def domains_with_variants
@uris.flat_map do |uri|
next if uri.nil?
segments = uri.normalized_host.split('.')
segments.map.with_index { |_, i| segments[i..-1].join('.') }
end
end
def extract_uris(domain_or_domains)
Array(domain_or_domains).map do |str|
domain = begin
if str.include?('@')
str.split('@', 2).last
else
str
end
end
Addressable::URI.new.tap { |u| u.host = domain.strip } if domain.present?
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
nil
end
end
end
def self.block?(domain_or_domains, attempt_ip: nil)
Matcher.new(domain_or_domains, attempt_ip: attempt_ip).match?
end end
end end

@ -0,0 +1,34 @@
# frozen_string_literal: true
class Form::StatusFilterBatchAction
include ActiveModel::Model
include AccountableConcern
include Authorization
attr_accessor :current_account, :type,
:status_filter_ids, :filter_id
def save!
process_action!
end
private
def status_filters
filter = current_account.custom_filters.find(filter_id)
filter.statuses.where(id: status_filter_ids)
end
def process_action!
return if status_filter_ids.empty?
case type
when 'remove'
handle_remove!
end
end
def handle_remove!
status_filters.destroy_all
end
end

@ -19,6 +19,7 @@ class IpBlock < ApplicationRecord
enum severity: { enum severity: {
sign_up_requires_approval: 5000, sign_up_requires_approval: 5000,
sign_up_block: 5500,
no_access: 9999, no_access: 9999,
} }

@ -94,7 +94,7 @@ class User < ApplicationRecord
validates :invite_request, presence: true, on: :create, if: :invite_text_required? validates :invite_request, presence: true, on: :create, if: :invite_text_required?
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: -> { !confirmed? } validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
validates_with EmailMxValidator, if: :validate_email_dns? validates_with EmailMxValidator, if: :validate_email_dns?
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
class FilterResultPresenter < ActiveModelSerializers::Model class FilterResultPresenter < ActiveModelSerializers::Model
attributes :filter, :keyword_matches attributes :filter, :keyword_matches, :status_matches
end end

@ -33,12 +33,7 @@ class StatusRelationshipsPresenter
active_filters = CustomFilter.cached_filters_for(current_account_id) active_filters = CustomFilter.cached_filters_for(current_account_id)
@filters_map = statuses.each_with_object({}) do |status, h| @filters_map = statuses.each_with_object({}) do |status, h|
filter_matches = active_filters.filter_map do |filter, rules| filter_matches = CustomFilter.apply_cached_filters(active_filters, status)
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? unless filter_matches.empty?
h[status.id] = filter_matches h[status.id] = filter_matches

@ -3,4 +3,9 @@
class REST::FilterResultSerializer < ActiveModel::Serializer class REST::FilterResultSerializer < ActiveModel::Serializer
belongs_to :filter, serializer: REST::FilterSerializer belongs_to :filter, serializer: REST::FilterSerializer
has_many :keyword_matches has_many :keyword_matches
has_many :status_matches
def status_matches
object.status_matches&.map(&:to_s)
end
end end

@ -3,6 +3,7 @@
class REST::FilterSerializer < ActiveModel::Serializer class REST::FilterSerializer < ActiveModel::Serializer
attributes :id, :title, :context, :expires_at, :filter_action attributes :id, :title, :context, :expires_at, :filter_action
has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested? has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested?
has_many :statuses, serializer: REST::FilterStatusSerializer, if: :rules_requested?
def id def id
object.id.to_s object.id.to_s

@ -0,0 +1,13 @@
# frozen_string_literal: true
class REST::FilterStatusSerializer < ActiveModel::Serializer
attributes :id, :status_id
def id
object.id.to_s
end
def status_id
object.status_id.to_s
end
end

@ -2,23 +2,67 @@
class AppSignUpService < BaseService class AppSignUpService < BaseService
def call(app, remote_ip, params) def call(app, remote_ip, params)
return unless allowed_registrations? @app = app
@remote_ip = remote_ip
@params = params
user_params = params.slice(:email, :password, :agreement, :locale) raise Mastodon::NotPermittedError unless allowed_registrations?
account_params = params.slice(:username)
invite_request_params = { text: params[:reason] }
user = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
Doorkeeper::AccessToken.create!(application: app, ApplicationRecord.transaction do
resource_owner_id: user.id, create_user!
scopes: app.scopes, create_access_token!
expires_in: Doorkeeper.configuration.access_token_expires_in, end
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?)
@access_token
end end
private private
def create_user!
@user = User.create!(
user_params.merge(created_by_application: @app, sign_up_ip: @remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params)
)
end
def create_access_token!
@access_token = Doorkeeper::AccessToken.create!(
application: @app,
resource_owner_id: @user.id,
scopes: @app.scopes,
expires_in: Doorkeeper.configuration.access_token_expires_in,
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?
)
end
def user_params
@params.slice(:email, :password, :agreement, :locale)
end
def account_params
@params.slice(:username)
end
def invite_request_params
{ text: @params[:reason] }
end
def allowed_registrations? def allowed_registrations?
Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode registrations_open? && !single_user_mode? && !omniauth_only? && !ip_blocked?
end
def registrations_open?
Setting.registrations_mode != 'none'
end
def single_user_mode?
Rails.configuration.x.single_user_mode
end
def omniauth_only?
ENV['OMNIAUTH_ONLY'] == 'true'
end
def ip_blocked?
IpBlock.where(severity: :sign_up_block).where('ip >>= ?', @remote_ip.to_s).exists?
end end
end end

@ -13,7 +13,7 @@
= f.input :position, wrapper: :with_label, input_html: { max: current_user.role.position - 1 } = f.input :position, wrapper: :with_label, input_html: { max: current_user.role.position - 1 }
.fields-group .fields-group
= f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000' } = f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000', type: 'color' }
%hr.spacer/ %hr.spacer/

@ -22,6 +22,15 @@
- keywords = filter.keywords.map(&:keyword) - keywords = filter.keywords.map(&:keyword)
- keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO - keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO
= keywords.join(', ') = keywords.join(', ')
- unless filter.statuses.empty?
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('comment')
.permissions-list__item__text
.permissions-list__item__text__title
= t('filters.index.statuses', count: filter.statuses.size)
.permissions-list__item__text__type
= t('filters.index.statuses_long', count: filter.statuses.size)
.announcements-list__item__action-bar .announcements-list__item__action-bar
.announcements-list__item__meta .announcements-list__item__meta

@ -14,6 +14,13 @@
%hr.spacer/ %hr.spacer/
- unless f.object.statuses.empty?
%h4= t('filters.edit.statuses')
%p.muted-hint= t('filters.edit.statuses_hint_html', path: filter_statuses_path(f.object))
%hr.spacer/
%h4= t('filters.edit.keywords') %h4= t('filters.edit.keywords')
.table-wrapper .table-wrapper

@ -0,0 +1,37 @@
- status = status_filter.status.proper
.batch-table__row
%label.batch-table__row__select.batch-checkbox
= f.check_box :status_filter_ids, { multiple: true, include_hidden: false }, status_filter.id
.batch-table__row__content
.status__content><
- if status.spoiler_text.blank?
= prerender_custom_emojis(status_content_format(status), status.emojis)
- else
%details<
%summary><
%strong> Content warning: #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
= prerender_custom_emojis(status_content_format(status), status.emojis)
- status.ordered_media_attachments.each do |media_attachment|
%abbr{ title: media_attachment.description }
= fa_icon 'link'
= media_attachment.file_file_name
.detailed-status__meta
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'name-tag', target: '_blank', rel: 'noopener noreferrer' do
= image_tag(status.account.avatar.url, width: 15, height: 15, alt: display_name(status.account), class: 'avatar')
.username= status.account.acct
·
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
- if status.edited?
·
= t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted'))
·
= fa_visibility_icon(status)
= t("statuses.visibilities.#{status.visibility}")
- if status.sensitive?
·
= fa_icon('eye-slash fw')
= t('stream_entries.sensitive_content')

@ -0,0 +1,38 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do
= t('filters.statuses.index.title')
\-
= @filter.title
.filters
.back-link
= link_to edit_filter_path(@filter) do
= fa_icon 'chevron-left fw'
= t('filters.statuses.back_to_filter')
%p.hint= t('filters.statuses.index.hint')
%hr.spacer/
= form_for(@status_filter_batch_action, url: batch_filter_statuses_path(@filter.id)) do |f|
= hidden_field_tag :page, params[:page] || 1
- Admin::StatusFilter::KEYS.each do |key|
= hidden_field_tag key, params[key] if params[key].present?
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
- unless @status_filters.empty?
= f.button safe_join([fa_icon('times'), t('filters.statuses.batch.remove')]), name: :remove, class: 'table-action-link', type: :submit
.batch-table__body
- if @status_filters.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'status_filter', collection: @status_filters, locals: { f: f }
= paginate @status_filters

@ -12,6 +12,10 @@ spec:
template: template:
metadata: metadata:
name: {{ include "mastodon.fullname" . }}-media-remove name: {{ include "mastodon.fullname" . }}-media-remove
{{- with .Values.jobAnnotations }}
annotations:
{{- toYaml . | nindent 12 }}
{{- end }}
spec: spec:
restartPolicy: OnFailure restartPolicy: OnFailure
{{- if (not .Values.mastodon.s3.enabled) }} {{- if (not .Values.mastodon.s3.enabled) }}

@ -70,6 +70,18 @@ spec:
key: redis-password key: redis-password
- name: "PORT" - name: "PORT"
value: {{ .Values.mastodon.web.port | quote }} value: {{ .Values.mastodon.web.port | quote }}
{{- if (and .Values.mastodon.s3.enabled .Values.mastodon.s3.existingSecret) }}
- name: "AWS_SECRET_ACCESS_KEY"
valueFrom:
secretKeyRef:
name: {{ .Values.mastodon.s3.existingSecret }}
key: AWS_SECRET_ACCESS_KEY
- name: "AWS_ACCESS_KEY_ID"
valueFrom:
secretKeyRef:
name: {{ .Values.mastodon.s3.existingSecret }}
key: AWS_ACCESS_KEY_ID
{{- end -}}
{{- if (not .Values.mastodon.s3.enabled) }} {{- if (not .Values.mastodon.s3.enabled) }}
volumeMounts: volumeMounts:
- name: assets - name: assets

@ -12,6 +12,10 @@ spec:
template: template:
metadata: metadata:
name: {{ include "mastodon.fullname" . }}-assets-precompile name: {{ include "mastodon.fullname" . }}-assets-precompile
{{- with .Values.jobAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
restartPolicy: Never restartPolicy: Never
{{- if (not .Values.mastodon.s3.enabled) }} {{- if (not .Values.mastodon.s3.enabled) }}

@ -13,6 +13,10 @@ spec:
template: template:
metadata: metadata:
name: {{ include "mastodon.fullname" . }}-chewy-upgrade name: {{ include "mastodon.fullname" . }}-chewy-upgrade
{{- with .Values.jobAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
restartPolicy: Never restartPolicy: Never
{{- if (not .Values.mastodon.s3.enabled) }} {{- if (not .Values.mastodon.s3.enabled) }}

@ -13,6 +13,10 @@ spec:
template: template:
metadata: metadata:
name: {{ include "mastodon.fullname" . }}-create-admin name: {{ include "mastodon.fullname" . }}-create-admin
{{- with .Values.jobAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
restartPolicy: Never restartPolicy: Never
{{- if (not .Values.mastodon.s3.enabled) }} {{- if (not .Values.mastodon.s3.enabled) }}

@ -12,6 +12,10 @@ spec:
template: template:
metadata: metadata:
name: {{ include "mastodon.fullname" . }}-db-migrate name: {{ include "mastodon.fullname" . }}-db-migrate
{{- with .Values.jobAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec: spec:
restartPolicy: Never restartPolicy: Never
{{- if (not .Values.mastodon.s3.enabled) }} {{- if (not .Values.mastodon.s3.enabled) }}

@ -281,8 +281,14 @@ serviceAccount:
# If not set and create is true, a name is generated using the fullname template # If not set and create is true, a name is generated using the fullname template
name: "" name: ""
# Kubernetes manages pods for jobs and pods for deployments differently, so you might
# need to apply different annotations to the two different sets of pods. The annotations
# set with podAnnotations will be added to all deployment-managed pods.
podAnnotations: {} podAnnotations: {}
# The annotations set with jobAnnotations will be added to all job pods.
jobAnnotations: {}
resources: {} resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious # We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little # choice for the user. This also increases chances charts run on environments with little

@ -47,7 +47,7 @@ Rails.application.configure do
config.force_ssl = true config.force_ssl = true
config.ssl_options = { config.ssl_options = {
redirect: { redirect: {
exclude: -> request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') } exclude: -> request { request.path.start_with?('/health') || request.headers["Host"].end_with?('.onion') || request.headers["Host"].end_with?('.i2p') }
} }
} }

@ -18,5 +18,22 @@ Rails.application.configure do
}.compact }.compact
end end
if ENV['http_hidden_proxy'].present?
proxy = URI.parse(ENV['http_hidden_proxy'])
raise "Unsupported proxy type: #{proxy.scheme}" unless %w(http https).include? proxy.scheme
raise "No proxy host" unless proxy.host
host = proxy.host
host = host[1...-1] if host[0] == '[' # for IPv6 address
config.x.http_client_hidden_proxy[:proxy] = {
proxy_address: host,
proxy_port: proxy.port,
proxy_username: proxy.user,
proxy_password: proxy.password,
}.compact
end
config.x.access_to_hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' config.x.access_to_hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
end end

@ -1181,6 +1181,8 @@ en:
edit: edit:
add_keyword: Add keyword add_keyword: Add keyword
keywords: Keywords keywords: Keywords
statuses: Individual posts
statuses_hint_html: This filter applies to select individual posts regardless of whether they match the keywords below. You can review these posts and remove them from the filter by <a href="%{path}">clicking here</a>.
title: Edit filter title: Edit filter
errors: 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. deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface.
@ -1194,10 +1196,23 @@ en:
keywords: keywords:
one: "%{count} keyword" one: "%{count} keyword"
other: "%{count} keywords" other: "%{count} keywords"
statuses:
one: "%{count} post"
other: "%{count} posts"
statuses_long:
one: "%{count} individual post hidden"
other: "%{count} individual posts hidden"
title: Filters title: Filters
new: new:
save: Save new filter save: Save new filter
title: Add new filter title: Add new filter
statuses:
back_to_filter: Back to filter
batch:
remove: Remove from filter
index:
hint: This filter applies to select individual posts regardless of other criteria. You can add more posts to this filter from the Web interface.
title: Filtered posts
footer: footer:
developers: Developers developers: Developers
more: More… more: More…

@ -85,6 +85,7 @@ en:
ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out! ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out!
severities: severities:
no_access: Block access to all resources no_access: Block access to all resources
sign_up_block: New sign-ups will not be possible
sign_up_requires_approval: New sign-ups will require your approval sign_up_requires_approval: New sign-ups will require your approval
severity: Choose what will happen with requests from this IP severity: Choose what will happen with requests from this IP
rule: rule:
@ -219,6 +220,7 @@ en:
ip: IP ip: IP
severities: severities:
no_access: Block access no_access: Block access
sign_up_block: Block sign-ups
sign_up_requires_approval: Limit sign-ups sign_up_requires_approval: Limit sign-ups
severity: Rule severity: Rule
notification_emails: notification_emails:

@ -180,7 +180,14 @@ Rails.application.routes.draw do
resources :tags, only: [:show] resources :tags, only: [:show]
resources :emojis, only: [:show] resources :emojis, only: [:show]
resources :invites, only: [:index, :create, :destroy] resources :invites, only: [:index, :create, :destroy]
resources :filters, except: [:show] resources :filters, except: [:show] do
resources :statuses, only: [:index], controller: 'filters/statuses' do
collection do
post :batch
end
end
end
resource :relationships, only: [:show, :update] resource :relationships, only: [:show, :update]
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update] resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
@ -470,12 +477,14 @@ Rails.application.routes.draw do
resources :trends, only: [:index], controller: 'trends/tags' resources :trends, only: [:index], controller: 'trends/tags'
resources :filters, only: [:index, :create, :show, :update, :destroy] do resources :filters, only: [:index, :create, :show, :update, :destroy] do
resources :keywords, only: [:index, :create], controller: 'filters/keywords' resources :keywords, only: [:index, :create], controller: 'filters/keywords'
resources :statuses, only: [:index, :create], controller: 'filters/statuses'
end end
resources :endorsements, only: [:index] resources :endorsements, only: [:index]
resources :markers, only: [:index, :create] resources :markers, only: [:index, :create]
namespace :filters do namespace :filters do
resources :keywords, only: [:show, :update, :destroy] resources :keywords, only: [:show, :update, :destroy]
resources :statuses, only: [:show, :destroy]
end end
namespace :apps do namespace :apps do

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateCustomFilterStatuses < ActiveRecord::Migration[6.1]
def change
create_table :custom_filter_statuses do |t|
t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false
t.belongs_to :status, foreign_key: { on_delete: :cascade }, null: false
t.timestamps
end
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_07_14_171049) do ActiveRecord::Schema.define(version: 2022_08_08_101323) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -348,6 +348,15 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do
t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id" t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id"
end end
create_table "custom_filter_statuses", force: :cascade do |t|
t.bigint "custom_filter_id", null: false
t.bigint "status_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["custom_filter_id"], name: "index_custom_filter_statuses_on_custom_filter_id"
t.index ["status_id"], name: "index_custom_filter_statuses_on_status_id"
end
create_table "custom_filters", force: :cascade do |t| create_table "custom_filters", force: :cascade do |t|
t.bigint "account_id" t.bigint "account_id"
t.datetime "expires_at" t.datetime "expires_at"
@ -1116,6 +1125,8 @@ ActiveRecord::Schema.define(version: 2022_07_14_171049) do
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade
add_foreign_key "custom_filter_statuses", "custom_filters", on_delete: :cascade
add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade
add_foreign_key "custom_filters", "accounts", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "devices", "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 add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade

@ -187,6 +187,7 @@ module Mastodon
option :account, type: :string option :account, type: :string
option :domain, type: :string option :domain, type: :string
option :status, type: :numeric option :status, type: :numeric
option :days, type: :numeric
option :concurrency, type: :numeric, default: 5, aliases: [:c] option :concurrency, type: :numeric, default: 5, aliases: [:c]
option :verbose, type: :boolean, default: false, aliases: [:v] option :verbose, type: :boolean, default: false, aliases: [:v]
option :dry_run, type: :boolean, default: false option :dry_run, type: :boolean, default: false
@ -204,6 +205,8 @@ module Mastodon
Use the --domain option to download attachments from a specific domain. Use the --domain option to download attachments from a specific domain.
Use the --days option to limit attachments created within days.
By default, attachments that are believed to be already downloaded will By default, attachments that are believed to be already downloaded will
not be re-downloaded. To force re-download of every URL, use --force. not be re-downloaded. To force re-download of every URL, use --force.
DESC DESC
@ -224,10 +227,16 @@ module Mastodon
scope = MediaAttachment.where(account_id: account.id) scope = MediaAttachment.where(account_id: account.id)
elsif options[:domain] elsif options[:domain]
scope = MediaAttachment.joins(:account).merge(Account.by_domain_and_subdomains(options[:domain])) scope = MediaAttachment.joins(:account).merge(Account.by_domain_and_subdomains(options[:domain]))
elsif options[:days].present?
scope = MediaAttachment.remote
else else
exit(1) exit(1)
end end
if options[:days].present?
scope = scope.where('id > ?', Mastodon::Snowflake.id_at(options[:days].days.ago, with_random: false))
end
processed, aggregate = parallelize_with_progress(scope) do |media_attachment| processed, aggregate = parallelize_with_progress(scope) do |media_attachment|
next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?) next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?)
next if DomainBlock.reject_media?(media_attachment.account.domain) next if DomainBlock.reject_media?(media_attachment.account.domain)

@ -24,7 +24,7 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.18.10", "@babel/core": "^7.18.13",
"@babel/plugin-proposal-decorators": "^7.18.10", "@babel/plugin-proposal-decorators": "^7.18.10",
"@babel/plugin-transform-react-inline-elements": "^7.18.6", "@babel/plugin-transform-react-inline-elements": "^7.18.6",
"@babel/plugin-transform-runtime": "^7.18.10", "@babel/plugin-transform-runtime": "^7.18.10",
@ -121,7 +121,7 @@
"requestidlecallback": "^0.3.0", "requestidlecallback": "^0.3.0",
"reselect": "^4.1.6", "reselect": "^4.1.6",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.54.4", "sass": "^1.54.5",
"sass-loader": "^10.2.0", "sass-loader": "^10.2.0",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"stringz": "^2.1.0", "stringz": "^2.1.0",
@ -134,7 +134,7 @@
"uuid": "^8.3.1", "uuid": "^8.3.1",
"webpack": "^4.46.0", "webpack": "^4.46.0",
"webpack-assets-manifest": "^4.0.6", "webpack-assets-manifest": "^4.0.6",
"webpack-bundle-analyzer": "^4.5.0", "webpack-bundle-analyzer": "^4.6.1",
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-merge": "^5.8.0", "webpack-merge": "^5.8.0",
"wicg-inert": "^3.1.2", "wicg-inert": "^3.1.2",
@ -157,7 +157,7 @@
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",
"stylelint": "^14.10.0", "stylelint": "^14.11.0",
"stylelint-config-standard-scss": "^4.0.0", "stylelint-config-standard-scss": "^4.0.0",
"webpack-dev-server": "^3.11.3", "webpack-dev-server": "^3.11.3",
"yargs": "^17.5.1" "yargs": "^17.5.1"

@ -0,0 +1,116 @@
require 'rails_helper'
RSpec.describe Api::V1::Filters::StatusesController, type: :controller do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
let(:other_user) { Fabricate(:user) }
let(:other_filter) { Fabricate(:custom_filter, account: other_user.account) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
let(:scopes) { 'read:filters' }
let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
it 'returns http success' do
get :index, params: { filter_id: filter.id }
expect(response).to have_http_status(200)
end
context "when trying to access another's user filters" do
it 'returns http not found' do
get :index, params: { filter_id: other_filter.id }
expect(response).to have_http_status(404)
end
end
end
describe 'POST #create' do
let(:scopes) { 'write:filters' }
let(:filter_id) { filter.id }
let!(:status) { Fabricate(:status) }
before do
post :create, params: { filter_id: filter_id, status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns a status filter' do
json = body_as_json
expect(json[:status_id]).to eq status.id.to_s
end
it 'creates a status filter' do
filter = user.account.custom_filters.first
expect(filter).to_not be_nil
expect(filter.statuses.pluck(:status_id)).to eq [status.id]
end
context "when trying to add to another another's user filters" do
let(:filter_id) { other_filter.id }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'GET #show' do
let(:scopes) { 'read:filters' }
let!(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
before do
get :show, params: { id: status_filter.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns expected data' do
json = body_as_json
expect(json[:status_id]).to eq status_filter.status_id.to_s
end
context "when trying to access another user's filter keyword" do
let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'DELETE #destroy' do
let(:scopes) { 'write:filters' }
let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: filter) }
before do
delete :destroy, params: { id: status_filter.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'removes the filter' do
expect { status_filter.reload }.to raise_error ActiveRecord::RecordNotFound
end
context "when trying to update another user's filter keyword" do
let(:status_filter) { Fabricate(:custom_filter_status, custom_filter: other_filter) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
end

@ -47,6 +47,33 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
end end
end end
context 'when post is explicitly filtered' do
let(:status) { Fabricate(:status, text: 'hello world') }
before do
filter = user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide)
filter.statuses.create!(status_id: status.id)
end
it 'returns http success' do
get :show, params: { id: status.id }
expect(response).to have_http_status(200)
end
it 'returns filter information' do
get :show, params: { id: status.id }
json = body_as_json
expect(json[:filtered][0]).to include({
filter: a_hash_including({
id: user.account.custom_filters.first.id.to_s,
title: 'filter1',
filter_action: 'hide',
}),
status_matches: [status.id.to_s],
})
end
end
context 'when reblog includes filtered terms' do context 'when reblog includes filtered terms' do
let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) } let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) }

@ -0,0 +1,4 @@
Fabricator(:custom_filter_status) do
custom_filter
status
end

@ -12,16 +12,29 @@ RSpec.describe EmailDomainBlock, type: :model do
let(:input) { nil } let(:input) { nil }
context 'given an e-mail address' do context 'given an e-mail address' do
let(:input) { 'nyarn@example.com' } let(:input) { "foo@#{domain}" }
it 'returns true if the domain is blocked' do context do
Fabricate(:email_domain_block, domain: 'example.com') let(:domain) { 'example.com' }
expect(EmailDomainBlock.block?(input)).to be true
it 'returns true if the domain is blocked' do
Fabricate(:email_domain_block, domain: 'example.com')
expect(EmailDomainBlock.block?(input)).to be true
end
it 'returns false if the domain is not blocked' do
Fabricate(:email_domain_block, domain: 'other-example.com')
expect(EmailDomainBlock.block?(input)).to be false
end
end end
it 'returns false if the domain is not blocked' do context do
Fabricate(:email_domain_block, domain: 'other-example.com') let(:domain) { 'mail.example.com' }
expect(EmailDomainBlock.block?(input)).to be false
it 'returns true if it is a subdomain of a blocked domain' do
Fabricate(:email_domain_block, domain: 'example.com')
expect(described_class.block?(input)).to be true
end
end end
end end

@ -94,5 +94,32 @@ RSpec.describe StatusRelationshipsPresenter do
expect(matched_filters[0].keyword_matches).to eq ['irrelevant'] expect(matched_filters[0].keyword_matches).to eq ['irrelevant']
end end
end end
context 'when post includes filtered individual statuses' do
let(:statuses) { [Fabricate(:status, text: 'hello world'), Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about an irrelevant word'))] }
let(:options) { {} }
before do
filter = Account.find(current_account_id).custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide)
filter.statuses.create!(status_id: statuses[0].id)
filter.statuses.create!(status_id: statuses[1].reblog_of_id)
end
it 'sets @filters_map to filter top-level status' do
matched_filters = presenter.filters_map[statuses[0].id]
expect(matched_filters.size).to eq 1
expect(matched_filters[0].filter.title).to eq 'filter1'
expect(matched_filters[0].status_matches).to eq [statuses[0].id]
end
it 'sets @filters_map to filter reblogged status' do
matched_filters = presenter.filters_map[statuses[1].reblog_of_id]
expect(matched_filters.size).to eq 1
expect(matched_filters[0].filter.title).to eq 'filter1'
expect(matched_filters[0].status_matches).to eq [statuses[1].reblog_of_id]
end
end
end end
end end

@ -11,7 +11,7 @@ RSpec.describe AppSignUpService, type: :service do
it 'returns nil when registrations are closed' do it 'returns nil when registrations are closed' do
tmp = Setting.registrations_mode tmp = Setting.registrations_mode
Setting.registrations_mode = 'none' Setting.registrations_mode = 'none'
expect(subject.call(app, remote_ip, good_params)).to be_nil expect { subject.call(app, remote_ip, good_params) }.to raise_error Mastodon::NotPermittedError
Setting.registrations_mode = tmp Setting.registrations_mode = tmp
end end

@ -33,21 +33,21 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d"
integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ==
"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.18.10", "@babel/core@^7.7.2": "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.18.13", "@babel/core@^7.7.2":
version "7.18.10" version "7.18.13"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.13.tgz#9be8c44512751b05094a4d3ab05fc53a47ce00ac"
integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw== integrity sha512-ZisbOvRRusFktksHSG6pjj1CSvkPkcZq/KHD45LAkVP/oiHJkNBZWfpvlLmX8OtHDG8IuzsFlVRWo08w7Qxn0A==
dependencies: dependencies:
"@ampproject/remapping" "^2.1.0" "@ampproject/remapping" "^2.1.0"
"@babel/code-frame" "^7.18.6" "@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.18.10" "@babel/generator" "^7.18.13"
"@babel/helper-compilation-targets" "^7.18.9" "@babel/helper-compilation-targets" "^7.18.9"
"@babel/helper-module-transforms" "^7.18.9" "@babel/helper-module-transforms" "^7.18.9"
"@babel/helpers" "^7.18.9" "@babel/helpers" "^7.18.9"
"@babel/parser" "^7.18.10" "@babel/parser" "^7.18.13"
"@babel/template" "^7.18.10" "@babel/template" "^7.18.10"
"@babel/traverse" "^7.18.10" "@babel/traverse" "^7.18.13"
"@babel/types" "^7.18.10" "@babel/types" "^7.18.13"
convert-source-map "^1.7.0" convert-source-map "^1.7.0"
debug "^4.1.0" debug "^4.1.0"
gensync "^1.0.0-beta.2" gensync "^1.0.0-beta.2"
@ -63,12 +63,12 @@
eslint-visitor-keys "^2.1.0" eslint-visitor-keys "^2.1.0"
semver "^6.3.0" semver "^6.3.0"
"@babel/generator@^7.18.10", "@babel/generator@^7.7.2": "@babel/generator@^7.18.13", "@babel/generator@^7.7.2":
version "7.18.10" version "7.18.13"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.10.tgz#794f328bfabdcbaf0ebf9bf91b5b57b61fa77a2a" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.13.tgz#59550cbb9ae79b8def15587bdfbaa388c4abf212"
integrity sha512-0+sW7e3HjQbiHbj1NeU/vN8ornohYlacAfZIaXhdoGweQqgcNy69COVciYYqEXJ/v+9OBA7Frxm4CVAuNqKeNA== integrity sha512-CkPg8ySSPuHTYPJYo7IRALdqyjM9HCbt/3uOBEFbzyGVP6Mn8bwFPB0jX6982JVNBlYzM1nnPkfjuXSOPtQeEQ==
dependencies: dependencies:
"@babel/types" "^7.18.10" "@babel/types" "^7.18.13"
"@jridgewell/gen-mapping" "^0.3.2" "@jridgewell/gen-mapping" "^0.3.2"
jsesc "^2.5.1" jsesc "^2.5.1"
@ -337,10 +337,10 @@
chalk "^2.0.0" chalk "^2.0.0"
js-tokens "^4.0.0" js-tokens "^4.0.0"
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10": "@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.13":
version "7.18.10" version "7.18.13"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.10.tgz#94b5f8522356e69e8277276adf67ed280c90ecc1" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.13.tgz#5b2dd21cae4a2c5145f1fbd8ca103f9313d3b7e4"
integrity sha512-TYk3OA0HKL6qNryUayb5UUEhM/rkOQozIBEA5ITXh5DWrSp0TlUQXMyZmnWxG/DizSWBeeQ0Zbc5z8UGaaqoeg== integrity sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg==
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
version "7.18.6" version "7.18.6"
@ -1085,26 +1085,26 @@
"@babel/parser" "^7.18.10" "@babel/parser" "^7.18.10"
"@babel/types" "^7.18.10" "@babel/types" "^7.18.10"
"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.6", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": "@babel/traverse@^7.18.10", "@babel/traverse@^7.18.13", "@babel/traverse@^7.18.6", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2":
version "7.18.10" version "7.18.13"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.10.tgz#37ad97d1cb00efa869b91dd5d1950f8a6cf0cb08" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.13.tgz#5ab59ef51a997b3f10c4587d648b9696b6cb1a68"
integrity sha512-J7ycxg0/K9XCtLyHf0cz2DqDihonJeIo+z+HEdRe9YuT8TY4A66i+Ab2/xZCEW7Ro60bPCBBfqqboHSamoV3+g== integrity sha512-N6kt9X1jRMLPxxxPYWi7tgvJRH/rtoU+dbKAPDM44RFHiMH8igdsaSBgFeskhSl/kLWLDUvIh1RXCrTmg0/zvA==
dependencies: dependencies:
"@babel/code-frame" "^7.18.6" "@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.18.10" "@babel/generator" "^7.18.13"
"@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-environment-visitor" "^7.18.9"
"@babel/helper-function-name" "^7.18.9" "@babel/helper-function-name" "^7.18.9"
"@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-hoist-variables" "^7.18.6"
"@babel/helper-split-export-declaration" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6"
"@babel/parser" "^7.18.10" "@babel/parser" "^7.18.13"
"@babel/types" "^7.18.10" "@babel/types" "^7.18.13"
debug "^4.1.0" debug "^4.1.0"
globals "^11.1.0" globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": "@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.13", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
version "7.18.10" version "7.18.13"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.13.tgz#30aeb9e514f4100f7c1cb6e5ba472b30e48f519a"
integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== integrity sha512-ePqfTihzW0W6XAU+aMw2ykilisStJfDnsejDCXRchCcMJ4O0+8DhPXf2YUbZ6wjBlsEmZwLK/sPweWtu8hcJYQ==
dependencies: dependencies:
"@babel/helper-string-parser" "^7.18.10" "@babel/helper-string-parser" "^7.18.10"
"@babel/helper-validator-identifier" "^7.18.6" "@babel/helper-validator-identifier" "^7.18.6"
@ -2139,12 +2139,7 @@ acorn@^7.1.1, acorn@^7.4.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
acorn@^8.0.4: acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
version "8.3.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.3.0.tgz#1193f9b96c4e8232f00b11a9edff81b2c8b98b88"
integrity sha512-tqPKHZ5CaBJw0Xmy0ZZvLs1qTV+BNFSyvn77ASXkpBNfIRk8ev26fKrD9iLGwGA9zedPao52GSHzq8lyZG0NUw==
acorn@^8.5.0, acorn@^8.7.1:
version "8.7.1" version "8.7.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
@ -3371,10 +3366,10 @@ color@^3.0.0:
color-convert "^1.9.1" color-convert "^1.9.1"
color-string "^1.5.2" color-string "^1.5.2"
colord@^2.9.2: colord@^2.9.3:
version "2.9.2" version "2.9.3"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
integrity sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ== integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==
colorette@^1.2.2: colorette@^1.2.2:
version "1.2.2" version "1.2.2"
@ -9755,10 +9750,10 @@ sass-loader@^10.2.0:
schema-utils "^3.0.0" schema-utils "^3.0.0"
semver "^7.3.2" semver "^7.3.2"
sass@^1.54.4: sass@^1.54.5:
version "1.54.4" version "1.54.5"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.4.tgz#803ff2fef5525f1dd01670c3915b4b68b6cba72d" resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.5.tgz#93708f5560784f6ff2eab8542ade021a4a947b3a"
integrity sha512-3tmF16yvnBwtlPrNBHw/H907j8MlOX8aTBnlNX1yrKx24RKcJGPyLhFUwkoKBKesR3unP93/2z14Ll8NicwQUA== integrity sha512-p7DTOzxkUPa/63FU0R3KApkRHwcVZYC0PLnLm5iyZACyp15qSi32x7zVUhRdABAATmkALqgGrjCJAcWvobmhHw==
dependencies: dependencies:
chokidar ">=3.0.0 <4.0.0" chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0" immutable "^4.0.0"
@ -10524,14 +10519,14 @@ stylelint-scss@^4.0.0:
postcss-selector-parser "^6.0.6" postcss-selector-parser "^6.0.6"
postcss-value-parser "^4.1.0" postcss-value-parser "^4.1.0"
stylelint@^14.10.0: stylelint@^14.11.0:
version "14.10.0" version "14.11.0"
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-14.10.0.tgz#c588f5cd47cd214cf1acee5bc165961b6a3ad836" resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-14.11.0.tgz#e2ecb28bbacab05e1fbeb84cbba23883b27499cc"
integrity sha512-VAmyKrEK+wNFh9R8mNqoxEFzaa4gsHGhcT4xgkQDuOA5cjF6CaNS8loYV7gpi4tIZBPUyXesotPXzJAMN8VLOQ== integrity sha512-OTLjLPxpvGtojEfpESWM8Ir64Z01E89xsisaBMUP/ngOx1+4VG2DPRcUyCCiin9Rd3kPXPsh/uwHd9eqnvhsYA==
dependencies: dependencies:
"@csstools/selector-specificity" "^2.0.2" "@csstools/selector-specificity" "^2.0.2"
balanced-match "^2.0.0" balanced-match "^2.0.0"
colord "^2.9.2" colord "^2.9.3"
cosmiconfig "^7.0.1" cosmiconfig "^7.0.1"
css-functions-list "^3.1.0" css-functions-list "^3.1.0"
debug "^4.3.4" debug "^4.3.4"
@ -10566,7 +10561,7 @@ stylelint@^14.10.0:
svg-tags "^1.0.0" svg-tags "^1.0.0"
table "^6.8.0" table "^6.8.0"
v8-compile-cache "^2.3.0" v8-compile-cache "^2.3.0"
write-file-atomic "^4.0.1" write-file-atomic "^4.0.2"
stylis@4.0.13: stylis@4.0.13:
version "4.0.13" version "4.0.13"
@ -11353,10 +11348,10 @@ webpack-assets-manifest@^4.0.6:
tapable "^1.0" tapable "^1.0"
webpack-sources "^1.0" webpack-sources "^1.0"
webpack-bundle-analyzer@^4.5.0: webpack-bundle-analyzer@^4.6.1:
version "4.5.0" version "4.6.1"
resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz#1b0eea2947e73528754a6f9af3e91b2b6e0f79d5" resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.6.1.tgz#bee2ee05f4ba4ed430e4831a319126bb4ed9f5a6"
integrity sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ== integrity sha512-oKz9Oz9j3rUciLNfpGFjOb49/jEpXNmWdVH8Ls//zNcnLlQdTGXQQMsBbb/gR7Zl8WNLxVCq+0Hqbx3zv6twBw==
dependencies: dependencies:
acorn "^8.0.4" acorn "^8.0.4"
acorn-walk "^8.0.0" acorn-walk "^8.0.0"
@ -11629,10 +11624,10 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
write-file-atomic@^4.0.1: write-file-atomic@^4.0.1, write-file-atomic@^4.0.2:
version "4.0.1" version "4.0.2"
resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.1.tgz#9faa33a964c1c85ff6f849b80b42a88c2c537c8f" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd"
integrity sha512-nSKUxgAbyioruk6hU87QzVbY279oYT6uiwgDoujth2ju4mJ+TZau7SQBhtbTmUyuNYTuXnSyRn66FV0+eCgcrQ== integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==
dependencies: dependencies:
imurmurhash "^0.1.4" imurmurhash "^0.1.4"
signal-exit "^3.0.7" signal-exit "^3.0.7"

Loading…
Cancel
Save