1
0
mirror of https://github.com/Radarr/Radarr.git synced 2024-08-18 08:19:38 +02:00

New: Dynamic Select and UMask Fields

Fixes #5380
Fixes #5348
Fixes #5167
Fixes #5166

Co-Authored-By: Taloth <Taloth@users.noreply.github.com>
This commit is contained in:
Qstick 2020-11-20 23:33:10 -05:00
parent 73ce77f1ca
commit 9c77399379
50 changed files with 1244 additions and 212 deletions

View File

@ -2,34 +2,22 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { clearOptions, fetchOptions } from 'Store/Actions/providerOptionActions'; import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput'; import DeviceInput from './DeviceInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state, { value }) => value, (state, { value }) => value,
(state, { name }) => name, (state) => state.providerOptions.devices || defaultState,
(state) => state.providerOptions, (value, devices) => {
(value, name, devices) => {
const {
isFetching,
isPopulated,
error,
items
} = devices;
return { return {
isFetching, ...devices,
isPopulated,
error,
items: items[name] || [],
selectedDevices: value.map((valueDevice) => { selectedDevices: value.map((valueDevice) => {
const sectionItems = items[name] || [];
// Disable equality ESLint rule so we don't need to worry about // Disable equality ESLint rule so we don't need to worry about
// a type mismatch between the value items and the device ID. // a type mismatch between the value items and the device ID.
// eslint-disable-next-line eqeqeq // eslint-disable-next-line eqeqeq
const device = sectionItems.find((d) => d.id == valueDevice); const device = devices.items.find((d) => d.id == valueDevice);
if (device) { if (device) {
return { return {
@ -63,7 +51,7 @@ class DeviceInputConnector extends Component {
} }
componentWillUnmount = () => { componentWillUnmount = () => {
this.props.dispatchClearOptions(); this.props.dispatchClearOptions({ section: 'devices' });
} }
// //
@ -73,14 +61,12 @@ class DeviceInputConnector extends Component {
const { const {
provider, provider,
providerData, providerData,
dispatchFetchOptions, dispatchFetchOptions
requestAction,
name
} = this.props; } = this.props;
dispatchFetchOptions({ dispatchFetchOptions({
action: requestAction, section: 'devices',
itemSection: name, action: 'getDevices',
provider, provider,
providerData providerData
}); });
@ -109,7 +95,6 @@ class DeviceInputConnector extends Component {
DeviceInputConnector.propTypes = { DeviceInputConnector.propTypes = {
provider: PropTypes.string.isRequired, provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired, providerData: PropTypes.object.isRequired,
requestAction: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired, dispatchFetchOptions: PropTypes.func.isRequired,

View File

@ -5,6 +5,10 @@
align-items: center; align-items: center;
} }
.editableContainer {
width: 100%;
}
.hasError { .hasError {
composes: hasError from '~Components/Form/Input.css'; composes: hasError from '~Components/Form/Input.css';
} }
@ -22,6 +26,16 @@
margin-left: 12px; margin-left: 12px;
} }
.dropdownArrowContainerEditable {
position: absolute;
top: 0;
right: 0;
padding-right: 17px;
width: 30%;
height: 35px;
text-align: right;
}
.dropdownArrowContainerDisabled { .dropdownArrowContainerDisabled {
composes: dropdownArrowContainer; composes: dropdownArrowContainer;
@ -66,3 +80,8 @@
border-radius: 4px; border-radius: 4px;
background-color: $white; background-color: $white;
} }
.loading {
display: inline-block;
margin: 5px -5px 5px 0;
}

View File

@ -5,6 +5,7 @@ import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper'; import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Measure from 'Components/Measure'; import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
@ -16,6 +17,7 @@ import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { isMobile as isMobileUtil } from 'Utilities/mobile'; import { isMobile as isMobileUtil } from 'Utilities/mobile';
import HintedSelectInputOption from './HintedSelectInputOption'; import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css'; import styles from './EnhancedSelectInput.css';
function isArrowKey(keyCode) { function isArrowKey(keyCode) {
@ -168,11 +170,21 @@ class EnhancedSelectInput extends Component {
} }
} }
onFocus = () => {
if (this.state.isOpen) {
this._removeListener();
this.setState({ isOpen: false });
}
}
onBlur = () => { onBlur = () => {
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) if (!this.props.isEditable) {
const origIndex = getSelectedIndex(this.props); // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
if (origIndex !== this.state.selectedIndex) { const origIndex = getSelectedIndex(this.props);
this.setState({ selectedIndex: origIndex });
if (origIndex !== this.state.selectedIndex) {
this.setState({ selectedIndex: origIndex });
}
} }
} }
@ -250,6 +262,10 @@ class EnhancedSelectInput extends Component {
this._addListener(); this._addListener();
} }
if (!this.state.isOpen && this.props.onOpen) {
this.props.onOpen();
}
this.setState({ isOpen: !this.state.isOpen }); this.setState({ isOpen: !this.state.isOpen });
} }
@ -292,15 +308,19 @@ class EnhancedSelectInput extends Component {
const { const {
className, className,
disabledClassName, disabledClassName,
name,
value, value,
values, values,
isDisabled, isDisabled,
isEditable,
isFetching,
hasError, hasError,
hasWarning, hasWarning,
valueOptions, valueOptions,
selectedValueOptions, selectedValueOptions,
selectedValueComponent: SelectedValueComponent, selectedValueComponent: SelectedValueComponent,
optionComponent: OptionComponent optionComponent: OptionComponent,
onChange
} = this.props; } = this.props;
const { const {
@ -326,40 +346,94 @@ class EnhancedSelectInput extends Component {
whitelist={['width']} whitelist={['width']}
onMeasure={this.onMeasure} onMeasure={this.onMeasure}
> >
<Link {
className={classNames( isEditable ?
className, <div
hasError && styles.hasError, className={styles.editableContainer}
hasWarning && styles.hasWarning, >
isDisabled && disabledClassName <TextInput
)} className={className}
isDisabled={isDisabled} name={name}
onBlur={this.onBlur} value={value}
onKeyDown={this.onKeyDown} readOnly={isDisabled}
onPress={this.onPress} hasError={hasError}
> hasWarning={hasWarning}
<SelectedValueComponent onFocus={this.onFocus}
value={value} onBlur={this.onBlur}
values={values} onChange={onChange}
{...selectedValueOptions} />
{...selectedOption} <Link
isDisabled={isDisabled} className={classNames(
isMultiSelect={isMultiSelect} styles.dropdownArrowContainerEditable,
> isDisabled ?
{selectedOption ? selectedOption.value : null} styles.dropdownArrowContainerDisabled :
</SelectedValueComponent> styles.dropdownArrowContainer)
}
onPress={this.onPress}
>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
<div {
className={isDisabled ? !isFetching &&
styles.dropdownArrowContainerDisabled : <Icon
styles.dropdownArrowContainer name={icons.CARET_DOWN}
} />
> }
<Icon </Link>
name={icons.CARET_DOWN} </div> :
/> <Link
</div> className={classNames(
</Link> className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
value={value}
values={values}
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</div>
</Link>
}
</Measure> </Measure>
</div> </div>
)} )}
@ -483,12 +557,15 @@ EnhancedSelectInput.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
hasError: PropTypes.bool, hasError: PropTypes.bool,
hasWarning: PropTypes.bool, hasWarning: PropTypes.bool,
valueOptions: PropTypes.object.isRequired, valueOptions: PropTypes.object.isRequired,
selectedValueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired,
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
optionComponent: PropTypes.elementType, optionComponent: PropTypes.elementType,
onOpen: PropTypes.func,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };
@ -496,6 +573,8 @@ EnhancedSelectInput.defaultProps = {
className: styles.enhancedSelect, className: styles.enhancedSelect,
disabledClassName: styles.isDisabled, disabledClassName: styles.isDisabled,
isDisabled: false, isDisabled: false,
isFetching: false,
isEditable: false,
valueOptions: {}, valueOptions: {},
selectedValueOptions: {}, selectedValueOptions: {},
selectedValueComponent: HintedSelectInputSelectedValue, selectedValueComponent: HintedSelectInputSelectedValue,

View File

@ -0,0 +1,159 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import EnhancedSelectInput from './EnhancedSelectInput';
const importantFieldNames = [
'baseUrl',
'apiPath',
'apiKey'
];
function getProviderDataKey(providerData) {
if (!providerData || !providerData.fields) {
return null;
}
const fields = providerData.fields
.filter((f) => importantFieldNames.includes(f.name))
.map((f) => f.value);
return fields;
}
function getSelectOptions(items) {
if (!items) {
return [];
}
return items.map((option) => {
return {
key: option.value,
value: option.name,
hint: option.hint,
parentKey: option.parentValue
};
});
}
function createMapStateToProps() {
return createSelector(
(state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState,
(options) => {
if (options) {
return {
isFetching: options.isFetching,
values: getSelectOptions(options.items)
};
}
}
);
}
const mapDispatchToProps = {
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class EnhancedSelectInputConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
refetchRequired: false
};
}
componentDidMount = () => {
this._populate();
}
componentDidUpdate = (prevProps) => {
const prevKey = getProviderDataKey(prevProps.providerData);
const nextKey = getProviderDataKey(this.props.providerData);
if (!_.isEqual(prevKey, nextKey)) {
this.setState({ refetchRequired: true });
}
}
componentWillUnmount = () => {
this._cleanup();
}
//
// Listeners
onOpen = () => {
if (this.state.refetchRequired) {
this._populate();
}
}
//
// Control
_populate() {
const {
provider,
providerData,
selectOptionsProviderAction,
dispatchFetchOptions
} = this.props;
if (selectOptionsProviderAction) {
this.setState({ refetchRequired: false });
dispatchFetchOptions({
section: selectOptionsProviderAction,
action: selectOptionsProviderAction,
provider,
providerData
});
}
}
_cleanup() {
const {
selectOptionsProviderAction,
dispatchClearOptions
} = this.props;
if (selectOptionsProviderAction) {
dispatchClearOptions({ section: selectOptionsProviderAction });
}
}
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onOpen={this.onOpen}
/>
);
}
}
EnhancedSelectInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired,
isFetching: PropTypes.bool.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired,
dispatchClearOptions: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector);

View File

@ -32,6 +32,7 @@ class EnhancedSelectInputOption extends Component {
const { const {
className, className,
id, id,
depth,
isSelected, isSelected,
isDisabled, isDisabled,
isHidden, isHidden,
@ -54,6 +55,11 @@ class EnhancedSelectInputOption extends Component {
onPress={this.onPress} onPress={this.onPress}
> >
{
depth !== 0 &&
<div style={{ width: `${depth * 20}px` }} />
}
{ {
isMultiSelect && isMultiSelect &&
<CheckInput <CheckInput
@ -84,6 +90,7 @@ class EnhancedSelectInputOption extends Component {
EnhancedSelectInputOption.propTypes = { EnhancedSelectInputOption.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
depth: PropTypes.number.isRequired,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isHidden: PropTypes.bool.isRequired, isHidden: PropTypes.bool.isRequired,
@ -95,6 +102,7 @@ EnhancedSelectInputOption.propTypes = {
EnhancedSelectInputOption.defaultProps = { EnhancedSelectInputOption.defaultProps = {
className: styles.option, className: styles.option,
depth: 0,
isDisabled: false, isDisabled: false,
isHidden: false, isHidden: false,
isMultiSelect: false isMultiSelect: false

View File

@ -9,6 +9,7 @@ import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput'; import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector'; import DeviceInputConnector from './DeviceInputConnector';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import FormInputHelpText from './FormInputHelpText'; import FormInputHelpText from './FormInputHelpText';
import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector'; import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector';
import KeyValueListInput from './KeyValueListInput'; import KeyValueListInput from './KeyValueListInput';
@ -24,6 +25,7 @@ import TagSelectInputConnector from './TagSelectInputConnector';
import TextArea from './TextArea'; import TextArea from './TextArea';
import TextInput from './TextInput'; import TextInput from './TextInput';
import TextTagInputConnector from './TextTagInputConnector'; import TextTagInputConnector from './TextTagInputConnector';
import UMaskInput from './UMaskInput';
import styles from './FormInputGroup.css'; import styles from './FormInputGroup.css';
function getComponent(type) { function getComponent(type) {
@ -73,6 +75,8 @@ function getComponent(type) {
case inputTypes.SELECT: case inputTypes.SELECT:
return EnhancedSelectInput; return EnhancedSelectInput;
case inputTypes.DYNAMIC_SELECT:
return EnhancedSelectInputConnector;
case inputTypes.TAG: case inputTypes.TAG:
return TagInputConnector; return TagInputConnector;
@ -85,6 +89,9 @@ function getComponent(type) {
case inputTypes.TAG_SELECT: case inputTypes.TAG_SELECT:
return TagSelectInputConnector; return TagSelectInputConnector;
case inputTypes.UMASK:
return UMaskInput;
default: default:
return TextInput; return TextInput;
} }
@ -192,7 +199,7 @@ function FormInputGroup(props) {
} }
{ {
!checkInput && helpTextWarning && (!checkInput || helpText) && helpTextWarning &&
<FormInputHelpText <FormInputHelpText
text={helpTextWarning} text={helpTextWarning}
isWarning={true} isWarning={true}

View File

@ -9,6 +9,7 @@ function HintedSelectInputOption(props) {
id, id,
value, value,
hint, hint,
depth,
isSelected, isSelected,
isDisabled, isDisabled,
isMultiSelect, isMultiSelect,
@ -19,6 +20,7 @@ function HintedSelectInputOption(props) {
return ( return (
<EnhancedSelectInputOption <EnhancedSelectInputOption
id={id} id={id}
depth={depth}
isSelected={isSelected} isSelected={isSelected}
isDisabled={isDisabled} isDisabled={isDisabled}
isHidden={isDisabled} isHidden={isDisabled}
@ -48,6 +50,7 @@ HintedSelectInputOption.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
hint: PropTypes.node, hint: PropTypes.node,
depth: PropTypes.number,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isMultiSelect: PropTypes.bool.isRequired, isMultiSelect: PropTypes.bool.isRequired,

View File

@ -98,7 +98,9 @@ class KeyValueListInput extends Component {
className, className,
value, value,
keyPlaceholder, keyPlaceholder,
valuePlaceholder valuePlaceholder,
hasError,
hasWarning
} = this.props; } = this.props;
const { isFocused } = this.state; const { isFocused } = this.state;
@ -106,7 +108,9 @@ class KeyValueListInput extends Component {
return ( return (
<div className={classNames( <div className={classNames(
className, className,
isFocused && styles.isFocused isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)} )}
> >
{ {

View File

@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
function getType(type, value) { function getType({ type, selectOptionsProviderAction }) {
switch (type) { switch (type) {
case 'captcha': case 'captcha':
return inputTypes.CAPTCHA; return inputTypes.CAPTCHA;
@ -23,6 +23,9 @@ function getType(type, value) {
case 'filePath': case 'filePath':
return inputTypes.PATH; return inputTypes.PATH;
case 'select': case 'select':
if (selectOptionsProviderAction) {
return inputTypes.DYNAMIC_SELECT;
}
return inputTypes.SELECT; return inputTypes.SELECT;
case 'tag': case 'tag':
return inputTypes.TEXT_TAG; return inputTypes.TEXT_TAG;
@ -63,7 +66,6 @@ function ProviderFieldFormGroup(props) {
value, value,
type, type,
advanced, advanced,
requestAction,
hidden, hidden,
pending, pending,
errors, errors,
@ -88,7 +90,7 @@ function ProviderFieldFormGroup(props) {
<FormLabel>{label}</FormLabel> <FormLabel>{label}</FormLabel>
<FormInputGroup <FormInputGroup
type={getType(type, value)} type={getType(props)}
name={name} name={name}
label={label} label={label}
helpText={helpText} helpText={helpText}
@ -100,7 +102,6 @@ function ProviderFieldFormGroup(props) {
pending={pending} pending={pending}
includeFiles={type === 'filePath' ? true : undefined} includeFiles={type === 'filePath' ? true : undefined}
onChange={onChange} onChange={onChange}
requestAction={requestAction}
{...otherProps} {...otherProps}
/> />
</FormGroup> </FormGroup>
@ -109,7 +110,8 @@ function ProviderFieldFormGroup(props) {
const selectOptionsShape = { const selectOptionsShape = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
value: PropTypes.number.isRequired value: PropTypes.number.isRequired,
hint: PropTypes.string
}; };
ProviderFieldFormGroup.propTypes = { ProviderFieldFormGroup.propTypes = {
@ -121,12 +123,12 @@ ProviderFieldFormGroup.propTypes = {
value: PropTypes.any, value: PropTypes.any,
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired, advanced: PropTypes.bool.isRequired,
requestAction: PropTypes.string,
hidden: PropTypes.string, hidden: PropTypes.string,
pending: PropTypes.bool.isRequired, pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired, errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired, warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)), selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };

View File

@ -0,0 +1,53 @@
.inputWrapper {
display: flex;
}
.inputFolder {
composes: input from '~Components/Form/Input.css';
max-width: 100px;
}
.inputUnitWrapper {
position: relative;
width: 100%;
}
.inputUnit {
composes: inputUnit from '~Components/Form/FormInputGroup.css';
right: 40px;
font-family: $monoSpaceFontFamily;
}
.unit {
font-family: $monoSpaceFontFamily;
}
.details {
margin-top: 5px;
margin-left: 17px;
line-height: 20px;
> div {
display: flex;
label {
flex: 0 0 50px;
}
.value {
width: 50px;
text-align: right;
}
.unit {
width: 90px;
text-align: right;
}
}
}
.readOnly {
background-color: #eee;
}

View File

@ -0,0 +1,133 @@
/* eslint-disable no-bitwise */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import EnhancedSelectInput from './EnhancedSelectInput';
import styles from './UMaskInput.css';
const umaskOptions = [
{
key: '755',
value: '755 - Owner write, Everyone else read',
hint: 'drwxr-xr-x'
},
{
key: '775',
value: '775 - Owner & Group write, Other read',
hint: 'drwxrwxr-x'
},
{
key: '770',
value: '770 - Owner & Group write',
hint: 'drwxrwx---'
},
{
key: '750',
value: '750 - Owner write, Group read',
hint: 'drwxr-x---'
},
{
key: '777',
value: '777 - Everyone write',
hint: 'drwxrwxrwx'
}
];
function formatPermissions(permissions) {
const hasSticky = permissions & 0o1000;
const hasSetGID = permissions & 0o2000;
const hasSetUID = permissions & 0o4000;
let result = '';
for (let i = 0; i < 9; i++) {
const bit = (permissions & (1 << i)) !== 0;
let digit = bit ? 'xwr'[i % 3] : '-';
if (i === 6 && hasSetUID) {
digit = bit ? 's' : 'S';
} else if (i === 3 && hasSetGID) {
digit = bit ? 's' : 'S';
} else if (i === 0 && hasSticky) {
digit = bit ? 't' : 'T';
}
result = digit + result;
}
return result;
}
class UMaskInput extends Component {
//
// Render
render() {
const {
name,
value,
onChange
} = this.props;
const valueNum = parseInt(value, 8);
const umaskNum = 0o777 & ~valueNum;
const umask = umaskNum.toString(8).padStart(4, '0');
const folderNum = 0o777 & ~umaskNum;
const folder = folderNum.toString(8).padStart(3, '0');
const fileNum = 0o666 & ~umaskNum;
const file = fileNum.toString(8).padStart(3, '0');
const unit = formatPermissions(folderNum);
const values = umaskOptions.map((v) => {
return { ...v, hint: <span className={styles.unit}>{v.hint}</span> };
});
return (
<div>
<div className={styles.inputWrapper}>
<div className={styles.inputUnitWrapper}>
<EnhancedSelectInput
name={name}
value={value}
values={values}
isEditable={true}
onChange={onChange}
/>
<div className={styles.inputUnit}>
d{unit}
</div>
</div>
</div>
<div className={styles.details}>
<div>
<label>UMask</label>
<div className={styles.value}>{umask}</div>
</div>
<div>
<label>Folder</label>
<div className={styles.value}>{folder}</div>
<div className={styles.unit}>d{formatPermissions(folderNum)}</div>
</div>
<div>
<label>File</label>
<div className={styles.value}>{file}</div>
<div className={styles.unit}>{formatPermissions(fileNum)}</div>
</div>
</div>
</div>
);
}
}
UMaskInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func
};
export default UMaskInput;

View File

@ -12,11 +12,13 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect';
export const SELECT = 'select'; export const SELECT = 'select';
export const DYNAMIC_SELECT = 'dynamicSelect';
export const TAG = 'tag'; export const TAG = 'tag';
export const TEXT = 'text'; export const TEXT = 'text';
export const TEXT_AREA = 'textArea'; export const TEXT_AREA = 'textArea';
export const TEXT_TAG = 'textTag'; export const TEXT_TAG = 'textTag';
export const TAG_SELECT = 'tagSelect'; export const TAG_SELECT = 'tagSelect';
export const UMASK = 'umask';
export const all = [ export const all = [
AUTO_COMPLETE, AUTO_COMPLETE,
@ -33,9 +35,11 @@ export const all = [
ROOT_FOLDER_SELECT, ROOT_FOLDER_SELECT,
INDEXER_FLAGS_SELECT, INDEXER_FLAGS_SELECT,
SELECT, SELECT,
DYNAMIC_SELECT,
TAG, TAG,
TEXT, TEXT,
TEXT_AREA, TEXT_AREA,
TEXT_TAG, TEXT_TAG,
TAG_SELECT TAG_SELECT,
UMASK
]; ];

View File

@ -364,17 +364,32 @@ class MediaManagement extends Component {
advancedSettings={advancedSettings} advancedSettings={advancedSettings}
isAdvanced={true} isAdvanced={true}
> >
<FormLabel>{translate('FileChmodMode')}</FormLabel> <FormLabel>{translate('ChmodFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.UMASK}
name="chmodFolder"
helpText={translate('ChmodFolderHelpText')}
helpTextWarning={translate('ChmodFolderHelpTextWarning')}
onChange={onInputChange}
{...settings.chmodFolder}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('ChmodGroup')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.TEXT} type={inputTypes.TEXT}
name="fileChmod" name="chownGroup"
helpTexts={[ helpText={translate('ChmodGroupHelpText')}
translate('FileChmodHelpTexts1'), helpTextWarning={translate('ChmodGroupHelpTextWarning')}
translate('FileChmodHelpTexts2') values={fileDateOptions}
]}
onChange={onInputChange} onChange={onInputChange}
{...settings.fileChmod} {...settings.chownGroup}
/> />
</FormGroup> </FormGroup>
</FieldSet> </FieldSet>

View File

@ -1,3 +1,4 @@
import _ from 'lodash';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import requestAction from 'Utilities/requestAction'; import requestAction from 'Utilities/requestAction';
@ -10,11 +11,14 @@ import createHandleActions from './Creators/createHandleActions';
export const section = 'providerOptions'; export const section = 'providerOptions';
const lastActions = {};
let lastActionId = 0;
// //
// State // State
export const defaultState = { export const defaultState = {
items: {}, items: [],
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
error: false error: false
@ -23,8 +27,8 @@ export const defaultState = {
// //
// Actions Types // Actions Types
export const FETCH_OPTIONS = 'devices/fetchOptions'; export const FETCH_OPTIONS = 'providers/fetchOptions';
export const CLEAR_OPTIONS = 'devices/clearOptions'; export const CLEAR_OPTIONS = 'providers/clearOptions';
// //
// Action Creators // Action Creators
@ -38,35 +42,55 @@ export const clearOptions = createAction(CLEAR_OPTIONS);
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
[FETCH_OPTIONS]: function(getState, payload, dispatch) { [FETCH_OPTIONS]: function(getState, payload, dispatch) {
const subsection = `${section}.${payload.section}`;
if (lastActions[payload.section] && _.isEqual(payload, lastActions[payload.section].payload)) {
return;
}
const actionId = ++lastActionId;
lastActions[payload.section] = {
actionId,
payload
};
dispatch(set({ dispatch(set({
section, section: subsection,
isFetching: true isFetching: true
})); }));
const oldItems = getState().providerOptions.items;
const itemSection = payload.itemSection;
const promise = requestAction(payload); const promise = requestAction(payload);
promise.done((data) => { promise.done((data) => {
oldItems[itemSection] = data.options || []; if (lastActions[payload.section]) {
if (lastActions[payload.section].actionId === actionId) {
lastActions[payload.section] = null;
}
dispatch(set({ dispatch(set({
section, section: subsection,
isFetching: false, isFetching: false,
isPopulated: true, isPopulated: true,
error: null, error: null,
items: oldItems items: data.options || []
})); }));
}
}); });
promise.fail((xhr) => { promise.fail((xhr) => {
dispatch(set({ if (lastActions[payload.section]) {
section, if (lastActions[payload.section].actionId === actionId) {
isFetching: false, lastActions[payload.section] = null;
isPopulated: false, }
error: xhr
})); dispatch(set({
section: subsection,
isFetching: false,
isPopulated: false,
error: xhr
}));
}
}); });
} }
}); });
@ -76,8 +100,12 @@ export const actionHandlers = handleThunks({
export const reducers = createHandleActions({ export const reducers = createHandleActions({
[CLEAR_OPTIONS]: function(state) { [CLEAR_OPTIONS]: function(state, { payload }) {
return updateSectionState(state, section, defaultState); const subsection = `${section}.${payload.section}`;
lastActions[payload.section] = null;
return updateSectionState(state, subsection, defaultState);
} }
}, defaultState, section); }, {}, section);

View File

@ -8,10 +8,10 @@ namespace NzbDrone.Api.Config
{ {
public class MediaManagementConfigModule : NzbDroneConfigModule<MediaManagementConfigResource> public class MediaManagementConfigModule : NzbDroneConfigModule<MediaManagementConfigResource>
{ {
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator)
: base(configService) : base(configService)
{ {
SharedValidator.RuleFor(c => c.FileChmod).SetValidator(fileChmodValidator).When(c => !string.IsNullOrEmpty(c.FileChmod) && (OsInfo.IsLinux || OsInfo.IsOsx)); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && PlatformInfo.IsMono);
SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin)); SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
} }

View File

@ -16,7 +16,8 @@ public class MediaManagementConfigResource : RestResource
public bool PathsDefaultStatic { get; set; } public bool PathsDefaultStatic { get; set; }
public bool SetPermissionsLinux { get; set; } public bool SetPermissionsLinux { get; set; }
public string FileChmod { get; set; } public string ChmodFolder { get; set; }
public string ChownGroup { get; set; }
public bool SkipFreeSpaceCheckWhenImporting { get; set; } public bool SkipFreeSpaceCheckWhenImporting { get; set; }
public bool CopyUsingHardlinks { get; set; } public bool CopyUsingHardlinks { get; set; }
@ -39,7 +40,8 @@ public static MediaManagementConfigResource ToResource(IConfigService model)
AutoRenameFolders = model.AutoRenameFolders, AutoRenameFolders = model.AutoRenameFolders,
SetPermissionsLinux = model.SetPermissionsLinux, SetPermissionsLinux = model.SetPermissionsLinux,
FileChmod = model.FileChmod, ChmodFolder = model.ChmodFolder,
ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
CopyUsingHardlinks = model.CopyUsingHardlinks, CopyUsingHardlinks = model.CopyUsingHardlinks,

View File

@ -30,7 +30,7 @@ public static StringComparison PathStringComparison
public abstract long? GetAvailableSpace(string path); public abstract long? GetAvailableSpace(string path);
public abstract void InheritFolderPermissions(string filename); public abstract void InheritFolderPermissions(string filename);
public abstract void SetEveryonePermissions(string filename); public abstract void SetEveryonePermissions(string filename);
public abstract void SetPermissions(string path, string mask); public abstract void SetPermissions(string path, string mask, string group);
public abstract void CopyPermissions(string sourcePath, string targetPath); public abstract void CopyPermissions(string sourcePath, string targetPath);
public abstract long? GetTotalSize(string path); public abstract long? GetTotalSize(string path);
@ -539,7 +539,7 @@ public void SaveStream(Stream stream, string path)
} }
} }
public virtual bool IsValidFilePermissionMask(string mask) public virtual bool IsValidFolderPermissionMask(string mask)
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }

View File

@ -11,7 +11,7 @@ public interface IDiskProvider
long? GetAvailableSpace(string path); long? GetAvailableSpace(string path);
void InheritFolderPermissions(string filename); void InheritFolderPermissions(string filename);
void SetEveryonePermissions(string filename); void SetEveryonePermissions(string filename);
void SetPermissions(string path, string mask); void SetPermissions(string path, string mask, string group);
void CopyPermissions(string sourcePath, string targetPath); void CopyPermissions(string sourcePath, string targetPath);
long? GetTotalSize(string path); long? GetTotalSize(string path);
DateTime FolderGetCreationTime(string path); DateTime FolderGetCreationTime(string path);
@ -56,6 +56,6 @@ public interface IDiskProvider
List<FileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly); List<FileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly);
void RemoveEmptySubfolders(string path); void RemoveEmptySubfolders(string path);
void SaveStream(Stream stream, string path); void SaveStream(Stream stream, string path);
bool IsValidFilePermissionMask(string mask); bool IsValidFolderPermissionMask(string mask);
} }
} }

View File

@ -19,10 +19,10 @@ public FieldDefinitionAttribute(int order)
public FieldType Type { get; set; } public FieldType Type { get; set; }
public bool Advanced { get; set; } public bool Advanced { get; set; }
public Type SelectOptions { get; set; } public Type SelectOptions { get; set; }
public string SelectOptionsProviderAction { get; set; }
public string Section { get; set; } public string Section { get; set; }
public HiddenType Hidden { get; set; } public HiddenType Hidden { get; set; }
public PrivacyLevel Privacy { get; set; } public PrivacyLevel Privacy { get; set; }
public string RequestAction { get; set; }
} }
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
@ -39,6 +39,15 @@ public FieldOptionAttribute(string label = null, [CallerLineNumber] int order =
public string Hint { get; set; } public string Hint { get; set; }
} }
public class FieldSelectOption
{
public int Value { get; set; }
public string Name { get; set; }
public int Order { get; set; }
public string Hint { get; set; }
public int? ParentValue { get; set; }
}
public enum FieldType public enum FieldType
{ {
Textbox, Textbox,

View File

@ -317,11 +317,18 @@ public bool SetPermissionsLinux
set { SetValue("SetPermissionsLinux", value); } set { SetValue("SetPermissionsLinux", value); }
} }
public string FileChmod public string ChmodFolder
{ {
get { return GetValue("FileChmod", "0644"); } get { return GetValue("ChmodFolder", "755"); }
set { SetValue("FileChmod", value); } set { SetValue("ChmodFolder", value); }
}
public string ChownGroup
{
get { return GetValue("ChownGroup", ""); }
set { SetValue("ChownGroup", value); }
} }
public int FirstDayOfWeek public int FirstDayOfWeek

View File

@ -45,7 +45,8 @@ public interface IConfigService
//Permissions (Media Management) //Permissions (Media Management)
bool SetPermissionsLinux { get; set; } bool SetPermissionsLinux { get; set; }
string FileChmod { get; set; } string ChmodFolder { get; set; }
string ChownGroup { get; set; }
//Indexers //Indexers
int Retention { get; set; } int Retention { get; set; }

View File

@ -8,7 +8,7 @@ public class remove_chown_and_folderchmod_config : NzbDroneMigrationBase
{ {
protected override void MainDbUpgrade() protected override void MainDbUpgrade()
{ {
Execute.Sql("DELETE FROM config WHERE Key IN ('folderchmod', 'chownuser', 'chowngroup', 'parsingleniency')"); Execute.Sql("DELETE FROM config WHERE Key IN ('folderchmod', 'chownuser', 'parsingleniency')");
} }
} }
} }

View File

@ -0,0 +1,56 @@
using System;
using System.Data;
using FluentMigrator;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(187)]
public class swap_filechmod_for_folderchmod : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
// Reverts part of migration 140, note that the v1 of migration140 also removed chowngroup
Execute.WithConnection(ConvertFileChmodToFolderChmod);
}
private void ConvertFileChmodToFolderChmod(IDbConnection conn, IDbTransaction tran)
{
using (IDbCommand getFileChmodCmd = conn.CreateCommand())
{
getFileChmodCmd.Transaction = tran;
getFileChmodCmd.CommandText = @"SELECT Value FROM Config WHERE Key = 'filechmod'";
var fileChmod = getFileChmodCmd.ExecuteScalar() as string;
if (fileChmod != null)
{
if (fileChmod.IsNotNullOrWhiteSpace())
{
// Convert without using mono libraries. We take the 'r' bits and shifting them to the 'x' position, preserving everything else.
var fileChmodNum = Convert.ToInt32(fileChmod, 8);
var folderChmodNum = fileChmodNum | ((fileChmodNum & 0x124) >> 2);
var folderChmod = Convert.ToString(folderChmodNum, 8).PadLeft(3, '0');
using (IDbCommand insertCmd = conn.CreateCommand())
{
insertCmd.Transaction = tran;
insertCmd.CommandText = "INSERT INTO Config (Key, Value) VALUES ('chmodfolder', ?)";
insertCmd.AddParameter(folderChmod);
insertCmd.ExecuteNonQuery();
}
}
using (IDbCommand deleteCmd = conn.CreateCommand())
{
deleteCmd.Transaction = tran;
deleteCmd.CommandText = "DELETE FROM Config WHERE Key = 'filechmod'";
deleteCmd.ExecuteNonQuery();
}
}
}
}
}
}

View File

@ -91,8 +91,8 @@ public override object RequestAction(string action, IDictionary<string, string>
options = devices.OrderBy(d => d.Name, StringComparer.InvariantCultureIgnoreCase) options = devices.OrderBy(d => d.Name, StringComparer.InvariantCultureIgnoreCase)
.Select(d => new .Select(d => new
{ {
id = d.Id, Value = d.Id,
name = d.Name Name = d.Name
}) })
}; };
} }
@ -106,8 +106,8 @@ public override object RequestAction(string action, IDictionary<string, string>
options = devices.OrderBy(d => d.Label, StringComparer.InvariantCultureIgnoreCase) options = devices.OrderBy(d => d.Label, StringComparer.InvariantCultureIgnoreCase)
.Select(d => new .Select(d => new
{ {
id = d.Id, Value = d.Id,
name = d.Label Name = d.Label
}) })
}; };
} }

View File

@ -34,10 +34,10 @@ public RadarrSettings()
[FieldDefinition(1, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "Apikey of the Radarr V3 instance to import from")] [FieldDefinition(1, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "Apikey of the Radarr V3 instance to import from")]
public string ApiKey { get; set; } public string ApiKey { get; set; }
[FieldDefinition(2, Type = FieldType.Device, RequestAction = "getProfiles", Label = "Profiles", HelpText = "Profiles from the source instance to import from")] [FieldDefinition(2, Type = FieldType.Select, SelectOptionsProviderAction = "getProfiles", Label = "Profiles", HelpText = "Profiles from the source instance to import from")]
public IEnumerable<int> ProfileIds { get; set; } public IEnumerable<int> ProfileIds { get; set; }
[FieldDefinition(3, Type = FieldType.Device, RequestAction = "getTags", Label = "Tags", HelpText = "Tags from the source instance to import from")] [FieldDefinition(3, Type = FieldType.Select, SelectOptionsProviderAction = "getTags", Label = "Tags", HelpText = "Tags from the source instance to import from")]
public IEnumerable<int> TagIds { get; set; } public IEnumerable<int> TagIds { get; set; }
public NzbDroneValidationResult Validate() public NzbDroneValidationResult Validate()

View File

@ -321,7 +321,14 @@ protected virtual ValidationFailure TestConnection()
_indexerStatusService.UpdateCookies(Definition.Id, cookies, expiration); _indexerStatusService.UpdateCookies(Definition.Id, cookies, expiration);
}; };
var generator = GetRequestGenerator(); var generator = GetRequestGenerator();
var releases = FetchPage(generator.GetRecentRequests().GetAllTiers().First().First(), parser); var firstRequest = generator.GetRecentRequests().GetAllTiers().FirstOrDefault()?.FirstOrDefault();
if (firstRequest == null)
{
return new ValidationFailure(string.Empty, "No rss feed query available. This may be an issue with the indexer or your indexer category settings.");
}
var releases = FetchPage(firstRequest, parser);
if (releases.Empty()) if (releases.Empty())
{ {

View File

@ -159,5 +159,31 @@ protected virtual ValidationFailure TestCapabilities()
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
} }
} }
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "newznabCategories")
{
List<NewznabCategory> categories = null;
try
{
if (Settings.BaseUrl.IsNotNullOrWhiteSpace() && Settings.ApiPath.IsNotNullOrWhiteSpace())
{
categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
}
}
catch
{
// Use default categories
}
return new
{
options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
};
}
return base.RequestAction(action, query);
}
} }
} }

View File

@ -48,6 +48,7 @@ private NewznabCapabilities FetchCapabilities(NewznabSettings indexerSettings)
} }
var request = new HttpRequest(url, HttpAccept.Rss); var request = new HttpRequest(url, HttpAccept.Rss);
request.AllowAutoRedirect = true;
HttpResponse response; HttpResponse response;
@ -76,6 +77,7 @@ private NewznabCapabilities FetchCapabilities(NewznabSettings indexerSettings)
{ {
ex.WithData(response, 128 * 1024); ex.WithData(response, 128 * 1024);
_logger.Trace("Unexpected Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content); _logger.Trace("Unexpected Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content);
_logger.Error(ex, "Failed to determine newznab api capabilities for {0}, using the defaults instead till Radarr restarts", indexerSettings.BaseUrl);
} }
return capabilities; return capabilities;

View File

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.Indexers.Newznab
{
public static class NewznabCategoryFieldOptionsConverter
{
public static List<FieldSelectOption> GetFieldSelectOptions(List<NewznabCategory> categories)
{
// Categories not relevant for Radarr
var ignoreCategories = new[] { 1000, 3000, 4000, 6000, 7000 };
// And maybe relevant for specific users
var unimportantCategories = new[] { 0, 5000 };
var result = new List<FieldSelectOption>();
if (categories == null)
{
// Fetching categories failed, use default Newznab categories
categories = new List<NewznabCategory>();
categories.Add(new NewznabCategory
{
Id = 2000,
Name = "Movies",
Subcategories = new List<NewznabCategory>
{
new NewznabCategory { Id = 2010, Name = "Foreign" },
new NewznabCategory { Id = 2020, Name = "Other" },
new NewznabCategory { Id = 2030, Name = "SD" },
new NewznabCategory { Id = 2040, Name = "HD" },
new NewznabCategory { Id = 2050, Name = "BluRay" },
new NewznabCategory { Id = 2060, Name = "3D" }
}
});
}
foreach (var category in categories.Where(cat => !ignoreCategories.Contains(cat.Id)).OrderBy(cat => unimportantCategories.Contains(cat.Id)).ThenBy(cat => cat.Id))
{
result.Add(new FieldSelectOption
{
Value = category.Id,
Name = category.Name,
Hint = $"({category.Id})"
});
if (category.Subcategories != null)
{
foreach (var subcat in category.Subcategories.OrderBy(cat => cat.Id))
{
result.Add(new FieldSelectOption
{
Value = subcat.Id,
Name = subcat.Name,
Hint = $"({subcat.Id})",
ParentValue = category.Id
});
}
}
}
return result;
}
}
}

View File

@ -75,7 +75,7 @@ public NewznabSettings()
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey)] [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey)]
public string ApiKey { get; set; } public string ApiKey { get; set; }
[FieldDefinition(3, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable all categories", Advanced = true)] [FieldDefinition(3, Label = "Categories", Type = FieldType.Select, SelectOptionsProviderAction = "newznabCategories", HelpText = "Comma Separated list, leave blank to disable all categories", Advanced = true)]
public IEnumerable<int> Categories { get; set; } public IEnumerable<int> Categories { get; set; }
[FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)] [FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)]

View File

@ -148,5 +148,28 @@ protected virtual ValidationFailure TestCapabilities()
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
} }
} }
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "newznabCategories")
{
List<NewznabCategory> categories = null;
try
{
categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
}
catch
{
// Use default categories
}
return new
{
options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
};
}
return base.RequestAction(action, query);
}
} }
} }

View File

@ -88,6 +88,12 @@
"ChangeHasNotBeenSavedYet": "Change has not been saved yet", "ChangeHasNotBeenSavedYet": "Change has not been saved yet",
"CheckDownloadClientForDetails": "check download client for more details", "CheckDownloadClientForDetails": "check download client for more details",
"CheckForFinishedDownloadsInterval": "Check For Finished Downloads Interval", "CheckForFinishedDownloadsInterval": "Check For Finished Downloads Interval",
"ChmodFolder": "chmod Folder",
"ChmodFolderHelpText": "Octal, applied during import/rename to media folders and files (without execute bits)",
"ChmodFolderHelpTextWarning": "This only works if the user running Radarr is the owner of the file. It's better to ensure the download client sets the permissions properly.",
"ChmodGroup": "chmod Group",
"ChmodGroupHelpText": "Group name or gid. Use gid for remote file systems.",
"ChmodGroupHelpTextWarning": "This only works if the user running Radarr is the owner of the file. It's better to ensure the download client uses the same group as Radarr.",
"ChooseAnotherFolder": "Choose another Folder", "ChooseAnotherFolder": "Choose another Folder",
"CleanLibraryLevel": "Clean Library Level", "CleanLibraryLevel": "Clean Library Level",
"Clear": "Clear", "Clear": "Clear",
@ -248,9 +254,6 @@
"Failed": "Failed", "Failed": "Failed",
"FailedDownloadHandling": "Failed Download Handling", "FailedDownloadHandling": "Failed Download Handling",
"FailedLoadingSearchResults": "Failed to load search results, please try again.", "FailedLoadingSearchResults": "Failed to load search results, please try again.",
"FileChmodHelpTexts1": "Octal, applied to media files when imported/renamed by Radarr",
"FileChmodHelpTexts2": "The same mode is applied to movie/sub folders with the execute bit added, e.g., 0644 becomes 0755",
"FileChmodMode": "File chmod mode",
"FileDateHelpText": "Change file date on import/rescan", "FileDateHelpText": "Change file date on import/rescan",
"FileManagement": "File Management", "FileManagement": "File Management",
"Filename": "Filename", "Filename": "Filename",

View File

@ -205,8 +205,7 @@ private void SetPermissions(string path)
try try
{ {
var permissions = _configService.FileChmod; _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
_diskProvider.SetPermissions(path, permissions);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -54,7 +54,7 @@ public void SetFilePermissions(string path)
} }
else else
{ {
SetMonoPermissions(path, _configService.FileChmod); SetMonoPermissions(path);
} }
} }
@ -62,7 +62,7 @@ public void SetFolderPermissions(string path)
{ {
if (OsInfo.IsNotWindows) if (OsInfo.IsNotWindows)
{ {
SetMonoPermissions(path, _configService.FileChmod); SetMonoPermissions(path);
} }
} }
@ -75,7 +75,7 @@ public void SetFolderLastWriteTime(string path, DateTime time)
} }
} }
private void SetMonoPermissions(string path, string permissions) private void SetMonoPermissions(string path)
{ {
if (!_configService.SetPermissionsLinux) if (!_configService.SetPermissionsLinux)
{ {
@ -84,7 +84,7 @@ private void SetMonoPermissions(string path, string permissions)
try try
{ {
_diskProvider.SetPermissions(path, permissions); _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -28,7 +28,7 @@ public PushBulletSettings()
[FieldDefinition(0, Label = "Access Token", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://www.pushbullet.com/#settings/account")] [FieldDefinition(0, Label = "Access Token", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://www.pushbullet.com/#settings/account")]
public string ApiKey { get; set; } public string ApiKey { get; set; }
[FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device, RequestAction = "getDevices")] [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device)]
public IEnumerable<string> DeviceIds { get; set; } public IEnumerable<string> DeviceIds { get; set; }
[FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)] [FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)]

View File

@ -149,7 +149,7 @@ private bool InstallUpdate(UpdatePackage updatePackage)
// Set executable flag on update app // Set executable flag on update app
if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore)) if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore))
{ {
_diskProvider.SetPermissions(_appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime), "0755"); _diskProvider.SetPermissions(_appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime), "755", null);
} }
_logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime)); _logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime));

View File

@ -3,11 +3,11 @@
namespace NzbDrone.Core.Validation namespace NzbDrone.Core.Validation
{ {
public class FileChmodValidator : PropertyValidator public class FolderChmodValidator : PropertyValidator
{ {
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
public FileChmodValidator(IDiskProvider diskProvider) public FolderChmodValidator(IDiskProvider diskProvider)
: base("Must contain a valid Unix permissions octal") : base("Must contain a valid Unix permissions octal")
{ {
_diskProvider = diskProvider; _diskProvider = diskProvider;
@ -20,7 +20,7 @@ protected override bool IsValid(PropertyValidatorContext context)
return false; return false;
} }
return _diskProvider.IsValidFilePermissionMask(context.PropertyValue.ToString()); return _diskProvider.IsValidFolderPermissionMask(context.PropertyValue.ToString());
} }
} }
} }

View File

@ -1,7 +1,10 @@
using System.Linq; using System.Linq;
using FluentAssertions; using FluentAssertions;
using Newtonsoft.Json.Linq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
using Radarr.Api.V3.Indexers;
using Radarr.Http.ClientSchema;
namespace NzbDrone.Integration.Test.ApiTests namespace NzbDrone.Integration.Test.ApiTests
{ {
@ -18,5 +21,184 @@ public void should_have_built_in_indexer()
indexers.Should().NotContain(c => string.IsNullOrWhiteSpace(c.Name)); indexers.Should().NotContain(c => string.IsNullOrWhiteSpace(c.Name));
indexers.Where(c => c.ConfigContract == typeof(NullConfig).Name).Should().OnlyContain(c => c.EnableRss); indexers.Where(c => c.ConfigContract == typeof(NullConfig).Name).Should().OnlyContain(c => c.EnableRss);
} }
private IndexerResource GetNewznabSchemav2(string name = null)
{
var schema = Indexers.Schema().First(v => v.Implementation == "Newznab");
schema.Name = name;
schema.EnableRss = false;
schema.EnableAutomaticSearch = false;
schema.EnableInteractiveSearch = false;
return schema;
}
private IndexerResource GetNewznabSchemav3(string name = null)
{
var schema = Indexers.Schema().First(v => v.Implementation == "Newznab");
schema.Name = name;
schema.EnableRss = false;
schema.EnableAutomaticSearch = false;
schema.EnableInteractiveSearch = false;
return schema;
}
private Field GetCategoriesField(IndexerResource resource)
{
var field = resource.Fields.First(v => v.Name == "categories");
return field;
}
[Test]
public void v2_categories_should_be_array()
{
var schema = GetNewznabSchemav2();
var categoriesField = GetCategoriesField(schema);
categoriesField.Value.Should().BeOfType<JArray>();
}
[Test]
public void v3_categories_should_be_array()
{
var schema = GetNewznabSchemav3();
var categoriesField = GetCategoriesField(schema);
categoriesField.Value.Should().BeOfType<JArray>();
}
[Test]
public void v2_categories_should_accept_null()
{
var schema = GetNewznabSchemav2("Testv2null");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = null;
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v2_categories_should_accept_emptystring()
{
var schema = GetNewznabSchemav2("Testv2emptystring");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v2_categories_should_accept_string()
{
var schema = GetNewznabSchemav2("Testv2string");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "1000,1010";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
[Test]
public void v2_categories_should_accept_array()
{
var schema = GetNewznabSchemav2("Testv2array");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = new object[] { 1000, 1010 };
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
[Test]
public void v3_categories_should_accept_null()
{
var schema = GetNewznabSchemav3("Testv3null");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = null;
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v3_categories_should_accept_emptystring()
{
var schema = GetNewznabSchemav3("Testv3emptystring");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v3_categories_should_accept_string()
{
var schema = GetNewznabSchemav3("Testv3string");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "1000,1010";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
[Test]
public void v3_categories_should_accept_array()
{
var schema = GetNewznabSchemav3("Testv3array");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = new object[] { 1000, 1010 };
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
} }
} }

View File

@ -1,4 +1,6 @@
using Radarr.Api.V3.Indexers; using System;
using System.Collections.Generic;
using Radarr.Api.V3.Indexers;
using RestSharp; using RestSharp;
namespace NzbDrone.Integration.Test.Client namespace NzbDrone.Integration.Test.Client
@ -9,5 +11,11 @@ public IndexerClient(IRestClient restClient, string apiKey)
: base(restClient, apiKey) : base(restClient, apiKey)
{ {
} }
public List<IndexerResource> Schema()
{
var request = BuildRequest("/schema");
return Get<List<IndexerResource>>(request);
}
} }
} }

View File

@ -17,11 +17,32 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
[Platform(Exclude = "Win")] [Platform(Exclude = "Win")]
public class DiskProviderFixture : DiskProviderFixtureBase<DiskProvider> public class DiskProviderFixture : DiskProviderFixtureBase<DiskProvider>
{ {
private string _tempPath;
public DiskProviderFixture() public DiskProviderFixture()
{ {
PosixOnly(); PosixOnly();
} }
[TearDown]
public void MonoDiskProviderFixtureTearDown()
{
// Give ourselves back write permissions so we can delete it
if (_tempPath != null)
{
if (Directory.Exists(_tempPath))
{
Syscall.chmod(_tempPath, FilePermissions.S_IRWXU);
}
else if (File.Exists(_tempPath))
{
Syscall.chmod(_tempPath, FilePermissions.S_IRUSR | FilePermissions.S_IWUSR);
}
_tempPath = null;
}
}
protected override void SetWritePermissions(string path, bool writable) protected override void SetWritePermissions(string path, bool writable)
{ {
if (Environment.UserName == "root") if (Environment.UserName == "root")
@ -29,16 +50,37 @@ protected override void SetWritePermissions(string path, bool writable)
Assert.Inconclusive("Need non-root user to test write permissions."); Assert.Inconclusive("Need non-root user to test write permissions.");
} }
SetWritePermissionsInternal(path, writable, false);
}
protected void SetWritePermissionsInternal(string path, bool writable, bool setgid)
{
// Remove Write permissions, we're still owner so we can clean it up, but we'll have to do that explicitly. // Remove Write permissions, we're still owner so we can clean it up, but we'll have to do that explicitly.
var entry = UnixFileSystemInfo.GetFileSystemEntry(path); Stat stat;
Syscall.stat(path, out stat);
FilePermissions mode = stat.st_mode;
if (writable) if (writable)
{ {
entry.FileAccessPermissions |= FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite; mode |= FilePermissions.S_IWUSR | FilePermissions.S_IWGRP | FilePermissions.S_IWOTH;
} }
else else
{ {
entry.FileAccessPermissions &= ~(FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite); mode &= ~(FilePermissions.S_IWUSR | FilePermissions.S_IWGRP | FilePermissions.S_IWOTH);
}
if (setgid)
{
mode |= FilePermissions.S_ISGID;
}
else
{
mode &= ~FilePermissions.S_ISGID;
}
if (stat.st_mode != mode)
{
Syscall.chmod(path, mode);
} }
} }
@ -164,21 +206,22 @@ public void should_set_file_permissions()
var tempFile = GetTempFilePath(); var tempFile = GetTempFilePath();
File.WriteAllText(tempFile, "File1"); File.WriteAllText(tempFile, "File1");
SetWritePermissions(tempFile, false); SetWritePermissionsInternal(tempFile, false, false);
_tempPath = tempFile;
// Verify test setup // Verify test setup
Syscall.stat(tempFile, out var fileStat); Syscall.stat(tempFile, out var fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0444"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0444");
Subject.SetPermissions(tempFile, "644"); Subject.SetPermissions(tempFile, "755", null);
Syscall.stat(tempFile, out fileStat); Syscall.stat(tempFile, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644");
Subject.SetPermissions(tempFile, "0644"); Subject.SetPermissions(tempFile, "0755", null);
Syscall.stat(tempFile, out fileStat); Syscall.stat(tempFile, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644");
Subject.SetPermissions(tempFile, "1664"); Subject.SetPermissions(tempFile, "1775", null);
Syscall.stat(tempFile, out fileStat); Syscall.stat(tempFile, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1664"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1664");
} }
@ -189,62 +232,118 @@ public void should_set_folder_permissions()
var tempPath = GetTempFilePath(); var tempPath = GetTempFilePath();
Directory.CreateDirectory(tempPath); Directory.CreateDirectory(tempPath);
SetWritePermissions(tempPath, false); SetWritePermissionsInternal(tempPath, false, false);
_tempPath = tempPath;
// Verify test setup // Verify test setup
Syscall.stat(tempPath, out var fileStat); Syscall.stat(tempPath, out var fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0555"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0555");
Subject.SetPermissions(tempPath, "644"); Subject.SetPermissions(tempPath, "755", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
Subject.SetPermissions(tempPath, "0644"); Subject.SetPermissions(tempPath, "775", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
Subject.SetPermissions(tempPath, "1664");
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1775");
Subject.SetPermissions(tempPath, "775");
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
Subject.SetPermissions(tempPath, "640"); Subject.SetPermissions(tempPath, "750", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750");
Subject.SetPermissions(tempPath, "0041"); Subject.SetPermissions(tempPath, "051", null);
Syscall.stat(tempPath, out fileStat); Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0051"); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0051");
// reinstate sane permissions so fokder can be cleaned up
Subject.SetPermissions(tempPath, "775");
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
} }
[Test] [Test]
public void IsValidFilePermissionMask_should_return_correct() public void should_preserve_setgid_on_set_folder_permissions()
{ {
// Files may not be executable var tempPath = GetTempFilePath();
Subject.IsValidFilePermissionMask("0777").Should().BeFalse();
Subject.IsValidFilePermissionMask("0544").Should().BeFalse();
Subject.IsValidFilePermissionMask("0454").Should().BeFalse();
Subject.IsValidFilePermissionMask("0445").Should().BeFalse();
Directory.CreateDirectory(tempPath);
SetWritePermissionsInternal(tempPath, false, true);
_tempPath = tempPath;
// Verify test setup
Syscall.stat(tempPath, out var fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2555");
Subject.SetPermissions(tempPath, "755", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2755");
Subject.SetPermissions(tempPath, "775", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2775");
Subject.SetPermissions(tempPath, "750", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2750");
Subject.SetPermissions(tempPath, "051", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2051");
}
[Test]
public void should_clear_setgid_on_set_folder_permissions()
{
var tempPath = GetTempFilePath();
Directory.CreateDirectory(tempPath);
SetWritePermissionsInternal(tempPath, false, true);
_tempPath = tempPath;
// Verify test setup
Syscall.stat(tempPath, out var fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2555");
Subject.SetPermissions(tempPath, "0755", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
Subject.SetPermissions(tempPath, "0775", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
Subject.SetPermissions(tempPath, "0750", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750");
Subject.SetPermissions(tempPath, "0051", null);
Syscall.stat(tempPath, out fileStat);
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0051");
}
[Test]
public void IsValidFolderPermissionMask_should_return_correct()
{
// No special bits should be set // No special bits should be set
Subject.IsValidFilePermissionMask("1644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("1755").Should().BeFalse();
Subject.IsValidFilePermissionMask("2644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("2755").Should().BeFalse();
Subject.IsValidFilePermissionMask("4644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("4755").Should().BeFalse();
Subject.IsValidFilePermissionMask("7644").Should().BeFalse(); Subject.IsValidFolderPermissionMask("7755").Should().BeFalse();
// Files should be readable and writeable by owner // Folder should be readable and writeable by owner
Subject.IsValidFilePermissionMask("0400").Should().BeFalse(); Subject.IsValidFolderPermissionMask("000").Should().BeFalse();
Subject.IsValidFilePermissionMask("0000").Should().BeFalse(); Subject.IsValidFolderPermissionMask("100").Should().BeFalse();
Subject.IsValidFilePermissionMask("0200").Should().BeFalse(); Subject.IsValidFolderPermissionMask("200").Should().BeFalse();
Subject.IsValidFilePermissionMask("0600").Should().BeTrue(); Subject.IsValidFolderPermissionMask("300").Should().BeFalse();
Subject.IsValidFolderPermissionMask("400").Should().BeFalse();
Subject.IsValidFolderPermissionMask("500").Should().BeFalse();
Subject.IsValidFolderPermissionMask("600").Should().BeFalse();
Subject.IsValidFolderPermissionMask("700").Should().BeTrue();
// Folder should be readable and writeable by owner
Subject.IsValidFolderPermissionMask("0000").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0100").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0200").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0300").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0400").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0500").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0600").Should().BeFalse();
Subject.IsValidFolderPermissionMask("0700").Should().BeTrue();
} }
} }
} }

View File

@ -61,15 +61,29 @@ public override void SetEveryonePermissions(string filename)
{ {
} }
public override void SetPermissions(string path, string mask) public override void SetPermissions(string path, string mask, string group)
{ {
_logger.Debug("Setting permissions: {0} on {1}", mask, path); _logger.Debug("Setting permissions: {0} on {1}", mask, path);
var permissions = NativeConvert.FromOctalPermissionString(mask); var permissions = NativeConvert.FromOctalPermissionString(mask);
if (Directory.Exists(path)) if (File.Exists(path))
{ {
permissions = GetFolderPermissions(permissions); permissions = GetFilePermissions(permissions);
}
// Preserve non-access permissions
if (Syscall.stat(path, out var curStat) < 0)
{
var error = Stdlib.GetLastError();
throw new LinuxPermissionsException("Error getting current permissions: " + error);
}
// Preserve existing non-access permissions unless mask is 4 digits
if (mask.Length < 4)
{
permissions |= curStat.st_mode & ~FilePermissions.ACCESSPERMS;
} }
if (Syscall.chmod(path, permissions) < 0) if (Syscall.chmod(path, permissions) < 0)
@ -78,33 +92,39 @@ public override void SetPermissions(string path, string mask)
throw new LinuxPermissionsException("Error setting permissions: " + error); throw new LinuxPermissionsException("Error setting permissions: " + error);
} }
var groupId = GetGroupId(group);
if (Syscall.chown(path, unchecked((uint)-1), groupId) < 0)
{
var error = Stdlib.GetLastError();
throw new LinuxPermissionsException("Error setting group: " + error);
}
} }
private static FilePermissions GetFolderPermissions(FilePermissions permissions) private static FilePermissions GetFilePermissions(FilePermissions permissions)
{ {
permissions |= (FilePermissions)((int)(permissions & (FilePermissions.S_IRUSR | FilePermissions.S_IRGRP | FilePermissions.S_IROTH)) >> 2); permissions &= ~(FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH);
return permissions; return permissions;
} }
public override bool IsValidFilePermissionMask(string mask) public override bool IsValidFolderPermissionMask(string mask)
{ {
try try
{ {
var permissions = NativeConvert.FromOctalPermissionString(mask); var permissions = NativeConvert.FromOctalPermissionString(mask);
if ((permissions & (FilePermissions.S_ISUID | FilePermissions.S_ISGID | FilePermissions.S_ISVTX)) != 0) if ((permissions & ~FilePermissions.ACCESSPERMS) != 0)
{ {
// Only allow access permissions
return false; return false;
} }
if ((permissions & (FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH)) != 0) if ((permissions & FilePermissions.S_IRWXU) != FilePermissions.S_IRWXU)
{
return false;
}
if ((permissions & (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR)) != (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR))
{ {
// We expect at least full owner permissions (700)
return false; return false;
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Runtime.ConstrainedExecution; using System.Runtime.ConstrainedExecution;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security.Permissions; using System.Security.Permissions;
@ -25,13 +25,13 @@ public SafeUnixHandle(int fd)
public override bool IsInvalid public override bool IsInvalid
{ {
get { return this.handle == new IntPtr(-1); } get { return handle == new IntPtr(-1); }
} }
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
protected override bool ReleaseHandle() protected override bool ReleaseHandle()
{ {
return Syscall.close(this.handle.ToInt32()) != -1; return Syscall.close(handle.ToInt32()) != -1;
} }
} }
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.IO; using System.IO;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
@ -131,7 +131,7 @@ public void Start(string installationFolder, int processId)
// Set executable flag on app // Set executable flag on app
if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore)) if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore))
{ {
_diskProvider.SetPermissions(Path.Combine(installationFolder, "Radarr"), "0755"); _diskProvider.SetPermissions(Path.Combine(installationFolder, "Radarr"), "755", null);
} }
} }
catch (Exception e) catch (Exception e)

View File

@ -91,7 +91,7 @@ public override void SetEveryonePermissions(string filename)
} }
} }
public override void SetPermissions(string path, string mask) public override void SetPermissions(string path, string mask, string group)
{ {
} }

View File

@ -8,11 +8,11 @@ namespace Radarr.Api.V3.Config
{ {
public class MediaManagementConfigModule : RadarrConfigModule<MediaManagementConfigResource> public class MediaManagementConfigModule : RadarrConfigModule<MediaManagementConfigResource>
{ {
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator)
: base(configService) : base(configService)
{ {
SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0);
SharedValidator.RuleFor(c => c.FileChmod).SetValidator(fileChmodValidator).When(c => !string.IsNullOrEmpty(c.FileChmod) && (OsInfo.IsLinux || OsInfo.IsOsx)); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx));
SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin)); SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100); SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100);
} }

View File

@ -19,7 +19,8 @@ public class MediaManagementConfigResource : RestResource
public bool PathsDefaultStatic { get; set; } public bool PathsDefaultStatic { get; set; }
public bool SetPermissionsLinux { get; set; } public bool SetPermissionsLinux { get; set; }
public string FileChmod { get; set; } public string ChmodFolder { get; set; }
public string ChownGroup { get; set; }
public bool SkipFreeSpaceCheckWhenImporting { get; set; } public bool SkipFreeSpaceCheckWhenImporting { get; set; }
public int MinimumFreeSpaceWhenImporting { get; set; } public int MinimumFreeSpaceWhenImporting { get; set; }
@ -46,7 +47,8 @@ public static MediaManagementConfigResource ToResource(IConfigService model)
AutoRenameFolders = model.AutoRenameFolders, AutoRenameFolders = model.AutoRenameFolders,
SetPermissionsLinux = model.SetPermissionsLinux, SetPermissionsLinux = model.SetPermissionsLinux,
FileChmod = model.FileChmod, ChmodFolder = model.ChmodFolder,
ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting, MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,

View File

@ -28,7 +28,7 @@ protected ProviderModuleBase(IProviderFactory<TProvider, TProviderDefinition> pr
Get("schema", x => GetTemplates()); Get("schema", x => GetTemplates());
Post("test", x => Test(ReadResourceFromRequest(true))); Post("test", x => Test(ReadResourceFromRequest(true)));
Post("testall", x => TestAll()); Post("testall", x => TestAll());
Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true))); Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true, true)));
GetResourceAll = GetAll; GetResourceAll = GetAll;
GetResourceById = GetProviderById; GetResourceById = GetProviderById;

View File

@ -15,9 +15,9 @@ public class Field
public string Type { get; set; } public string Type { get; set; }
public bool Advanced { get; set; } public bool Advanced { get; set; }
public List<SelectOption> SelectOptions { get; set; } public List<SelectOption> SelectOptions { get; set; }
public string SelectOptionsProviderAction { get; set; }
public string Section { get; set; } public string Section { get; set; }
public string Hidden { get; set; } public string Hidden { get; set; }
public string RequestAction { get; set; }
public Field Clone() public Field Clone()
{ {

View File

@ -100,13 +100,19 @@ private static FieldMapping[] GetFieldMapping(Type type, string prefix, Func<obj
Order = fieldAttribute.Order, Order = fieldAttribute.Order,
Advanced = fieldAttribute.Advanced, Advanced = fieldAttribute.Advanced,
Type = fieldAttribute.Type.ToString().FirstCharToLower(), Type = fieldAttribute.Type.ToString().FirstCharToLower(),
Section = fieldAttribute.Section, Section = fieldAttribute.Section
RequestAction = fieldAttribute.RequestAction
}; };
if (fieldAttribute.Type == FieldType.Select || fieldAttribute.Type == FieldType.TagSelect) if (fieldAttribute.Type == FieldType.Select || fieldAttribute.Type == FieldType.TagSelect)
{ {
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); if (fieldAttribute.SelectOptionsProviderAction.IsNotNullOrWhiteSpace())
{
field.SelectOptionsProviderAction = fieldAttribute.SelectOptionsProviderAction;
}
else
{
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions);
}
} }
if (fieldAttribute.Hidden != HiddenType.Visible) if (fieldAttribute.Hidden != HiddenType.Visible)
@ -215,7 +221,11 @@ private static Func<object, object> GetValueConverter(Type propertyType)
{ {
return fieldValue => return fieldValue =>
{ {
if (fieldValue.GetType() == typeof(JArray)) if (fieldValue == null)
{
return Enumerable.Empty<int>();
}
else if (fieldValue.GetType() == typeof(JArray))
{ {
return ((JArray)fieldValue).Select(s => s.Value<int>()); return ((JArray)fieldValue).Select(s => s.Value<int>());
} }
@ -229,7 +239,11 @@ private static Func<object, object> GetValueConverter(Type propertyType)
{ {
return fieldValue => return fieldValue =>
{ {
if (fieldValue.GetType() == typeof(JArray)) if (fieldValue == null)
{
return Enumerable.Empty<string>();
}
else if (fieldValue.GetType() == typeof(JArray))
{ {
return ((JArray)fieldValue).Select(s => s.Value<string>()); return ((JArray)fieldValue).Select(s => s.Value<string>());
} }

View File

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FluentValidation; using FluentValidation;
using FluentValidation.Results;
using Nancy; using Nancy;
using Nancy.Responses.Negotiation; using Nancy.Responses.Negotiation;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -224,7 +225,7 @@ protected Negotiator ResponseWithCode(object model, HttpStatusCode statusCode)
return Negotiate.WithModel(model).WithStatusCode(statusCode); return Negotiate.WithModel(model).WithStatusCode(statusCode);
} }
protected TResource ReadResourceFromRequest(bool skipValidate = false) protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false)
{ {
TResource resource; TResource resource;
@ -242,7 +243,12 @@ protected TResource ReadResourceFromRequest(bool skipValidate = false)
throw new BadRequestException("Request body can't be empty"); throw new BadRequestException("Request body can't be empty");
} }
var errors = SharedValidator.Validate(resource).Errors.ToList(); var errors = new List<ValidationFailure>();
if (!skipSharedValidate)
{
errors.AddRange(SharedValidator.Validate(resource).Errors);
}
if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase))
{ {