diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css index aa997f377..363ca7dd7 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; diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index bc9917caf..197375bb6 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -17,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) { @@ -169,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 }); + } } } @@ -297,16 +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 { @@ -332,52 +346,94 @@ class EnhancedSelectInput extends Component { whitelist={['width']} onMeasure={this.onMeasure} > - <Link - className={classNames( - 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> + { + isEditable ? + <div + className={styles.editableContainer} + > + <TextInput + className={className} + name={name} + value={value} + readOnly={isDisabled} + hasError={hasError} + hasWarning={hasWarning} + onFocus={this.onFocus} + onBlur={this.onBlur} + onChange={onChange} + /> + <Link + className={classNames( + styles.dropdownArrowContainerEditable, + isDisabled ? + styles.dropdownArrowContainerDisabled : + styles.dropdownArrowContainer) + } + onPress={this.onPress} + > + { + isFetching && + <LoadingIndicator + className={styles.loading} + size={20} + /> + } + + { + !isFetching && + <Icon + name={icons.CARET_DOWN} + /> + } + </Link> + </div> : + <Link + className={classNames( + 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> </div> )} @@ -502,6 +558,7 @@ EnhancedSelectInput.propTypes = { 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, @@ -517,6 +574,7 @@ EnhancedSelectInput.defaultProps = { disabledClassName: styles.isDisabled, isDisabled: false, isFetching: false, + isEditable: false, valueOptions: {}, selectedValueOptions: {}, selectedValueComponent: HintedSelectInputSelectedValue, diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 69d0511c8..7a453fef0 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -25,6 +25,7 @@ import SeriesTypeSelectInput from './SeriesTypeSelectInput'; import TagInputConnector from './TagInputConnector'; import TextInput from './TextInput'; import TextTagInputConnector from './TextTagInputConnector'; +import UMaskInput from './UMaskInput'; import styles from './FormInputGroup.css'; function getComponent(type) { @@ -92,6 +93,9 @@ function getComponent(type) { case inputTypes.TEXT_TAG: return TextTagInputConnector; + case inputTypes.UMASK: + return UMaskInput; + default: return TextInput; } @@ -199,7 +203,7 @@ function FormInputGroup(props) { } { - !checkInput && helpTextWarning && + (!checkInput || helpText) && helpTextWarning && <FormInputHelpText text={helpTextWarning} isWarning={true} 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: <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; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 8daeb9eb0..f71f4f531 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -20,6 +20,7 @@ export const DYNAMIC_SELECT = 'dynamicSelect'; export const TAG = 'tag'; export const TEXT = 'text'; export const TEXT_TAG = 'textTag'; +export const UMASK = 'umask'; export const all = [ AUTO_COMPLETE, @@ -43,5 +44,6 @@ export const all = [ SERIES_TYPE_SELECT, TAG, TEXT, - TEXT_TAG + TEXT_TAG, + UMASK ]; diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index 7a50f4741..e224b6af0 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -383,17 +383,32 @@ class MediaManagement extends Component { advancedSettings={advancedSettings} isAdvanced={true} > - <FormLabel>File chmod mode</FormLabel> + <FormLabel>chmod Folder</FormLabel> + + <FormInputGroup + type={inputTypes.UMASK} + name="chmodFolder" + helpText="Octal, applied during import/rename to media folders and files (without execute bits)" + helpTextWarning="This only works if the user running Lidarr is the owner of the file. It's better to ensure the download client sets the permissions properly." + onChange={onInputChange} + {...settings.chmodFolder} + /> + </FormGroup> + + <FormGroup + advancedSettings={advancedSettings} + isAdvanced={true} + > + <FormLabel>chown Group</FormLabel> <FormInputGroup type={inputTypes.TEXT} - name="fileChmod" - helpTexts={[ - 'Octal, applied to media files when imported/renamed by Readarr', - 'The same mode is applied to movie/sub folders with the execute bit added, e.g., 0644 becomes 0755' - ]} + name="chownGroup" + helpText="Group name or gid. Use gid for remote file systems." + helpTextWarning="This only works if the user running Readarr is the owner of the file. It's better to ensure the download client uses the same group as Readarr." + values={fileDateOptions} onChange={onInputChange} - {...settings.fileChmod} + {...settings.chownGroup} /> </FormGroup> </FieldSet> diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index cc1e407ac..59a844526 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -37,7 +37,7 @@ namespace NzbDrone.Common.Disk 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); @@ -534,7 +534,7 @@ namespace NzbDrone.Common.Disk } } - 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 1c94daad3..6eb8a9aa9 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Common.Disk 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); @@ -58,6 +58,6 @@ namespace NzbDrone.Common.Disk List<IFileInfo> 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/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index d16040161..86408e2d7 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -255,11 +255,18 @@ namespace NzbDrone.Core.Configuration 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 string MetadataSource diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 4d0debfe4..b55783f7c 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -42,7 +42,8 @@ namespace NzbDrone.Core.Configuration //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/006_remove_chown_and_folderchmod_config.cs b/src/NzbDrone.Core/Datastore/Migration/006_remove_chown_and_folderchmod_config.cs index 3db6c0f7a..a1ead826d 100644 --- a/src/NzbDrone.Core/Datastore/Migration/006_remove_chown_and_folderchmod_config.cs +++ b/src/NzbDrone.Core/Datastore/Migration/006_remove_chown_and_folderchmod_config.cs @@ -1,4 +1,7 @@ +using System; +using System.Data; using FluentMigrator; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Migration.Framework; namespace NzbDrone.Core.Datastore.Migration @@ -9,6 +12,45 @@ namespace NzbDrone.Core.Datastore.Migration protected override void MainDbUpgrade() { Execute.Sql("DELETE FROM config WHERE Key IN ('folderchmod', 'chownuser')"); + 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(); + } + } + } } } -} +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs index cb9097453..9f1caf6c1 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs @@ -54,7 +54,7 @@ namespace NzbDrone.Core.MediaFiles } else { - SetMonoPermissions(path, _configService.FileChmod); + SetMonoPermissions(path); } } @@ -62,7 +62,7 @@ namespace NzbDrone.Core.MediaFiles { if (OsInfo.IsNotWindows) { - SetMonoPermissions(path, _configService.FileChmod); + SetMonoPermissions(path); } } @@ -75,7 +75,7 @@ namespace NzbDrone.Core.MediaFiles } } - private void SetMonoPermissions(string path, string permissions) + private void SetMonoPermissions(string path) { if (!_configService.SetPermissionsLinux) { @@ -84,7 +84,7 @@ namespace NzbDrone.Core.MediaFiles try { - _diskProvider.SetPermissions(path, permissions); + _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup); } catch (Exception ex) { diff --git a/src/NzbDrone.Core/Update/InstallUpdateService.cs b/src/NzbDrone.Core/Update/InstallUpdateService.cs index 419c2102a..af405dd3f 100644 --- a/src/NzbDrone.Core/Update/InstallUpdateService.cs +++ b/src/NzbDrone.Core/Update/InstallUpdateService.cs @@ -139,7 +139,7 @@ namespace NzbDrone.Core.Update // 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), "0755", 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/FileChmodValidator.cs index c9f0881a7..3e90bf9fa 100644 --- a/src/NzbDrone.Core/Validation/FileChmodValidator.cs +++ b/src/NzbDrone.Core/Validation/FileChmodValidator.cs @@ -3,11 +3,11 @@ using NzbDrone.Common.Disk; 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 @@ namespace NzbDrone.Core.Validation return false; } - return _diskProvider.IsValidFilePermissionMask(context.PropertyValue.ToString()); + return _diskProvider.IsValidFolderPermissionMask(context.PropertyValue.ToString()); } } } diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs index 19f008757..cb33e54f1 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs @@ -170,15 +170,15 @@ namespace NzbDrone.Mono.Test.DiskProviderTests 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"); } @@ -195,56 +195,49 @@ namespace NzbDrone.Mono.Test.DiskProviderTests 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"); + Subject.SetPermissions(tempPath, "0755", null); Syscall.stat(tempPath, out fileStat); NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755"); - Subject.SetPermissions(tempPath, "1664"); + Subject.SetPermissions(tempPath, "1775", null); 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, "0051", 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 IsValidFolderPermissionMask_should_return_correct() { - // 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(); - // 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(); - - // 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(); + Subject.IsValidFolderPermissionMask("1755").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("2755").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("4755").Should().BeFalse(); + Subject.IsValidFolderPermissionMask("7755").Should().BeFalse(); + + // 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 2172cbc01..ed5848309 100644 --- a/src/NzbDrone.Mono/Disk/DiskProvider.cs +++ b/src/NzbDrone.Mono/Disk/DiskProvider.cs @@ -78,50 +78,66 @@ namespace NzbDrone.Mono.Disk { } - 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 (_fileSystem.Directory.Exists(path)) + if (_fileSystem.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); + } + + permissions |= curStat.st_mode & ~FilePermissions.ACCESSPERMS; + if (Syscall.chmod(path, permissions) < 0) { var error = Stdlib.GetLastError(); 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) - { - return false; - } - - if ((permissions & (FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH)) != 0) + if ((permissions & ~FilePermissions.ACCESSPERMS) != 0) { + // Only allow access permissions 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.Update/UpdateEngine/InstallUpdateService.cs b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs index 37908823f..45ae551ab 100644 --- a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs +++ b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs @@ -128,7 +128,7 @@ namespace NzbDrone.Update.UpdateEngine // Set executable flag on Readarr app if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore)) { - _diskProvider.SetPermissions(Path.Combine(installationFolder, "Readarr"), "0755"); + _diskProvider.SetPermissions(Path.Combine(installationFolder, "Readarr"), "0755", null); } } catch (Exception e) diff --git a/src/NzbDrone.Windows/Disk/DiskProvider.cs b/src/NzbDrone.Windows/Disk/DiskProvider.cs index 80dfaf0a8..d1fbfa100 100644 --- a/src/NzbDrone.Windows/Disk/DiskProvider.cs +++ b/src/NzbDrone.Windows/Disk/DiskProvider.cs @@ -102,7 +102,7 @@ namespace NzbDrone.Windows.Disk } } - public override void SetPermissions(string path, string mask) + public override void SetPermissions(string path, string mask, string group) { } diff --git a/src/Readarr.Api.V1/Config/MediaManagementConfigModule.cs b/src/Readarr.Api.V1/Config/MediaManagementConfigModule.cs index 3eaf411fd..fcc637946 100644 --- a/src/Readarr.Api.V1/Config/MediaManagementConfigModule.cs +++ b/src/Readarr.Api.V1/Config/MediaManagementConfigModule.cs @@ -8,11 +8,11 @@ namespace Readarr.Api.V1.Config { public class MediaManagementConfigModule : ReadarrConfigModule<MediaManagementConfigResource> { - 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/Readarr.Api.V1/Config/MediaManagementConfigResource.cs b/src/Readarr.Api.V1/Config/MediaManagementConfigResource.cs index 66f359cbc..81e781be5 100644 --- a/src/Readarr.Api.V1/Config/MediaManagementConfigResource.cs +++ b/src/Readarr.Api.V1/Config/MediaManagementConfigResource.cs @@ -19,7 +19,8 @@ namespace Readarr.Api.V1.Config public AllowFingerprinting AllowFingerprinting { 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 @@ namespace Readarr.Api.V1.Config AllowFingerprinting = model.AllowFingerprinting, SetPermissionsLinux = model.SetPermissionsLinux, - FileChmod = model.FileChmod, + ChmodFolder = model.ChmodFolder, + ChownGroup = model.ChownGroup, SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,