1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-18 21:35:51 -04:00

New: Dynamic Select and UMask Fields

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

Co-Authored-By: Taloth <Taloth@users.noreply.github.com>
This commit is contained in:
Qstick
2020-11-20 23:33:10 -05:00
parent 73ce77f1ca
commit 9c77399379
50 changed files with 1244 additions and 212 deletions
@@ -8,10 +8,10 @@ namespace NzbDrone.Api.Config
{
public class MediaManagementConfigModule : NzbDroneConfigModule<MediaManagementConfigResource>
{
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));
}
@@ -16,7 +16,8 @@ namespace NzbDrone.Api.Config
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 @@ namespace NzbDrone.Api.Config
AutoRenameFolders = model.AutoRenameFolders,
SetPermissionsLinux = model.SetPermissionsLinux,
FileChmod = model.FileChmod,
ChmodFolder = model.ChmodFolder,
ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
CopyUsingHardlinks = model.CopyUsingHardlinks,
+2 -2
View File
@@ -30,7 +30,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);
@@ -539,7 +539,7 @@ namespace NzbDrone.Common.Disk
}
}
public virtual bool IsValidFilePermissionMask(string mask)
public virtual bool IsValidFolderPermissionMask(string mask)
{
throw new NotSupportedException();
}
+2 -2
View File
@@ -11,7 +11,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);
@@ -56,6 +56,6 @@ namespace NzbDrone.Common.Disk
List<FileInfo> 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);
}
}
@@ -19,10 +19,10 @@ namespace NzbDrone.Core.Annotations
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 @@ namespace NzbDrone.Core.Annotations
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,
@@ -317,11 +317,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 int FirstDayOfWeek
@@ -45,7 +45,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; }
@@ -8,7 +8,7 @@ namespace NzbDrone.Core.Datastore.Migration
{
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')");
}
}
}
@@ -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();
}
}
}
}
}
}
@@ -91,8 +91,8 @@ namespace NzbDrone.Core.ImportLists.Radarr
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 @@ namespace NzbDrone.Core.ImportLists.Radarr
options = devices.OrderBy(d => d.Label, StringComparer.InvariantCultureIgnoreCase)
.Select(d => new
{
id = d.Id,
name = d.Label
Value = d.Id,
Name = d.Label
})
};
}
@@ -34,10 +34,10 @@ namespace NzbDrone.Core.ImportLists.Radarr
[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<int> ProfileIds { get; set; }
[FieldDefinition(3, Type = FieldType.Device, RequestAction = "getTags", Label = "Tags", HelpText = "Tags from the source instance to import from")]
[FieldDefinition(3, Type = FieldType.Select, SelectOptionsProviderAction = "getTags", Label = "Tags", HelpText = "Tags from the source instance to import from")]
public IEnumerable<int> TagIds { get; set; }
public NzbDroneValidationResult Validate()
@@ -321,7 +321,14 @@ namespace NzbDrone.Core.Indexers
_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())
{
@@ -159,5 +159,31 @@ namespace NzbDrone.Core.Indexers.Newznab
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
}
}
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "newznabCategories")
{
List<NewznabCategory> categories = null;
try
{
if (Settings.BaseUrl.IsNotNullOrWhiteSpace() && Settings.ApiPath.IsNotNullOrWhiteSpace())
{
categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
}
}
catch
{
// Use default categories
}
return new
{
options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
};
}
return base.RequestAction(action, query);
}
}
}
@@ -48,6 +48,7 @@ namespace NzbDrone.Core.Indexers.Newznab
}
var request = new HttpRequest(url, HttpAccept.Rss);
request.AllowAutoRedirect = true;
HttpResponse response;
@@ -76,6 +77,7 @@ namespace NzbDrone.Core.Indexers.Newznab
{
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;
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.Indexers.Newznab
{
public static class NewznabCategoryFieldOptionsConverter
{
public static List<FieldSelectOption> GetFieldSelectOptions(List<NewznabCategory> categories)
{
// Categories not relevant for Radarr
var ignoreCategories = new[] { 1000, 3000, 4000, 6000, 7000 };
// And maybe relevant for specific users
var unimportantCategories = new[] { 0, 5000 };
var result = new List<FieldSelectOption>();
if (categories == null)
{
// Fetching categories failed, use default Newznab categories
categories = new List<NewznabCategory>();
categories.Add(new NewznabCategory
{
Id = 2000,
Name = "Movies",
Subcategories = new List<NewznabCategory>
{
new NewznabCategory { Id = 2010, Name = "Foreign" },
new NewznabCategory { Id = 2020, Name = "Other" },
new NewznabCategory { Id = 2030, Name = "SD" },
new NewznabCategory { Id = 2040, Name = "HD" },
new NewznabCategory { Id = 2050, Name = "BluRay" },
new NewznabCategory { Id = 2060, Name = "3D" }
}
});
}
foreach (var category in categories.Where(cat => !ignoreCategories.Contains(cat.Id)).OrderBy(cat => unimportantCategories.Contains(cat.Id)).ThenBy(cat => cat.Id))
{
result.Add(new FieldSelectOption
{
Value = category.Id,
Name = category.Name,
Hint = $"({category.Id})"
});
if (category.Subcategories != null)
{
foreach (var subcat in category.Subcategories.OrderBy(cat => cat.Id))
{
result.Add(new FieldSelectOption
{
Value = subcat.Id,
Name = subcat.Name,
Hint = $"({subcat.Id})",
ParentValue = category.Id
});
}
}
}
return result;
}
}
}
@@ -75,7 +75,7 @@ namespace NzbDrone.Core.Indexers.Newznab
[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<int> Categories { get; set; }
[FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)]
@@ -148,5 +148,28 @@ namespace NzbDrone.Core.Indexers.Torznab
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
}
}
public override object RequestAction(string action, IDictionary<string, string> query)
{
if (action == "newznabCategories")
{
List<NewznabCategory> categories = null;
try
{
categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
}
catch
{
// Use default categories
}
return new
{
options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
};
}
return base.RequestAction(action, query);
}
}
}
+6 -3
View File
@@ -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",
@@ -205,8 +205,7 @@ namespace NzbDrone.Core.MediaFiles
try
{
var permissions = _configService.FileChmod;
_diskProvider.SetPermissions(path, permissions);
_diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
}
catch (Exception ex)
{
@@ -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)
{
@@ -28,7 +28,7 @@ namespace NzbDrone.Core.Notifications.PushBullet
[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<string> DeviceIds { get; set; }
[FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)]
@@ -149,7 +149,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), "755", null);
}
_logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime));
@@ -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());
}
}
}
@@ -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 @@ namespace NzbDrone.Integration.Test.ApiTests
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<JArray>();
}
[Test]
public void v3_categories_should_be_array()
{
var schema = GetNewznabSchemav3();
var categoriesField = GetCategoriesField(schema);
categoriesField.Value.Should().BeOfType<JArray>();
}
[Test]
public void v2_categories_should_accept_null()
{
var schema = GetNewznabSchemav2("Testv2null");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = null;
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v2_categories_should_accept_emptystring()
{
var schema = GetNewznabSchemav2("Testv2emptystring");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v2_categories_should_accept_string()
{
var schema = GetNewznabSchemav2("Testv2string");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "1000,1010";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
[Test]
public void v2_categories_should_accept_array()
{
var schema = GetNewznabSchemav2("Testv2array");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = new object[] { 1000, 1010 };
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
[Test]
public void v3_categories_should_accept_null()
{
var schema = GetNewznabSchemav3("Testv3null");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = null;
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v3_categories_should_accept_emptystring()
{
var schema = GetNewznabSchemav3("Testv3emptystring");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().Should().BeEmpty();
}
[Test]
public void v3_categories_should_accept_string()
{
var schema = GetNewznabSchemav3("Testv3string");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = "1000,1010";
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
[Test]
public void v3_categories_should_accept_array()
{
var schema = GetNewznabSchemav3("Testv3array");
var categoriesField = GetCategoriesField(schema);
categoriesField.Value = new object[] { 1000, 1010 };
var result = Indexers.Post(schema);
var resultArray = GetCategoriesField(result).Value;
resultArray.Should().BeOfType<JArray>();
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
}
}
}
@@ -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 @@ namespace NzbDrone.Integration.Test.Client
: base(restClient, apiKey)
{
}
public List<IndexerResource> Schema()
{
var request = BuildRequest("/schema");
return Get<List<IndexerResource>>(request);
}
}
}
@@ -17,11 +17,32 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
[Platform(Exclude = "Win")]
public class DiskProviderFixture : DiskProviderFixtureBase<DiskProvider>
{
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 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
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 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
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 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
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();
}
}
}
+33 -13
View File
@@ -61,15 +61,29 @@ 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 (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 @@ namespace NzbDrone.Mono.Disk
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;
}
+3 -3
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Runtime.ConstrainedExecution;
using System.Runtime.InteropServices;
using System.Security.Permissions;
@@ -25,13 +25,13 @@ namespace NzbDrone.Mono.Interop
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;
}
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
@@ -131,7 +131,7 @@ namespace NzbDrone.Update.UpdateEngine
// 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)
+1 -1
View File
@@ -91,7 +91,7 @@ namespace NzbDrone.Windows.Disk
}
}
public override void SetPermissions(string path, string mask)
public override void SetPermissions(string path, string mask, string group)
{
}
@@ -8,11 +8,11 @@ namespace Radarr.Api.V3.Config
{
public class MediaManagementConfigModule : RadarrConfigModule<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);
}
@@ -19,7 +19,8 @@ namespace Radarr.Api.V3.Config
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 @@ namespace Radarr.Api.V3.Config
AutoRenameFolders = model.AutoRenameFolders,
SetPermissionsLinux = model.SetPermissionsLinux,
FileChmod = model.FileChmod,
ChmodFolder = model.ChmodFolder,
ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,
+1 -1
View File
@@ -28,7 +28,7 @@ namespace Radarr.Api.V3
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;
+1 -1
View File
@@ -15,9 +15,9 @@ namespace Radarr.Http.ClientSchema
public string Type { get; set; }
public bool Advanced { get; set; }
public List<SelectOption> 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()
{
+19 -5
View File
@@ -100,13 +100,19 @@ namespace Radarr.Http.ClientSchema
Order = fieldAttribute.Order,
Advanced = fieldAttribute.Advanced,
Type = fieldAttribute.Type.ToString().FirstCharToLower(),
Section = fieldAttribute.Section,
RequestAction = fieldAttribute.RequestAction
Section = fieldAttribute.Section
};
if (fieldAttribute.Type == FieldType.Select || fieldAttribute.Type == FieldType.TagSelect)
{
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions);
if (fieldAttribute.SelectOptionsProviderAction.IsNotNullOrWhiteSpace())
{
field.SelectOptionsProviderAction = fieldAttribute.SelectOptionsProviderAction;
}
else
{
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions);
}
}
if (fieldAttribute.Hidden != HiddenType.Visible)
@@ -215,7 +221,11 @@ namespace Radarr.Http.ClientSchema
{
return fieldValue =>
{
if (fieldValue.GetType() == typeof(JArray))
if (fieldValue == null)
{
return Enumerable.Empty<int>();
}
else if (fieldValue.GetType() == typeof(JArray))
{
return ((JArray)fieldValue).Select(s => s.Value<int>());
}
@@ -229,7 +239,11 @@ namespace Radarr.Http.ClientSchema
{
return fieldValue =>
{
if (fieldValue.GetType() == typeof(JArray))
if (fieldValue == null)
{
return Enumerable.Empty<string>();
}
else if (fieldValue.GetType() == typeof(JArray))
{
return ((JArray)fieldValue).Select(s => s.Value<string>());
}
+8 -2
View File
@@ -2,6 +2,7 @@ using System;
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 @@ namespace Radarr.Http.REST
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 @@ namespace Radarr.Http.REST
throw new BadRequestException("Request body can't be empty");
}
var errors = SharedValidator.Validate(resource).Errors.ToList();
var errors = new List<ValidationFailure>();
if (!skipSharedValidate)
{
errors.AddRange(SharedValidator.Validate(resource).Errors);
}
if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase))
{