diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js index 2053d3a30..4e3281169 100644 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -2,34 +2,22 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { clearOptions, fetchOptions } from 'Store/Actions/providerOptionActions'; +import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; import DeviceInput from './DeviceInput'; function createMapStateToProps() { return createSelector( (state, { value }) => value, - (state, { name }) => name, - (state) => state.providerOptions, - (value, name, devices) => { - const { - isFetching, - isPopulated, - error, - items - } = devices; + (state) => state.providerOptions.devices || defaultState, + (value, devices) => { return { - isFetching, - isPopulated, - error, - items: items[name] || [], + ...devices, selectedDevices: value.map((valueDevice) => { - const sectionItems = items[name] || []; - // Disable equality ESLint rule so we don't need to worry about // a type mismatch between the value items and the device ID. // eslint-disable-next-line eqeqeq - const device = sectionItems.find((d) => d.id == valueDevice); + const device = devices.items.find((d) => d.id == valueDevice); if (device) { return { @@ -63,7 +51,7 @@ class DeviceInputConnector extends Component { } componentWillUnmount = () => { - this.props.dispatchClearOptions(); + this.props.dispatchClearOptions({ section: 'devices' }); } // @@ -73,14 +61,12 @@ class DeviceInputConnector extends Component { const { provider, providerData, - dispatchFetchOptions, - requestAction, - name + dispatchFetchOptions } = this.props; dispatchFetchOptions({ - action: requestAction, - itemSection: name, + section: 'devices', + action: 'getDevices', provider, providerData }); @@ -109,7 +95,6 @@ class DeviceInputConnector extends Component { DeviceInputConnector.propTypes = { provider: PropTypes.string.isRequired, providerData: PropTypes.object.isRequired, - requestAction: PropTypes.string.isRequired, name: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, dispatchFetchOptions: PropTypes.func.isRequired, diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css index c3623c199..60cd28d69 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -5,6 +5,10 @@ align-items: center; } +.editableContainer { + width: 100%; +} + .hasError { composes: hasError from '~Components/Form/Input.css'; } @@ -22,6 +26,16 @@ margin-left: 12px; } +.dropdownArrowContainerEditable { + position: absolute; + top: 0; + right: 0; + padding-right: 17px; + width: 30%; + height: 35px; + text-align: right; +} + .dropdownArrowContainerDisabled { composes: dropdownArrowContainer; @@ -66,3 +80,8 @@ border-radius: 4px; background-color: $white; } + +.loading { + display: inline-block; + margin: 5px -5px 5px 0; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 08690161d..197375bb6 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -5,6 +5,7 @@ import React, { Component } from 'react'; import { Manager, Popper, Reference } from 'react-popper'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Measure from 'Components/Measure'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; @@ -16,6 +17,7 @@ import getUniqueElememtId from 'Utilities/getUniqueElementId'; import { isMobile as isMobileUtil } from 'Utilities/mobile'; import HintedSelectInputOption from './HintedSelectInputOption'; import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; +import TextInput from './TextInput'; import styles from './EnhancedSelectInput.css'; function isArrowKey(keyCode) { @@ -168,11 +170,21 @@ class EnhancedSelectInput extends Component { } } + onFocus = () => { + if (this.state.isOpen) { + this._removeListener(); + this.setState({ isOpen: false }); + } + } + onBlur = () => { - // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) - const origIndex = getSelectedIndex(this.props); - if (origIndex !== this.state.selectedIndex) { - this.setState({ selectedIndex: origIndex }); + if (!this.props.isEditable) { + // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) + const origIndex = getSelectedIndex(this.props); + + if (origIndex !== this.state.selectedIndex) { + this.setState({ selectedIndex: origIndex }); + } } } @@ -250,6 +262,10 @@ class EnhancedSelectInput extends Component { this._addListener(); } + if (!this.state.isOpen && this.props.onOpen) { + this.props.onOpen(); + } + this.setState({ isOpen: !this.state.isOpen }); } @@ -292,15 +308,19 @@ class EnhancedSelectInput extends Component { const { className, disabledClassName, + name, value, values, isDisabled, + isEditable, + isFetching, hasError, hasWarning, valueOptions, selectedValueOptions, selectedValueComponent: SelectedValueComponent, - optionComponent: OptionComponent + optionComponent: OptionComponent, + onChange } = this.props; const { @@ -326,40 +346,94 @@ class EnhancedSelectInput extends Component { whitelist={['width']} onMeasure={this.onMeasure} > - - - {selectedOption ? selectedOption.value : null} - + { + isEditable ? +
+ + + { + isFetching && + + } -
- -
- + { + !isFetching && + + } + +
: + + + {selectedOption ? selectedOption.value : null} + + +
+ + { + isFetching && + + } + + { + !isFetching && + + } +
+ + } )} @@ -483,12 +557,15 @@ EnhancedSelectInput.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isEditable: PropTypes.bool.isRequired, hasError: PropTypes.bool, hasWarning: PropTypes.bool, valueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, optionComponent: PropTypes.elementType, + onOpen: PropTypes.func, onChange: PropTypes.func.isRequired }; @@ -496,6 +573,8 @@ EnhancedSelectInput.defaultProps = { className: styles.enhancedSelect, disabledClassName: styles.isDisabled, isDisabled: false, + isFetching: false, + isEditable: false, valueOptions: {}, selectedValueOptions: {}, selectedValueComponent: HintedSelectInputSelectedValue, diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js new file mode 100644 index 000000000..0311a920c --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputConnector.js @@ -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 ( + + ); + } +} + +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); diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js index f594a4335..fce64d2f7 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.js +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js @@ -32,6 +32,7 @@ class EnhancedSelectInputOption extends Component { const { className, id, + depth, isSelected, isDisabled, isHidden, @@ -54,6 +55,11 @@ class EnhancedSelectInputOption extends Component { onPress={this.onPress} > + { + depth !== 0 && +
+ } + { isMultiSelect && { diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index bfbe1e763..f5130d18b 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; import { inputTypes } from 'Helpers/Props'; -function getType(type, value) { +function getType({ type, selectOptionsProviderAction }) { switch (type) { case 'captcha': return inputTypes.CAPTCHA; @@ -23,6 +23,9 @@ function getType(type, value) { case 'filePath': return inputTypes.PATH; case 'select': + if (selectOptionsProviderAction) { + return inputTypes.DYNAMIC_SELECT; + } return inputTypes.SELECT; case 'tag': return inputTypes.TEXT_TAG; @@ -63,7 +66,6 @@ function ProviderFieldFormGroup(props) { value, type, advanced, - requestAction, hidden, pending, errors, @@ -88,7 +90,7 @@ function ProviderFieldFormGroup(props) { {label} @@ -109,7 +110,8 @@ function ProviderFieldFormGroup(props) { const selectOptionsShape = { name: PropTypes.string.isRequired, - value: PropTypes.number.isRequired + value: PropTypes.number.isRequired, + hint: PropTypes.string }; ProviderFieldFormGroup.propTypes = { @@ -121,12 +123,12 @@ ProviderFieldFormGroup.propTypes = { value: PropTypes.any, type: PropTypes.string.isRequired, advanced: PropTypes.bool.isRequired, - requestAction: PropTypes.string, hidden: PropTypes.string, pending: PropTypes.bool.isRequired, errors: PropTypes.arrayOf(PropTypes.object).isRequired, warnings: PropTypes.arrayOf(PropTypes.object).isRequired, selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)), + selectOptionsProviderAction: PropTypes.string, onChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Form/UMaskInput.css b/frontend/src/Components/Form/UMaskInput.css new file mode 100644 index 000000000..7b687caf9 --- /dev/null +++ b/frontend/src/Components/Form/UMaskInput.css @@ -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; +} diff --git a/frontend/src/Components/Form/UMaskInput.js b/frontend/src/Components/Form/UMaskInput.js new file mode 100644 index 000000000..22f51c8fc --- /dev/null +++ b/frontend/src/Components/Form/UMaskInput.js @@ -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: {v.hint} }; + }); + + return ( +
+
+
+ + +
+ d{unit} +
+
+
+
+
+ +
{umask}
+
+
+ +
{folder}
+
d{formatPermissions(folderNum)}
+
+
+ +
{file}
+
{formatPermissions(fileNum)}
+
+
+
+ ); + } +} + +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; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 46f4c1b84..75812576f 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -12,11 +12,13 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const SELECT = 'select'; +export const DYNAMIC_SELECT = 'dynamicSelect'; export const TAG = 'tag'; export const TEXT = 'text'; export const TEXT_AREA = 'textArea'; export const TEXT_TAG = 'textTag'; export const TAG_SELECT = 'tagSelect'; +export const UMASK = 'umask'; export const all = [ AUTO_COMPLETE, @@ -33,9 +35,11 @@ export const all = [ ROOT_FOLDER_SELECT, INDEXER_FLAGS_SELECT, SELECT, + DYNAMIC_SELECT, TAG, TEXT, TEXT_AREA, TEXT_TAG, - TAG_SELECT + TAG_SELECT, + UMASK ]; diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index 4d6247b4b..00e1396a9 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -364,17 +364,32 @@ class MediaManagement extends Component { advancedSettings={advancedSettings} isAdvanced={true} > - {translate('FileChmodMode')} + {translate('ChmodFolder')} + + + + + + {translate('ChmodGroup')} diff --git a/frontend/src/Store/Actions/providerOptionActions.js b/frontend/src/Store/Actions/providerOptionActions.js index 72a8b1745..4dc38a98f 100644 --- a/frontend/src/Store/Actions/providerOptionActions.js +++ b/frontend/src/Store/Actions/providerOptionActions.js @@ -1,3 +1,4 @@ +import _ from 'lodash'; import { createAction } from 'redux-actions'; import { createThunk, handleThunks } from 'Store/thunks'; import requestAction from 'Utilities/requestAction'; @@ -10,11 +11,14 @@ import createHandleActions from './Creators/createHandleActions'; export const section = 'providerOptions'; +const lastActions = {}; +let lastActionId = 0; + // // State export const defaultState = { - items: {}, + items: [], isFetching: false, isPopulated: false, error: false @@ -23,8 +27,8 @@ export const defaultState = { // // Actions Types -export const FETCH_OPTIONS = 'devices/fetchOptions'; -export const CLEAR_OPTIONS = 'devices/clearOptions'; +export const FETCH_OPTIONS = 'providers/fetchOptions'; +export const CLEAR_OPTIONS = 'providers/clearOptions'; // // Action Creators @@ -38,35 +42,55 @@ export const clearOptions = createAction(CLEAR_OPTIONS); export const actionHandlers = handleThunks({ [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({ - section, + section: subsection, isFetching: true })); - const oldItems = getState().providerOptions.items; - const itemSection = payload.itemSection; - const promise = requestAction(payload); promise.done((data) => { - oldItems[itemSection] = data.options || []; + if (lastActions[payload.section]) { + if (lastActions[payload.section].actionId === actionId) { + lastActions[payload.section] = null; + } - dispatch(set({ - section, - isFetching: false, - isPopulated: true, - error: null, - items: oldItems - })); + dispatch(set({ + section: subsection, + isFetching: false, + isPopulated: true, + error: null, + items: data.options || [] + })); + } }); promise.fail((xhr) => { - dispatch(set({ - section, - isFetching: false, - isPopulated: false, - error: xhr - })); + if (lastActions[payload.section]) { + if (lastActions[payload.section].actionId === actionId) { + lastActions[payload.section] = null; + } + + dispatch(set({ + section: subsection, + isFetching: false, + isPopulated: false, + error: xhr + })); + } }); } }); @@ -76,8 +100,12 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [CLEAR_OPTIONS]: function(state) { - return updateSectionState(state, section, defaultState); + [CLEAR_OPTIONS]: function(state, { payload }) { + const subsection = `${section}.${payload.section}`; + + lastActions[payload.section] = null; + + return updateSectionState(state, subsection, defaultState); } -}, defaultState, section); +}, {}, section); diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs index ce316268d..43fb40963 100644 --- a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs +++ b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs @@ -8,10 +8,10 @@ namespace NzbDrone.Api.Config { public class MediaManagementConfigModule : NzbDroneConfigModule { - public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) + public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator) : 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)); } diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs index 512891fcb..27b04cd76 100644 --- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs +++ b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs @@ -16,7 +16,8 @@ public class MediaManagementConfigResource : RestResource public bool PathsDefaultStatic { 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 CopyUsingHardlinks { get; set; } @@ -39,7 +40,8 @@ public static MediaManagementConfigResource ToResource(IConfigService model) AutoRenameFolders = model.AutoRenameFolders, SetPermissionsLinux = model.SetPermissionsLinux, - FileChmod = model.FileChmod, + ChmodFolder = model.ChmodFolder, + ChownGroup = model.ChownGroup, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, CopyUsingHardlinks = model.CopyUsingHardlinks, diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 60cf6a623..440b7c57f 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -30,7 +30,7 @@ public static StringComparison PathStringComparison public abstract long? GetAvailableSpace(string path); public abstract void InheritFolderPermissions(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 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(); } diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index cb262504a..91524470f 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -11,7 +11,7 @@ public interface IDiskProvider long? GetAvailableSpace(string path); void InheritFolderPermissions(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); long? GetTotalSize(string path); DateTime FolderGetCreationTime(string path); @@ -56,6 +56,6 @@ public interface IDiskProvider List GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly); void RemoveEmptySubfolders(string path); void SaveStream(Stream stream, string path); - bool IsValidFilePermissionMask(string mask); + bool IsValidFolderPermissionMask(string mask); } } diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index b887551b4..e68db5a2c 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -19,10 +19,10 @@ public FieldDefinitionAttribute(int order) public FieldType Type { get; set; } public bool Advanced { get; set; } public Type SelectOptions { get; set; } + public string SelectOptionsProviderAction { get; set; } public string Section { get; set; } public HiddenType Hidden { get; set; } public PrivacyLevel Privacy { get; set; } - public string RequestAction { get; set; } } [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 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 { Textbox, diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 15066c55d..05ce821f7 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -317,11 +317,18 @@ public bool SetPermissionsLinux 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 diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index c4c24136c..cb42aa207 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -45,7 +45,8 @@ public interface IConfigService //Permissions (Media Management) bool SetPermissionsLinux { get; set; } - string FileChmod { get; set; } + string ChmodFolder { get; set; } + string ChownGroup { get; set; } //Indexers int Retention { get; set; } diff --git a/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs b/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs index 6ea2e5d68..9e8f44aa3 100644 --- a/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs +++ b/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs @@ -8,7 +8,7 @@ public class remove_chown_and_folderchmod_config : NzbDroneMigrationBase { 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')"); } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/187_swap_filechmod_for_folderchmod.cs b/src/NzbDrone.Core/Datastore/Migration/187_swap_filechmod_for_folderchmod.cs new file mode 100644 index 000000000..a1580efa4 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/187_swap_filechmod_for_folderchmod.cs @@ -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(); + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs b/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs index 3b6e6eff6..92162c46b 100644 --- a/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs +++ b/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs @@ -91,8 +91,8 @@ public override object RequestAction(string action, IDictionary options = devices.OrderBy(d => d.Name, StringComparer.InvariantCultureIgnoreCase) .Select(d => new { - id = d.Id, - name = d.Name + Value = d.Id, + Name = d.Name }) }; } @@ -106,8 +106,8 @@ public override object RequestAction(string action, IDictionary options = devices.OrderBy(d => d.Label, StringComparer.InvariantCultureIgnoreCase) .Select(d => new { - id = d.Id, - name = d.Label + Value = d.Id, + Name = d.Label }) }; } diff --git a/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs b/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs index b9fc5659a..7d7052ab7 100644 --- a/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs @@ -34,10 +34,10 @@ public RadarrSettings() [FieldDefinition(1, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "Apikey of the Radarr V3 instance to import from")] 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 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 TagIds { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index c0af6232f..260c92155 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -321,7 +321,14 @@ protected virtual ValidationFailure TestConnection() _indexerStatusService.UpdateCookies(Definition.Id, cookies, expiration); }; 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()) { diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index 6486d0de0..3960579a2 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -159,5 +159,31 @@ protected virtual ValidationFailure TestCapabilities() return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); } } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "newznabCategories") + { + List 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); + } } } diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs index deae3ea5d..51074704a 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs @@ -48,6 +48,7 @@ private NewznabCapabilities FetchCapabilities(NewznabSettings indexerSettings) } var request = new HttpRequest(url, HttpAccept.Rss); + request.AllowAutoRedirect = true; HttpResponse response; @@ -76,6 +77,7 @@ private NewznabCapabilities FetchCapabilities(NewznabSettings indexerSettings) { ex.WithData(response, 128 * 1024); _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; diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs new file mode 100644 index 000000000..d433ed244 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs @@ -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 GetFieldSelectOptions(List 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(); + + if (categories == null) + { + // Fetching categories failed, use default Newznab categories + categories = new List(); + categories.Add(new NewznabCategory + { + Id = 2000, + Name = "Movies", + Subcategories = new List + { + 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; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs index 4a3c77d94..ede334e3e 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs @@ -75,7 +75,7 @@ public NewznabSettings() [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey)] 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 Categories { get; set; } [FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)] diff --git a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs index 867209572..77041cb03 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs @@ -148,5 +148,28 @@ protected virtual ValidationFailure TestCapabilities() return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); } } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "newznabCategories") + { + List categories = null; + try + { + categories = _capabilitiesProvider.GetCapabilities(Settings).Categories; + } + catch + { + // Use default categories + } + + return new + { + options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories) + }; + } + + return base.RequestAction(action, query); + } } } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index f6a3e04f1..c5f85635f 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -88,6 +88,12 @@ "ChangeHasNotBeenSavedYet": "Change has not been saved yet", "CheckDownloadClientForDetails": "check download client for more details", "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", "CleanLibraryLevel": "Clean Library Level", "Clear": "Clear", @@ -248,9 +254,6 @@ "Failed": "Failed", "FailedDownloadHandling": "Failed Download Handling", "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", "FileManagement": "File Management", "Filename": "Filename", diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index c08849eed..c11935005 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -205,8 +205,7 @@ private void SetPermissions(string path) try { - var permissions = _configService.FileChmod; - _diskProvider.SetPermissions(path, permissions); + _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup); } catch (Exception ex) { diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs index 3750af342..ae8d83667 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs @@ -54,7 +54,7 @@ public void SetFilePermissions(string path) } else { - SetMonoPermissions(path, _configService.FileChmod); + SetMonoPermissions(path); } } @@ -62,7 +62,7 @@ public void SetFolderPermissions(string path) { 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) { @@ -84,7 +84,7 @@ private void SetMonoPermissions(string path, string permissions) try { - _diskProvider.SetPermissions(path, permissions); + _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup); } catch (Exception ex) { diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs index 7d3167987..d4e9d0661 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs @@ -28,7 +28,7 @@ public PushBulletSettings() [FieldDefinition(0, Label = "Access Token", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://www.pushbullet.com/#settings/account")] 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 DeviceIds { get; set; } [FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)] diff --git a/src/NzbDrone.Core/Update/InstallUpdateService.cs b/src/NzbDrone.Core/Update/InstallUpdateService.cs index c13badb05..343af375d 100644 --- a/src/NzbDrone.Core/Update/InstallUpdateService.cs +++ b/src/NzbDrone.Core/Update/InstallUpdateService.cs @@ -149,7 +149,7 @@ private bool InstallUpdate(UpdatePackage updatePackage) // Set executable flag on update app 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)); diff --git a/src/NzbDrone.Core/Validation/FileChmodValidator.cs b/src/NzbDrone.Core/Validation/FolderChmodValidator.cs similarity index 69% rename from src/NzbDrone.Core/Validation/FileChmodValidator.cs rename to src/NzbDrone.Core/Validation/FolderChmodValidator.cs index c9f0881a7..3e90bf9fa 100644 --- a/src/NzbDrone.Core/Validation/FileChmodValidator.cs +++ b/src/NzbDrone.Core/Validation/FolderChmodValidator.cs @@ -3,11 +3,11 @@ namespace NzbDrone.Core.Validation { - public class FileChmodValidator : PropertyValidator + public class FolderChmodValidator : PropertyValidator { private readonly IDiskProvider _diskProvider; - public FileChmodValidator(IDiskProvider diskProvider) + public FolderChmodValidator(IDiskProvider diskProvider) : base("Must contain a valid Unix permissions octal") { _diskProvider = diskProvider; @@ -20,7 +20,7 @@ protected override bool IsValid(PropertyValidatorContext context) return false; } - return _diskProvider.IsValidFilePermissionMask(context.PropertyValue.ToString()); + return _diskProvider.IsValidFolderPermissionMask(context.PropertyValue.ToString()); } } } diff --git a/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs index 0124c83fd..fa53ce54c 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs @@ -1,7 +1,10 @@ using System.Linq; using FluentAssertions; +using Newtonsoft.Json.Linq; using NUnit.Framework; using NzbDrone.Core.ThingiProvider; +using Radarr.Api.V3.Indexers; +using Radarr.Http.ClientSchema; 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.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(); + } + + [Test] + public void v3_categories_should_be_array() + { + var schema = GetNewznabSchemav3(); + + var categoriesField = GetCategoriesField(schema); + + categoriesField.Value.Should().BeOfType(); + } + + [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(); + resultArray.As().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(); + resultArray.As().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(); + resultArray.As().ToObject().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(); + resultArray.As().ToObject().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(); + resultArray.As().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(); + resultArray.As().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(); + resultArray.As().ToObject().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(); + resultArray.As().ToObject().Should().BeEquivalentTo(new[] { 1000, 1010 }); + } } } diff --git a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs index 38c8eea52..190ae13bb 100644 --- a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs +++ b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs @@ -1,4 +1,6 @@ -using Radarr.Api.V3.Indexers; +using System; +using System.Collections.Generic; +using Radarr.Api.V3.Indexers; using RestSharp; namespace NzbDrone.Integration.Test.Client @@ -9,5 +11,11 @@ public IndexerClient(IRestClient restClient, string apiKey) : base(restClient, apiKey) { } + + public List Schema() + { + var request = BuildRequest("/schema"); + return Get>(request); + } } } diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs index 19f008757..0d076d1e1 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs @@ -17,11 +17,32 @@ namespace NzbDrone.Mono.Test.DiskProviderTests [Platform(Exclude = "Win")] public class DiskProviderFixture : DiskProviderFixtureBase { + private string _tempPath; + public DiskProviderFixture() { 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) { 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."); } + 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. - var entry = UnixFileSystemInfo.GetFileSystemEntry(path); + Stat stat; + Syscall.stat(path, out stat); + FilePermissions mode = stat.st_mode; if (writable) { - entry.FileAccessPermissions |= FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite; + mode |= FilePermissions.S_IWUSR | FilePermissions.S_IWGRP | FilePermissions.S_IWOTH; } 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(); File.WriteAllText(tempFile, "File1"); - SetWritePermissions(tempFile, false); + SetWritePermissionsInternal(tempFile, false, false); + _tempPath = tempFile; // Verify test setup Syscall.stat(tempFile, out var fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0444"); - Subject.SetPermissions(tempFile, "644"); + Subject.SetPermissions(tempFile, "755", null); Syscall.stat(tempFile, out fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644"); - Subject.SetPermissions(tempFile, "0644"); + Subject.SetPermissions(tempFile, "0755", null); Syscall.stat(tempFile, out fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644"); - Subject.SetPermissions(tempFile, "1664"); + Subject.SetPermissions(tempFile, "1775", null); Syscall.stat(tempFile, out fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1664"); } @@ -189,62 +232,118 @@ public void should_set_folder_permissions() var tempPath = GetTempFilePath(); Directory.CreateDirectory(tempPath); - SetWritePermissions(tempPath, false); + SetWritePermissionsInternal(tempPath, false, false); + _tempPath = tempPath; // Verify test setup Syscall.stat(tempPath, out var fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0555"); - Subject.SetPermissions(tempPath, "644"); + Subject.SetPermissions(tempPath, "755", null); Syscall.stat(tempPath, out fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755"); - Subject.SetPermissions(tempPath, "0644"); - 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"); + Subject.SetPermissions(tempPath, "775", null); Syscall.stat(tempPath, out fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775"); - Subject.SetPermissions(tempPath, "640"); + Subject.SetPermissions(tempPath, "750", null); Syscall.stat(tempPath, out fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750"); - Subject.SetPermissions(tempPath, "0041"); + Subject.SetPermissions(tempPath, "051", null); Syscall.stat(tempPath, out fileStat); 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] - public void IsValidFilePermissionMask_should_return_correct() + public void should_preserve_setgid_on_set_folder_permissions() { - // Files may not be executable - Subject.IsValidFilePermissionMask("0777").Should().BeFalse(); - Subject.IsValidFilePermissionMask("0544").Should().BeFalse(); - Subject.IsValidFilePermissionMask("0454").Should().BeFalse(); - Subject.IsValidFilePermissionMask("0445").Should().BeFalse(); + 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, "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 - Subject.IsValidFilePermissionMask("1644").Should().BeFalse(); - Subject.IsValidFilePermissionMask("2644").Should().BeFalse(); - Subject.IsValidFilePermissionMask("4644").Should().BeFalse(); - Subject.IsValidFilePermissionMask("7644").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("1755").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("2755").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("4755").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("7755").Should().BeFalse(); - // Files should be readable and writeable by owner - Subject.IsValidFilePermissionMask("0400").Should().BeFalse(); - Subject.IsValidFilePermissionMask("0000").Should().BeFalse(); - Subject.IsValidFilePermissionMask("0200").Should().BeFalse(); - Subject.IsValidFilePermissionMask("0600").Should().BeTrue(); + // Folder should be readable and writeable by owner + Subject.IsValidFolderPermissionMask("000").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("100").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("200").Should().BeFalse(); + 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(); } } } diff --git a/src/NzbDrone.Mono/Disk/DiskProvider.cs b/src/NzbDrone.Mono/Disk/DiskProvider.cs index 6ea2549dd..86369b60a 100644 --- a/src/NzbDrone.Mono/Disk/DiskProvider.cs +++ b/src/NzbDrone.Mono/Disk/DiskProvider.cs @@ -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); 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) @@ -78,33 +92,39 @@ public override void SetPermissions(string path, string mask) 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; } - public override bool IsValidFilePermissionMask(string mask) + public override bool IsValidFolderPermissionMask(string mask) { try { 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; } - if ((permissions & (FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH)) != 0) - { - return false; - } - - if ((permissions & (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR)) != (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR)) + if ((permissions & FilePermissions.S_IRWXU) != FilePermissions.S_IRWXU) { + // We expect at least full owner permissions (700) return false; } diff --git a/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs b/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs index bad088ec5..22b8f167d 100644 --- a/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs +++ b/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Runtime.ConstrainedExecution; using System.Runtime.InteropServices; using System.Security.Permissions; @@ -25,13 +25,13 @@ public SafeUnixHandle(int fd) public override bool IsInvalid { - get { return this.handle == new IntPtr(-1); } + get { return handle == new IntPtr(-1); } } [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] protected override bool ReleaseHandle() { - return Syscall.close(this.handle.ToInt32()) != -1; + return Syscall.close(handle.ToInt32()) != -1; } } } diff --git a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs index 3ae5af9bf..ecf6c144a 100644 --- a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs +++ b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -131,7 +131,7 @@ public void Start(string installationFolder, int processId) // Set executable flag on app 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) diff --git a/src/NzbDrone.Windows/Disk/DiskProvider.cs b/src/NzbDrone.Windows/Disk/DiskProvider.cs index 943058a6e..3f648e66a 100644 --- a/src/NzbDrone.Windows/Disk/DiskProvider.cs +++ b/src/NzbDrone.Windows/Disk/DiskProvider.cs @@ -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) { } diff --git a/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs b/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs index 6a37a760e..fffedc515 100644 --- a/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs +++ b/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs @@ -8,11 +8,11 @@ namespace Radarr.Api.V3.Config { public class MediaManagementConfigModule : RadarrConfigModule { - public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator) + public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator) : base(configService) { 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.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100); } diff --git a/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs b/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs index 987eba574..2813530fa 100644 --- a/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs +++ b/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs @@ -19,7 +19,8 @@ public class MediaManagementConfigResource : RestResource public bool PathsDefaultStatic { 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 int MinimumFreeSpaceWhenImporting { get; set; } @@ -46,7 +47,8 @@ public static MediaManagementConfigResource ToResource(IConfigService model) AutoRenameFolders = model.AutoRenameFolders, SetPermissionsLinux = model.SetPermissionsLinux, - FileChmod = model.FileChmod, + ChmodFolder = model.ChmodFolder, + ChownGroup = model.ChownGroup, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting, diff --git a/src/Radarr.Api.V3/ProviderModuleBase.cs b/src/Radarr.Api.V3/ProviderModuleBase.cs index 7320181d4..281fc6a18 100644 --- a/src/Radarr.Api.V3/ProviderModuleBase.cs +++ b/src/Radarr.Api.V3/ProviderModuleBase.cs @@ -28,7 +28,7 @@ protected ProviderModuleBase(IProviderFactory pr Get("schema", x => GetTemplates()); Post("test", x => Test(ReadResourceFromRequest(true))); 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; GetResourceById = GetProviderById; diff --git a/src/Radarr.Http/ClientSchema/Field.cs b/src/Radarr.Http/ClientSchema/Field.cs index eb8fa0edd..afa8d0300 100644 --- a/src/Radarr.Http/ClientSchema/Field.cs +++ b/src/Radarr.Http/ClientSchema/Field.cs @@ -15,9 +15,9 @@ public class Field public string Type { get; set; } public bool Advanced { get; set; } public List SelectOptions { get; set; } + public string SelectOptionsProviderAction { get; set; } public string Section { get; set; } public string Hidden { get; set; } - public string RequestAction { get; set; } public Field Clone() { diff --git a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs index 5710adc09..d05855d56 100644 --- a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs @@ -100,13 +100,19 @@ private static FieldMapping[] GetFieldMapping(Type type, string prefix, Func GetValueConverter(Type propertyType) { return fieldValue => { - if (fieldValue.GetType() == typeof(JArray)) + if (fieldValue == null) + { + return Enumerable.Empty(); + } + else if (fieldValue.GetType() == typeof(JArray)) { return ((JArray)fieldValue).Select(s => s.Value()); } @@ -229,7 +239,11 @@ private static Func GetValueConverter(Type propertyType) { return fieldValue => { - if (fieldValue.GetType() == typeof(JArray)) + if (fieldValue == null) + { + return Enumerable.Empty(); + } + else if (fieldValue.GetType() == typeof(JArray)) { return ((JArray)fieldValue).Select(s => s.Value()); } diff --git a/src/Radarr.Http/REST/RestModule.cs b/src/Radarr.Http/REST/RestModule.cs index 815363837..10a8c784a 100644 --- a/src/Radarr.Http/REST/RestModule.cs +++ b/src/Radarr.Http/REST/RestModule.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; +using FluentValidation.Results; using Nancy; using Nancy.Responses.Negotiation; using Newtonsoft.Json; @@ -224,7 +225,7 @@ protected Negotiator ResponseWithCode(object model, HttpStatusCode statusCode) return Negotiate.WithModel(model).WithStatusCode(statusCode); } - protected TResource ReadResourceFromRequest(bool skipValidate = false) + protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false) { TResource resource; @@ -242,7 +243,12 @@ protected TResource ReadResourceFromRequest(bool skipValidate = false) throw new BadRequestException("Request body can't be empty"); } - var errors = SharedValidator.Validate(resource).Errors.ToList(); + var errors = new List(); + + if (!skipSharedValidate) + { + errors.AddRange(SharedValidator.Validate(resource).Errors); + } if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) {