Merge: + Filters: Allow changing Filter Name and URL parameters

Close #971

* commit 'b3bca39de4bebb716606c35f034f84cb6aa12de0':
  + client: modify added filters
  * filtering/set_url: allow changing Name and URL parameters
This commit is contained in:
Simon Zolin 2020-01-21 14:22:11 +03:00
commit b5f95fefc8
11 changed files with 325 additions and 115 deletions

View File

@ -1338,9 +1338,13 @@ Request:
POST /control/filtering/set_url POST /control/filtering/set_url
{ {
"url": "..."
"data": {
"name": "..."
"url": "..." "url": "..."
"enabled": true | false "enabled": true | false
} }
}
Response: Response:

View File

@ -137,7 +137,9 @@
"enter_url_hint": "Enter URL", "enter_url_hint": "Enter URL",
"check_updates_btn": "Check updates", "check_updates_btn": "Check updates",
"new_filter_btn": "New filter subscription", "new_filter_btn": "New filter subscription",
"edit_filter_title": "Edit filter",
"enter_valid_filter_url": "Enter a valid URL to a filter subscription or a hosts file.", "enter_valid_filter_url": "Enter a valid URL to a filter subscription or a hosts file.",
"form_error_url_format": "Invalid url format",
"custom_filter_rules": "Custom filtering rules", "custom_filter_rules": "Custom filtering rules",
"custom_filter_rules_hint": "Enter one rule on a line. You can use either adblock rules or hosts files syntax.", "custom_filter_rules_hint": "Enter one rule on a line. You can use either adblock rules or hosts files syntax.",
"examples_title": "Examples", "examples_title": "Examples",
@ -404,6 +406,7 @@
"domain": "Domain", "domain": "Domain",
"answer": "Answer", "answer": "Answer",
"filter_added_successfully": "The filter has been successfully added", "filter_added_successfully": "The filter has been successfully added",
"filter_updated": "The filter successfully updated",
"statistics_configuration": "Statistics configuration", "statistics_configuration": "Statistics configuration",
"statistics_retention": "Statistics retention", "statistics_retention": "Statistics retention",
"statistics_retention_desc": "If you decrease the interval value, some data will be lost", "statistics_retention_desc": "If you decrease the interval value, some data will be lost",

View File

@ -78,10 +78,10 @@ export const toggleFilterRequest = createAction('FILTER_TOGGLE_REQUEST');
export const toggleFilterFailure = createAction('FILTER_TOGGLE_FAILURE'); export const toggleFilterFailure = createAction('FILTER_TOGGLE_FAILURE');
export const toggleFilterSuccess = createAction('FILTER_TOGGLE_SUCCESS'); export const toggleFilterSuccess = createAction('FILTER_TOGGLE_SUCCESS');
export const toggleFilterStatus = (url, enabled) => async (dispatch) => { export const toggleFilterStatus = (url, data) => async (dispatch) => {
dispatch(toggleFilterRequest()); dispatch(toggleFilterRequest());
try { try {
await apiClient.setFilterUrl({ url, enabled: !enabled }); await apiClient.setFilterUrl({ url, data });
dispatch(toggleFilterSuccess(url)); dispatch(toggleFilterSuccess(url));
dispatch(getFilteringStatus()); dispatch(getFilteringStatus());
} catch (error) { } catch (error) {
@ -90,6 +90,24 @@ export const toggleFilterStatus = (url, enabled) => async (dispatch) => {
} }
}; };
export const editFilterRequest = createAction('EDIT_FILTER_REQUEST');
export const editFilterFailure = createAction('EDIT_FILTER_FAILURE');
export const editFilterSuccess = createAction('EDIT_FILTER_SUCCESS');
export const editFilter = (url, data) => async (dispatch) => {
dispatch(editFilterRequest());
try {
await apiClient.setFilterUrl({ url, data });
dispatch(editFilterSuccess(url));
dispatch(toggleFilteringModal());
dispatch(addSuccessToast('filter_updated'));
dispatch(getFilteringStatus());
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(editFilterFailure());
}
};
export const refreshFiltersRequest = createAction('FILTERING_REFRESH_REQUEST'); export const refreshFiltersRequest = createAction('FILTERING_REFRESH_REQUEST');
export const refreshFiltersFailure = createAction('FILTERING_REFRESH_FAILURE'); export const refreshFiltersFailure = createAction('FILTERING_REFRESH_FAILURE');
export const refreshFiltersSuccess = createAction('FILTERING_REFRESH_SUCCESS'); export const refreshFiltersSuccess = createAction('FILTERING_REFRESH_SUCCESS');

View File

@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Field, reduxForm } from 'redux-form';
import { Trans, withNamespaces } from 'react-i18next';
import flow from 'lodash/flow';
import { renderInputField, required, isValidUrl } from '../../helpers/form';
const Form = (props) => {
const {
t,
closeModal,
handleSubmit,
processingAddFilter,
processingConfigFilter,
} = props;
return (
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="form__group">
<Field
id="name"
name="name"
type="text"
component={renderInputField}
className="form-control"
placeholder={t('enter_name_hint')}
validate={[required]}
/>
</div>
<div className="form__group">
<Field
id="url"
name="url"
type="text"
component={renderInputField}
className="form-control"
placeholder={t('enter_url_hint')}
validate={[required, isValidUrl]}
/>
</div>
<div className="form__description">
<Trans>enter_valid_filter_url</Trans>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={closeModal}
>
<Trans>cancel_btn</Trans>
</button>
<button
type="submit"
className="btn btn-success"
disabled={processingAddFilter || processingConfigFilter}
>
<Trans>save_btn</Trans>
</button>
</div>
</form>
);
};
Form.propTypes = {
t: PropTypes.func.isRequired,
closeModal: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
processingAddFilter: PropTypes.bool.isRequired,
processingConfigFilter: PropTypes.bool.isRequired,
};
export default flow([
withNamespaces(),
reduxForm({
form: 'filterForm',
}),
])(Form);

View File

@ -1,52 +1,28 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
import classnames from 'classnames';
import { Trans, withNamespaces } from 'react-i18next'; import { Trans, withNamespaces } from 'react-i18next';
import { R_URL_REQUIRES_PROTOCOL } from '../../helpers/constants';
import { MODAL_TYPE } from '../../helpers/constants';
import Form from './Form';
import '../ui/Modal.css'; import '../ui/Modal.css';
ReactModal.setAppElement('#root'); ReactModal.setAppElement('#root');
const initialState = {
url: '',
name: '',
isUrlValid: false,
};
class Modal extends Component { class Modal extends Component {
state = initialState;
isUrlValid = url => R_URL_REQUIRES_PROTOCOL.test(url);
handleUrlChange = async (e) => {
const { value: url } = e.currentTarget;
this.setState(...this.state, { url, isUrlValid: this.isUrlValid(url) });
};
handleNameChange = (e) => {
const { value: name } = e.currentTarget;
this.setState({ ...this.state, name });
};
closeModal = () => { closeModal = () => {
this.props.toggleModal(); this.props.toggleModal();
this.setState({ ...this.state, ...initialState });
}; };
render() { render() {
const { isOpen, processingAddFilter } = this.props; const {
const { isUrlValid, url, name } = this.state; isOpen,
const inputUrlClass = classnames({ processingAddFilter,
'form-control mb-2': true, processingConfigFilter,
'is-invalid': url.length > 0 && !isUrlValid, handleSubmit,
'is-valid': url.length > 0 && isUrlValid, modalType,
}); currentFilterData,
const inputNameClass = classnames({ } = this.props;
'form-control mb-2': true,
'is-valid': name.length > 0,
});
const isValidForSubmit = url.length > 0 && isUrlValid && name.length > 0;
return ( return (
<ReactModal <ReactModal
@ -58,46 +34,23 @@ class Modal extends Component {
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
<h4 className="modal-title"> <h4 className="modal-title">
<Trans>new_filter_btn</Trans> {modalType === MODAL_TYPE.EDIT ? (
<Trans>edit_filter_title</Trans>
) : (
<Trans>new_filter_btn</Trans>
)}
</h4> </h4>
<button type="button" className="close" onClick={this.closeModal}> <button type="button" className="close" onClick={this.closeModal}>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</button> </button>
</div> </div>
<div className="modal-body"> <Form
<input initialValues={{ ...currentFilterData }}
type="text" onSubmit={handleSubmit}
className={inputNameClass} processingAddFilter={processingAddFilter}
placeholder={this.props.t('enter_name_hint')} processingConfigFilter={processingConfigFilter}
onChange={this.handleNameChange} closeModal={this.closeModal}
/> />
<input
type="text"
className={inputUrlClass}
placeholder={this.props.t('enter_url_hint')}
onChange={this.handleUrlChange}
/>
<div className="description">
<Trans>enter_valid_filter_url</Trans>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={this.closeModal}
>
<Trans>cancel_btn</Trans>
</button>
<button
type="button"
className="btn btn-success"
onClick={() => this.props.addFilter(url, name)}
disabled={!isValidForSubmit || processingAddFilter}
>
<Trans>add_filter_btn</Trans>
</button>
</div>
</div> </div>
</ReactModal> </ReactModal>
); );
@ -110,6 +63,10 @@ Modal.propTypes = {
addFilter: PropTypes.func.isRequired, addFilter: PropTypes.func.isRequired,
isFilterAdded: PropTypes.bool.isRequired, isFilterAdded: PropTypes.bool.isRequired,
processingAddFilter: PropTypes.bool.isRequired, processingAddFilter: PropTypes.bool.isRequired,
processingConfigFilter: PropTypes.bool.isRequired,
handleSubmit: PropTypes.func.isRequired,
modalType: PropTypes.string.isRequired,
currentFilterData: PropTypes.object.isRequired,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
}; };

View File

@ -9,6 +9,8 @@ import CellWrap from '../ui/CellWrap';
import UserRules from './UserRules'; import UserRules from './UserRules';
import Modal from './Modal'; import Modal from './Modal';
import { MODAL_TYPE } from '../../helpers/constants';
class Filters extends Component { class Filters extends Component {
componentDidMount() { componentDidMount() {
this.props.getFilteringStatus(); this.props.getFilteringStatus();
@ -22,15 +24,29 @@ class Filters extends Component {
this.props.setRules(this.props.filtering.userRules); this.props.setRules(this.props.filtering.userRules);
}; };
handleSubmit = (values) => {
const { name, url } = values;
const { filtering } = this.props;
if (filtering.modalType === MODAL_TYPE.EDIT) {
const data = { ...values };
this.props.editFilter(filtering.modalFilterUrl, data);
} else {
this.props.addFilter(url, name);
}
}
renderCheckbox = ({ original }) => { renderCheckbox = ({ original }) => {
const { processingConfigFilter } = this.props.filtering; const { processingConfigFilter } = this.props.filtering;
const { url, enabled } = original; const { url, name, enabled } = original;
const data = { name, url, enabled: !enabled };
return ( return (
<label className="checkbox"> <label className="checkbox">
<input <input
type="checkbox" type="checkbox"
className="checkbox__input" className="checkbox__input"
onChange={() => this.props.toggleFilterStatus(url, enabled)} onChange={() => this.props.toggleFilterStatus(url, data)}
checked={enabled} checked={enabled}
disabled={processingConfigFilter} disabled={processingConfigFilter}
/> />
@ -46,6 +62,17 @@ class Filters extends Component {
} }
}; };
getFilter = (url, filters) => {
const filter = filters.find(item => url === item.url);
if (filter) {
const { enabled, name, url } = filter;
return { enabled, name, url };
}
return { name: '', url: '' };
};
columns = [ columns = [
{ {
Header: <Trans>enabled_table_header</Trans>, Header: <Trans>enabled_table_header</Trans>,
@ -94,21 +121,43 @@ class Filters extends Component {
{ {
Header: <Trans>actions_table_header</Trans>, Header: <Trans>actions_table_header</Trans>,
accessor: 'url', accessor: 'url',
Cell: ({ value }) => (
<button
type="button"
className="btn btn-icon btn-outline-secondary btn-sm"
onClick={() => this.handleDelete(value)}
title={this.props.t('delete_table_action')}
>
<svg className="icons">
<use xlinkHref="#delete" />
</svg>
</button>
),
className: 'text-center', className: 'text-center',
width: 80, width: 100,
sortable: false, sortable: false,
Cell: (row) => {
const { value } = row;
const { t, toggleFilteringModal } = this.props;
return (
<div className="logs__row logs__row--center">
<button
type="button"
className="btn btn-icon btn-outline-primary btn-sm mr-2"
title={t('edit_table_action')}
onClick={() =>
toggleFilteringModal({
type: MODAL_TYPE.EDIT,
url: value,
})
}
>
<svg className="icons">
<use xlinkHref="#edit" />
</svg>
</button>
<button
type="button"
className="btn btn-icon btn-outline-secondary btn-sm"
onClick={() => this.handleDelete(value)}
title={this.props.t('delete_table_action')}
>
<svg className="icons">
<use xlinkHref="#delete" />
</svg>
</button>
</div>
);
},
}, },
]; ];
@ -124,9 +173,14 @@ class Filters extends Component {
processingRefreshFilters, processingRefreshFilters,
processingRemoveFilter, processingRemoveFilter,
processingAddFilter, processingAddFilter,
processingConfigFilter,
processingFilters, processingFilters,
modalType,
modalFilterUrl,
} = filtering; } = filtering;
const currentFilterData = this.getFilter(modalFilterUrl, filters);
return ( return (
<Fragment> <Fragment>
<PageTitle title={t('filters')} /> <PageTitle title={t('filters')} />
@ -161,7 +215,9 @@ class Filters extends Component {
<button <button
className="btn btn-success btn-standard mr-2" className="btn btn-success btn-standard mr-2"
type="submit" type="submit"
onClick={toggleFilteringModal} onClick={() =>
toggleFilteringModal({ type: MODAL_TYPE.ADD })
}
> >
<Trans>add_filter_btn</Trans> <Trans>add_filter_btn</Trans>
</button> </button>
@ -191,6 +247,10 @@ class Filters extends Component {
addFilter={addFilter} addFilter={addFilter}
isFilterAdded={isFilterAdded} isFilterAdded={isFilterAdded}
processingAddFilter={processingAddFilter} processingAddFilter={processingAddFilter}
processingConfigFilter={processingConfigFilter}
handleSubmit={this.handleSubmit}
modalType={modalType}
currentFilterData={currentFilterData}
/> />
</Fragment> </Fragment>
); );
@ -210,6 +270,7 @@ Filters.propTypes = {
processingRefreshFilters: PropTypes.bool.isRequired, processingRefreshFilters: PropTypes.bool.isRequired,
processingConfigFilter: PropTypes.bool.isRequired, processingConfigFilter: PropTypes.bool.isRequired,
processingRemoveFilter: PropTypes.bool.isRequired, processingRemoveFilter: PropTypes.bool.isRequired,
modalType: PropTypes.string.isRequired,
}), }),
removeFilter: PropTypes.func.isRequired, removeFilter: PropTypes.func.isRequired,
toggleFilterStatus: PropTypes.func.isRequired, toggleFilterStatus: PropTypes.func.isRequired,
@ -217,6 +278,7 @@ Filters.propTypes = {
toggleFilteringModal: PropTypes.func.isRequired, toggleFilteringModal: PropTypes.func.isRequired,
handleRulesChange: PropTypes.func.isRequired, handleRulesChange: PropTypes.func.isRequired,
refreshFilters: PropTypes.func.isRequired, refreshFilters: PropTypes.func.isRequired,
editFilter: PropTypes.func.isRequired,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
}; };

View File

@ -8,6 +8,7 @@ import {
toggleFilteringModal, toggleFilteringModal,
refreshFilters, refreshFilters,
handleRulesChange, handleRulesChange,
editFilter,
} from '../actions/filtering'; } from '../actions/filtering';
import Filters from '../components/Filters'; import Filters from '../components/Filters';
@ -26,6 +27,7 @@ const mapDispatchToProps = {
toggleFilteringModal, toggleFilteringModal,
refreshFilters, refreshFilters,
handleRulesChange, handleRulesChange,
editFilter,
}; };
export default connect( export default connect(

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { R_IPV4, R_MAC, R_HOST, R_IPV6, R_CIDR, UNSAFE_PORTS } from '../helpers/constants'; import { R_IPV4, R_MAC, R_HOST, R_IPV6, R_CIDR, UNSAFE_PORTS, R_URL_REQUIRES_PROTOCOL } from '../helpers/constants';
import { createOnBlurHandler } from './helpers'; import { createOnBlurHandler } from './helpers';
export const renderField = (props, elementType) => { export const renderField = (props, elementType) => {
@ -270,4 +270,11 @@ export const answer = (value) => {
return undefined; return undefined;
}; };
export const isValidUrl = (value) => {
if (value && !R_URL_REQUIRES_PROTOCOL.test(value)) {
return <Trans>form_error_url_format</Trans>;
}
return undefined;
};
export const toNumber = value => value && parseInt(value, 10); export const toNumber = value => value && parseInt(value, 10);

View File

@ -37,7 +37,17 @@ const filtering = handleActions(
isFilterAdded: true, isFilterAdded: true,
}), }),
[actions.toggleFilteringModal]: (state) => { [actions.toggleFilteringModal]: (state, { payload }) => {
if (payload) {
const newState = {
...state,
isModalOpen: !state.isModalOpen,
isFilterAdded: false,
modalType: payload.type || '',
modalFilterUrl: payload.url || '',
};
return newState;
}
const newState = { const newState = {
...state, ...state,
isModalOpen: !state.isModalOpen, isModalOpen: !state.isModalOpen,
@ -50,6 +60,10 @@ const filtering = handleActions(
[actions.toggleFilterFailure]: state => ({ ...state, processingConfigFilter: false }), [actions.toggleFilterFailure]: state => ({ ...state, processingConfigFilter: false }),
[actions.toggleFilterSuccess]: state => ({ ...state, processingConfigFilter: false }), [actions.toggleFilterSuccess]: state => ({ ...state, processingConfigFilter: false }),
[actions.editFilterRequest]: state => ({ ...state, processingConfigFilter: true }),
[actions.editFilterFailure]: state => ({ ...state, processingConfigFilter: false }),
[actions.editFilterSuccess]: state => ({ ...state, processingConfigFilter: false }),
[actions.refreshFiltersRequest]: state => ({ ...state, processingRefreshFilters: true }), [actions.refreshFiltersRequest]: state => ({ ...state, processingRefreshFilters: true }),
[actions.refreshFiltersFailure]: state => ({ ...state, processingRefreshFilters: false }), [actions.refreshFiltersFailure]: state => ({ ...state, processingRefreshFilters: false }),
[actions.refreshFiltersSuccess]: state => ({ ...state, processingRefreshFilters: false }), [actions.refreshFiltersSuccess]: state => ({ ...state, processingRefreshFilters: false }),
@ -80,6 +94,8 @@ const filtering = handleActions(
userRules: '', userRules: '',
interval: 24, interval: 24,
enabled: true, enabled: true,
modalType: '',
modalFilterUrl: '',
}, },
); );

View File

@ -137,12 +137,18 @@ func handleFilteringRemoveURL(w http.ResponseWriter, r *http.Request) {
} }
type filterURLJSON struct { type filterURLJSON struct {
Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
type filterURLReq struct {
URL string `json:"url"`
Data filterURLJSON `json:"data"`
}
func handleFilteringSetURL(w http.ResponseWriter, r *http.Request) { func handleFilteringSetURL(w http.ResponseWriter, r *http.Request) {
fj := filterURLJSON{} fj := filterURLReq{}
err := json.NewDecoder(r.Body).Decode(&fj) err := json.NewDecoder(r.Body).Decode(&fj)
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest, "json decode: %s", err) httpError(w, http.StatusBadRequest, "json decode: %s", err)
@ -154,14 +160,34 @@ func handleFilteringSetURL(w http.ResponseWriter, r *http.Request) {
return return
} }
found := filterEnable(fj.URL, fj.Enabled) f := filter{
if !found { Enabled: fj.Data.Enabled,
Name: fj.Data.Name,
URL: fj.Data.URL,
}
status := filterSetProperties(fj.URL, f)
if (status & statusFound) == 0 {
http.Error(w, "URL doesn't exist", http.StatusBadRequest) http.Error(w, "URL doesn't exist", http.StatusBadRequest)
return return
} }
if (status & statusURLExists) != 0 {
http.Error(w, "URL already exists", http.StatusBadRequest)
return
}
onConfigModified() onConfigModified()
enableFilters(true) if (status & statusURLChanged) != 0 {
if fj.Data.Enabled {
// download new filter and apply its rules
refreshStatus = 1
refreshLock.Lock()
_, _ = refreshFiltersIfNecessary(true)
refreshLock.Unlock()
}
} else if (status & statusEnabledChanged) != 0 {
enableFilters(true)
}
} }
func handleFilteringSetRules(w http.ResponseWriter, r *http.Request) { func handleFilteringSetRules(w http.ResponseWriter, r *http.Request) {

View File

@ -68,45 +68,80 @@ func userFilter() filter {
return f return f
} }
// Enable or disable a filter const (
func filterEnable(url string, enable bool) bool { statusFound = 1
r := false statusEnabledChanged = 2
statusURLChanged = 4
statusURLExists = 8
)
// Update properties for a filter specified by its URL
// Return status* flags.
func filterSetProperties(url string, newf filter) int {
r := 0
config.Lock() config.Lock()
defer config.Unlock()
for i := range config.Filters { for i := range config.Filters {
filter := &config.Filters[i] // otherwise we will be operating on a copy f := &config.Filters[i]
if filter.URL == url { if f.URL != url {
filter.Enabled = enable continue
if enable { }
e := filter.load()
if e != nil { log.Debug("filter: set properties: %s: {%s %s %v}",
// This isn't a fatal error, f.URL, newf.Name, newf.URL, newf.Enabled)
// because it may occur when someone removes the file from disk. f.Name = newf.Name
// In this case the periodic update task will try to download the file.
filter.LastUpdated = time.Time{} if f.URL != newf.URL {
log.Tracef("%s filter load: %v", url, e) r |= statusURLChanged
if filterExistsNoLock(newf.URL) {
return statusURLExists
}
f.URL = newf.URL
f.unload()
f.LastUpdated = time.Time{}
}
if f.Enabled != newf.Enabled {
r |= statusEnabledChanged
f.Enabled = newf.Enabled
if f.Enabled {
if (r & statusURLChanged) == 0 {
e := f.load()
if e != nil {
// This isn't a fatal error,
// because it may occur when someone removes the file from disk.
// In this case the periodic update task will try to download the file.
f.LastUpdated = time.Time{}
}
} }
} else { } else {
filter.unload() f.unload()
} }
r = true
break
} }
return r | statusFound
} }
config.Unlock() return 0
return r
} }
// Return TRUE if a filter with this URL exists // Return TRUE if a filter with this URL exists
func filterExists(url string) bool { func filterExists(url string) bool {
r := false
config.RLock() config.RLock()
r := filterExistsNoLock(url)
config.RUnlock()
return r
}
// Return TRUE if a filter with this URL exists
func filterExistsNoLock(url string) bool {
r := false
for i := range config.Filters { for i := range config.Filters {
if config.Filters[i].URL == url { if config.Filters[i].URL == url {
r = true r = true
break break
} }
} }
config.RUnlock()
return r return r
} }