From 58ddbcd77e17ef95ecfad4b746084ee9326116f3 Mon Sep 17 00:00:00 2001 From: ta264 Date: Thu, 11 Mar 2021 20:56:24 +0000 Subject: [PATCH] New: Use ASP.NET Core instead of Nancy --- .../Actions/Settings/qualityDefinitions.js | 4 +- frontend/src/Store/Actions/commandActions.js | 3 +- frontend/src/Store/Actions/tagActions.js | 3 +- .../Serializer/System.Text.Json/STJson.cs | 35 +- src/NzbDrone.Host/MainAppContainerBuilder.cs | 4 - src/NzbDrone.Host/Readarr.Host.csproj | 1 - .../WebHost/ControllerActivator.cs | 26 ++ .../Middleware/IAspNetCoreMiddleware.cs | 10 - .../WebHost/Middleware/NancyMiddleware.cs | 29 -- .../WebHost/Middleware/SignalRMiddleware.cs | 70 ---- .../WebHost/WebHostController.cs | 138 ++++++- .../ApiTests/AuthorFixture.cs | 6 - .../ApiTests/DownloadClientFixture.cs | 8 - .../ApiTests/NamingConfigFixture.cs | 10 - .../ApiTests/RootFolderFixture.cs | 2 - .../Client/ClientBase.cs | 4 +- src/NzbDrone.Integration.Test/CorsFixture.cs | 3 + .../GenericApiFixture.cs | 4 +- .../HttpLogFixture.cs | 2 - .../IntegrationTestBase.cs | 18 - .../SymlinkResolverFixture.cs | 8 +- .../{AuthorModule.cs => AuthorController.cs} | 41 +- ...torModule.cs => AuthorEditorController.cs} | 25 +- .../Author/AuthorImportModule.cs | 28 -- ...kupModule.cs => AuthorLookupController.cs} | 14 +- .../Blacklist/BlacklistController.cs | 43 ++ .../Blacklist/BlacklistModule.cs | 42 -- ...ookFileModule.cs => BookFileController.cs} | 79 ++-- ...kshelfModule.cs => BookshelfController.cs} | 17 +- .../{BookModule.cs => BookController.cs} | 76 ++-- ...ignalR.cs => BookControllerWithSignalR.cs} | 29 +- ...ookupModule.cs => BookLookupController.cs} | 14 +- src/Readarr.Api.V1/Books/BookResource.cs | 1 - .../Books/RenameBookController.cs | 29 ++ src/Readarr.Api.V1/Books/RenameBookModule.cs | 42 -- ...agBookModule.cs => RetagBookController.cs} | 22 +- ...alendarModule.cs => CalendarController.cs} | 36 +- ...eedModule.cs => CalendarFeedController.cs} | 42 +- ...{CommandModule.cs => CommandController.cs} | 37 +- src/Readarr.Api.V1/Config/ConfigController.cs | 48 +++ ...e.cs => DownloadClientConfigController.cs} | 6 +- ...onfigModule.cs => HostConfigController.cs} | 37 +- ...igModule.cs => IndexerConfigController.cs} | 6 +- ....cs => MediaManagementConfigController.cs} | 6 +- ...cs => MetadataProviderConfigController.cs} | 6 +- ...figModule.cs => NamingConfigController.cs} | 38 +- .../Config/ReadarrConfigModule.cs | 53 --- ...iConfigModule.cs => UiConfigController.cs} | 8 +- .../CustomFilters/CustomFilterController.cs | 52 +++ .../CustomFilters/CustomFilterModule.cs | 49 --- ...kSpaceModule.cs => DiskSpaceController.cs} | 11 +- ...tModule.cs => DownloadClientController.cs} | 8 +- ...ystemModule.cs => FileSystemController.cs} | 34 +- .../{HealthModule.cs => HealthController.cs} | 20 +- ...{HistoryModule.cs => HistoryController.cs} | 74 +--- ...tListModule.cs => ImportListController.cs} | 6 +- ...le.cs => ImportListExclusionController.cs} | 31 +- ...{IndexerModule.cs => IndexerController.cs} | 8 +- ...{ReleaseModule.cs => ReleaseController.cs} | 30 +- .../Indexers/ReleaseModuleBase.cs | 10 +- ...PushModule.cs => ReleasePushController.cs} | 14 +- .../Logs/{LogModule.cs => LogController.cs} | 14 +- ...{LogFileModule.cs => LogFileController.cs} | 8 +- src/Readarr.Api.V1/Logs/LogFileModuleBase.cs | 30 +- ...leModule.cs => UpdateLogFileController.cs} | 10 +- ...ortModule.cs => ManualImportController.cs} | 35 +- ...CoverModule.cs => MediaCoverController.cs} | 43 +- ...etadataModule.cs => MetadataController.cs} | 8 +- ...ionModule.cs => NotificationController.cs} | 8 +- .../{ParseModule.cs => ParseController.cs} | 12 +- ...ileModule.cs => DelayProfileController.cs} | 39 +- ...Module.cs => MetadataProfileController.cs} | 32 +- ....cs => MetadataProfileSchemaController.cs} | 13 +- ...eModule.cs => QualityProfileController.cs} | 32 +- ...e.cs => QualityProfileSchemaController.cs} | 11 +- ...eModule.cs => ReleaseProfileController.cs} | 31 +- src/Readarr.Api.V1/ProviderModuleBase.cs | 66 ++-- .../Qualities/QualityDefinitionController.cs | 54 +++ .../Qualities/QualityDefinitionModule.cs | 54 --- .../Queue/QueueActionController.cs | 55 +++ src/Readarr.Api.V1/Queue/QueueActionModule.cs | 184 --------- .../{QueueModule.cs => QueueController.cs} | 138 ++++++- ...ilsModule.cs => QueueDetailsController.cs} | 38 +- ...atusModule.cs => QueueStatusController.cs} | 20 +- src/Readarr.Api.V1/Readarr.Api.V1.csproj | 4 +- src/Readarr.Api.V1/ReadarrV1FeedModule.cs | 12 - src/Readarr.Api.V1/ReadarrV1Module.cs | 12 - ...dule.cs => RemotePathMappingController.cs} | 34 +- ...olderModule.cs => RootFolderController.cs} | 31 +- .../{SearchModule.cs => SearchController.cs} | 14 +- src/Readarr.Api.V1/Series/SeriesController.cs | 24 ++ src/Readarr.Api.V1/Series/SeriesModule.cs | 35 -- .../{BackupModule.cs => BackupController.cs} | 25 +- .../{SystemModule.cs => SystemController.cs} | 68 ++-- .../{TaskModule.cs => TaskController.cs} | 17 +- .../Tags/{TagModule.cs => TagController.cs} | 33 +- ...tailsModule.cs => TagDetailsController.cs} | 16 +- .../{UpdateModule.cs => UpdateController.cs} | 12 +- .../{CutoffModule.cs => CutoffController.cs} | 18 +- ...{MissingModule.cs => MissingController.cs} | 18 +- .../ApiKeyAuthenticationHandler.cs | 89 +++++ .../AuthenticationBuilderExtensions.cs | 65 +++ .../AuthenticationController.cs | 58 +++ .../Authentication/AuthenticationModule.cs | 50 --- .../Authentication/AuthenticationService.cs | 177 +-------- .../BasicAuthenticationHandler.cs | 84 ++++ .../Authentication/EnableAuthInNancy.cs | 143 ------- .../Authentication/LoginResource.cs | 4 +- .../Authentication/NoAuthenticationHandler.cs | 37 ++ .../ErrorManagement/ErrorHandler.cs | 41 -- .../ErrorManagement/ErrorModel.cs | 13 +- .../ErrorManagement/ReadarrErrorPipeline.cs | 80 ++-- src/Readarr.Http/Exceptions/ApiException.cs | 10 +- .../Extensions/NancyJsonSerializer.cs | 31 -- .../Pipelines/CacheHeaderPipeline.cs | 40 -- .../Extensions/Pipelines/CorsPipeline.cs | 80 ---- .../Extensions/Pipelines/GZipPipeline.cs | 104 ----- .../Pipelines/IRegisterNancyPipeline.cs | 11 - .../Pipelines/IfModifiedPipeline.cs | 35 -- .../Pipelines/ReadarrVersionPipeline.cs | 24 -- .../Pipelines/RequestLoggingPipeline.cs | 105 ----- .../Extensions/Pipelines/UrlBasePipeline.cs | 45 --- .../Extensions/ReqResExtensions.cs | 61 --- .../Extensions/RequestExtensions.cs | 189 ++++++--- .../Frontend/CacheableSpecification.cs | 74 ---- ...eJsModule.cs => InitializeJsController.cs} | 34 +- .../Frontend/Mappers/HtmlMapperBase.cs | 6 +- .../Mappers/IMapHttpRequestsToDisk.cs | 4 +- .../Frontend/Mappers/LoginHtmlMapper.cs | 33 -- .../Mappers/StaticResourceMapperBase.cs | 20 +- .../Frontend/StaticResourceController.cs | 73 ++++ .../Frontend/StaticResourceModule.cs | 48 --- .../Middleware/CacheHeaderMiddleware.cs | 35 ++ .../Middleware/CacheableSpecification.cs | 74 ++++ .../Middleware/IfModifiedMiddleware.cs | 43 ++ .../Middleware/LoggingMiddleware.cs | 92 +++++ .../Middleware/UrlBaseMiddleware.cs | 29 ++ .../Middleware/VersionMiddleware.cs | 30 ++ .../Attributes/RestDeleteByIdAttribute.cs | 14 + .../REST/Attributes/RestGetByIdAttribute.cs | 21 + .../REST/Attributes/RestPostByIdAttribute.cs | 10 + .../REST/Attributes/RestPutByIdAttribute.cs | 14 + .../Attributes/SkipValidationAttribute.cs | 17 + src/Readarr.Http/REST/BadRequestException.cs | 2 +- .../REST/MethodNotAllowedException.cs | 2 +- src/Readarr.Http/REST/NotFoundException.cs | 2 +- src/Readarr.Http/REST/RestController.cs | 130 ++++++ .../RestControllerWithSignalR.cs} | 25 +- src/Readarr.Http/REST/RestModule.cs | 373 ------------------ .../REST/UnsupportedMediaTypeException.cs | 2 +- src/Readarr.Http/Readarr.Http.csproj | 4 +- src/Readarr.Http/ReadarrBootstrapper.cs | 75 ---- src/Readarr.Http/ReadarrModule.cs | 18 - src/Readarr.Http/ReadarrRestModule.cs | 58 --- src/Readarr.Http/TinyIoCNancyBootstrapper.cs | 273 ------------- .../Validation/DuplicateEndpointDetector.cs | 121 ++++++ .../VersionedApiControllerAttribute.cs | 34 ++ .../VersionedFeedControllerAttribute.cs | 27 ++ 158 files changed, 2732 insertions(+), 3529 deletions(-) create mode 100644 src/NzbDrone.Host/WebHost/ControllerActivator.cs delete mode 100644 src/NzbDrone.Host/WebHost/Middleware/IAspNetCoreMiddleware.cs delete mode 100644 src/NzbDrone.Host/WebHost/Middleware/NancyMiddleware.cs delete mode 100644 src/NzbDrone.Host/WebHost/Middleware/SignalRMiddleware.cs rename src/Readarr.Api.V1/Author/{AuthorModule.cs => AuthorController.cs} (91%) rename src/Readarr.Api.V1/Author/{AuthorEditorModule.cs => AuthorEditorController.cs} (78%) delete mode 100644 src/Readarr.Api.V1/Author/AuthorImportModule.cs rename src/Readarr.Api.V1/Author/{AuthorLookupModule.cs => AuthorLookupController.cs} (77%) create mode 100644 src/Readarr.Api.V1/Blacklist/BlacklistController.cs delete mode 100644 src/Readarr.Api.V1/Blacklist/BlacklistModule.cs rename src/Readarr.Api.V1/BookFiles/{BookFileModule.cs => BookFileController.cs} (66%) rename src/Readarr.Api.V1/BookShelf/{BookshelfModule.cs => BookshelfController.cs} (70%) rename src/Readarr.Api.V1/Books/{BookModule.cs => BookController.cs} (78%) rename src/Readarr.Api.V1/Books/{BookModuleWithSignalR.cs => BookControllerWithSignalR.cs} (78%) rename src/Readarr.Api.V1/Books/{BookLookupModule.cs => BookLookupController.cs} (73%) create mode 100644 src/Readarr.Api.V1/Books/RenameBookController.cs delete mode 100644 src/Readarr.Api.V1/Books/RenameBookModule.cs rename src/Readarr.Api.V1/Books/{RetagBookModule.cs => RetagBookController.cs} (53%) rename src/Readarr.Api.V1/Calendar/{CalendarModule.cs => CalendarController.cs} (54%) rename src/Readarr.Api.V1/Calendar/{CalendarFeedModule.cs => CalendarFeedController.cs} (64%) rename src/Readarr.Api.V1/Commands/{CommandModule.cs => CommandController.cs} (75%) create mode 100644 src/Readarr.Api.V1/Config/ConfigController.cs rename src/Readarr.Api.V1/Config/{DownloadClientConfigModule.cs => DownloadClientConfigController.cs} (57%) rename src/Readarr.Api.V1/Config/{HostConfigModule.cs => HostConfigController.cs} (83%) rename src/Readarr.Api.V1/Config/{IndexerConfigModule.cs => IndexerConfigController.cs} (79%) rename src/Readarr.Api.V1/Config/{MediaManagementConfigModule.cs => MediaManagementConfigController.cs} (74%) rename src/Readarr.Api.V1/Config/{MetadataProviderConfigModule.cs => MetadataProviderConfigController.cs} (68%) rename src/Readarr.Api.V1/Config/{NamingConfigModule.cs => NamingConfigController.cs} (82%) delete mode 100644 src/Readarr.Api.V1/Config/ReadarrConfigModule.cs rename src/Readarr.Api.V1/Config/{UiConfigModule.cs => UiConfigController.cs} (53%) create mode 100644 src/Readarr.Api.V1/CustomFilters/CustomFilterController.cs delete mode 100644 src/Readarr.Api.V1/CustomFilters/CustomFilterModule.cs rename src/Readarr.Api.V1/DiskSpace/{DiskSpaceModule.cs => DiskSpaceController.cs} (62%) rename src/Readarr.Api.V1/DownloadClient/{DownloadClientModule.cs => DownloadClientController.cs} (64%) rename src/Readarr.Api.V1/FileSystem/{FileSystemModule.cs => FileSystemController.cs} (54%) rename src/Readarr.Api.V1/Health/{HealthModule.cs => HealthController.cs} (54%) rename src/Readarr.Api.V1/History/{HistoryModule.cs => HistoryController.cs} (54%) rename src/Readarr.Api.V1/ImportLists/{ImportListModule.cs => ImportListController.cs} (84%) rename src/Readarr.Api.V1/ImportLists/{ImportListExclusionModule.cs => ImportListExclusionController.cs} (56%) rename src/Readarr.Api.V1/Indexers/{IndexerModule.cs => IndexerController.cs} (67%) rename src/Readarr.Api.V1/Indexers/{ReleaseModule.cs => ReleaseController.cs} (91%) rename src/Readarr.Api.V1/Indexers/{ReleasePushModule.cs => ReleasePushController.cs} (90%) rename src/Readarr.Api.V1/Logs/{LogModule.cs => LogController.cs} (82%) rename src/Readarr.Api.V1/Logs/{LogFileModule.cs => LogFileController.cs} (83%) rename src/Readarr.Api.V1/Logs/{UpdateLogFileModule.cs => UpdateLogFileController.cs} (83%) rename src/Readarr.Api.V1/ManualImport/{ManualImportModule.cs => ManualImportController.cs} (71%) rename src/Readarr.Api.V1/MediaCovers/{MediaCoverModule.cs => MediaCoverController.cs} (62%) rename src/Readarr.Api.V1/Metadata/{MetadataModule.cs => MetadataController.cs} (65%) rename src/Readarr.Api.V1/Notifications/{NotificationModule.cs => NotificationController.cs} (64%) rename src/Readarr.Api.V1/Parse/{ParseModule.cs => ParseController.cs} (80%) rename src/Readarr.Api.V1/Profiles/Delay/{DelayProfileModule.cs => DelayProfileController.cs} (65%) rename src/Readarr.Api.V1/Profiles/Metadata/{MetadataProfileModule.cs => MetadataProfileController.cs} (53%) rename src/Readarr.Api.V1/Profiles/Metadata/{MetadataProfileSchemaModule.cs => MetadataProfileSchemaController.cs} (51%) rename src/Readarr.Api.V1/Profiles/Quality/{QualityProfileModule.cs => QualityProfileController.cs} (54%) rename src/Readarr.Api.V1/Profiles/Quality/{QualityProfileSchemaModule.cs => QualityProfileSchemaController.cs} (57%) rename src/Readarr.Api.V1/Profiles/Release/{ReleaseProfileModule.cs => ReleaseProfileController.cs} (62%) create mode 100644 src/Readarr.Api.V1/Qualities/QualityDefinitionController.cs delete mode 100644 src/Readarr.Api.V1/Qualities/QualityDefinitionModule.cs create mode 100644 src/Readarr.Api.V1/Queue/QueueActionController.cs delete mode 100644 src/Readarr.Api.V1/Queue/QueueActionModule.cs rename src/Readarr.Api.V1/Queue/{QueueModule.cs => QueueController.cs} (56%) rename src/Readarr.Api.V1/Queue/{QueueDetailsModule.cs => QueueDetailsController.cs} (52%) rename src/Readarr.Api.V1/Queue/{QueueStatusModule.cs => QueueStatusController.cs} (77%) delete mode 100644 src/Readarr.Api.V1/ReadarrV1FeedModule.cs delete mode 100644 src/Readarr.Api.V1/ReadarrV1Module.cs rename src/Readarr.Api.V1/RemotePathMappings/{RemotePathMappingModule.cs => RemotePathMappingController.cs} (60%) rename src/Readarr.Api.V1/RootFolders/{RootFolderModule.cs => RootFolderController.cs} (86%) rename src/Readarr.Api.V1/Search/{SearchModule.cs => SearchController.cs} (88%) create mode 100644 src/Readarr.Api.V1/Series/SeriesController.cs delete mode 100644 src/Readarr.Api.V1/Series/SeriesModule.cs rename src/Readarr.Api.V1/System/Backup/{BackupModule.cs => BackupController.cs} (84%) rename src/Readarr.Api.V1/System/{SystemModule.cs => SystemController.cs} (62%) rename src/Readarr.Api.V1/System/Tasks/{TaskModule.cs => TaskController.cs} (76%) rename src/Readarr.Api.V1/Tags/{TagModule.cs => TagController.cs} (51%) rename src/Readarr.Api.V1/Tags/{TagDetailsModule.cs => TagDetailsController.cs} (53%) rename src/Readarr.Api.V1/Update/{UpdateModule.cs => UpdateController.cs} (79%) rename src/Readarr.Api.V1/Wanted/{CutoffModule.cs => CutoffController.cs} (76%) rename src/Readarr.Api.V1/Wanted/{MissingModule.cs => MissingController.cs} (75%) create mode 100644 src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs create mode 100644 src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs create mode 100644 src/Readarr.Http/Authentication/AuthenticationController.cs delete mode 100644 src/Readarr.Http/Authentication/AuthenticationModule.cs create mode 100644 src/Readarr.Http/Authentication/BasicAuthenticationHandler.cs delete mode 100644 src/Readarr.Http/Authentication/EnableAuthInNancy.cs create mode 100644 src/Readarr.Http/Authentication/NoAuthenticationHandler.cs delete mode 100644 src/Readarr.Http/ErrorManagement/ErrorHandler.cs delete mode 100644 src/Readarr.Http/Extensions/NancyJsonSerializer.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/CorsPipeline.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/GZipPipeline.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/ReadarrVersionPipeline.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs delete mode 100644 src/Readarr.Http/Extensions/Pipelines/UrlBasePipeline.cs delete mode 100644 src/Readarr.Http/Extensions/ReqResExtensions.cs delete mode 100644 src/Readarr.Http/Frontend/CacheableSpecification.cs rename src/Readarr.Http/Frontend/{InitializeJsModule.cs => InitializeJsController.cs} (72%) delete mode 100644 src/Readarr.Http/Frontend/Mappers/LoginHtmlMapper.cs create mode 100644 src/Readarr.Http/Frontend/StaticResourceController.cs delete mode 100644 src/Readarr.Http/Frontend/StaticResourceModule.cs create mode 100644 src/Readarr.Http/Middleware/CacheHeaderMiddleware.cs create mode 100644 src/Readarr.Http/Middleware/CacheableSpecification.cs create mode 100644 src/Readarr.Http/Middleware/IfModifiedMiddleware.cs create mode 100644 src/Readarr.Http/Middleware/LoggingMiddleware.cs create mode 100644 src/Readarr.Http/Middleware/UrlBaseMiddleware.cs create mode 100644 src/Readarr.Http/Middleware/VersionMiddleware.cs create mode 100644 src/Readarr.Http/REST/Attributes/RestDeleteByIdAttribute.cs create mode 100644 src/Readarr.Http/REST/Attributes/RestGetByIdAttribute.cs create mode 100644 src/Readarr.Http/REST/Attributes/RestPostByIdAttribute.cs create mode 100644 src/Readarr.Http/REST/Attributes/RestPutByIdAttribute.cs create mode 100644 src/Readarr.Http/REST/Attributes/SkipValidationAttribute.cs create mode 100644 src/Readarr.Http/REST/RestController.cs rename src/Readarr.Http/{ReadarrRestModuleWithSignalR.cs => REST/RestControllerWithSignalR.cs} (76%) delete mode 100644 src/Readarr.Http/REST/RestModule.cs delete mode 100644 src/Readarr.Http/ReadarrBootstrapper.cs delete mode 100644 src/Readarr.Http/ReadarrModule.cs delete mode 100644 src/Readarr.Http/ReadarrRestModule.cs delete mode 100644 src/Readarr.Http/TinyIoCNancyBootstrapper.cs create mode 100644 src/Readarr.Http/Validation/DuplicateEndpointDetector.cs create mode 100644 src/Readarr.Http/VersionedApiControllerAttribute.cs create mode 100644 src/Readarr.Http/VersionedFeedControllerAttribute.cs diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js index b17ec01de..09317ca07 100644 --- a/frontend/src/Store/Actions/Settings/qualityDefinitions.js +++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js @@ -78,7 +78,9 @@ export default { const promise = createAjaxRequest({ method: 'PUT', url: '/qualityDefinition/update', - data: JSON.stringify(upatedDefinitions) + data: JSON.stringify(upatedDefinitions), + contentType: 'application/json', + dataType: 'json' }).request; promise.done((data) => { diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js index 27583c299..b6e8c06cf 100644 --- a/frontend/src/Store/Actions/commandActions.js +++ b/frontend/src/Store/Actions/commandActions.js @@ -141,7 +141,8 @@ export function executeCommandHelper( payload, dispatch) { const promise = createAjaxRequest({ url: '/command', method: 'POST', - data: JSON.stringify(payload) + data: JSON.stringify(payload), + dataType: 'json' }).request; return promise.then((data) => { diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js index 3b92eb8a4..6800b1d58 100644 --- a/frontend/src/Store/Actions/tagActions.js +++ b/frontend/src/Store/Actions/tagActions.js @@ -53,7 +53,8 @@ export const actionHandlers = handleThunks({ const promise = createAjaxRequest({ url: '/tag', method: 'POST', - data: JSON.stringify(payload.tag) + data: JSON.stringify(payload.tag), + dataType: 'json' }).request; promise.done((data) => { diff --git a/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs b/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs index 48b98cf6d..6b28a17ac 100644 --- a/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs +++ b/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace NzbDrone.Common.Serializer { @@ -15,23 +16,25 @@ namespace NzbDrone.Common.Serializer public static JsonSerializerOptions GetSerializerSettings() { - var serializerSettings = new JsonSerializerOptions - { - AllowTrailingCommas = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNameCaseInsensitive = true, - DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }; + var settings = new JsonSerializerOptions(); + ApplySerializerSettings(settings); + return settings; + } + + public static void ApplySerializerSettings(JsonSerializerOptions serializerSettings) + { + serializerSettings.AllowTrailingCommas = true; + serializerSettings.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + serializerSettings.PropertyNameCaseInsensitive = true; + serializerSettings.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + serializerSettings.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + serializerSettings.WriteIndented = true; serializerSettings.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, true)); serializerSettings.Converters.Add(new STJVersionConverter()); serializerSettings.Converters.Add(new STJHttpUriConverter()); serializerSettings.Converters.Add(new STJTimeSpanConverter()); serializerSettings.Converters.Add(new STJUtcConverter()); - - return serializerSettings; } public static T Deserialize(string json) @@ -84,5 +87,15 @@ namespace NzbDrone.Common.Serializer JsonSerializer.Serialize(writer, (object)model, options); } } + + public static Task SerializeAsync(TModel model, Stream outputStream, JsonSerializerOptions options = null) + { + if (options == null) + { + options = SerializerSettings; + } + + return JsonSerializer.SerializeAsync(outputStream, (object)model, options); + } } } diff --git a/src/NzbDrone.Host/MainAppContainerBuilder.cs b/src/NzbDrone.Host/MainAppContainerBuilder.cs index 93eb703ac..0f8703908 100644 --- a/src/NzbDrone.Host/MainAppContainerBuilder.cs +++ b/src/NzbDrone.Host/MainAppContainerBuilder.cs @@ -1,9 +1,7 @@ using System.Collections.Generic; -using Nancy.Bootstrapper; using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.SignalR; -using Readarr.Http; namespace NzbDrone.Host { @@ -28,8 +26,6 @@ namespace NzbDrone.Host { AutoRegisterImplementations(); - Container.Register(); - if (OsInfo.IsWindows) { Container.Register(); diff --git a/src/NzbDrone.Host/Readarr.Host.csproj b/src/NzbDrone.Host/Readarr.Host.csproj index 0af87f14a..6e10859dc 100644 --- a/src/NzbDrone.Host/Readarr.Host.csproj +++ b/src/NzbDrone.Host/Readarr.Host.csproj @@ -4,7 +4,6 @@ Library - diff --git a/src/NzbDrone.Host/WebHost/ControllerActivator.cs b/src/NzbDrone.Host/WebHost/ControllerActivator.cs new file mode 100644 index 000000000..ad90106fa --- /dev/null +++ b/src/NzbDrone.Host/WebHost/ControllerActivator.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using NzbDrone.Common.Composition; + +namespace NzbDrone.Host +{ + public class ControllerActivator : IControllerActivator + { + private readonly IContainer _container; + + public ControllerActivator(IContainer container) + { + _container = container; + } + + public object Create(ControllerContext context) + { + return _container.Resolve(context.ActionDescriptor.ControllerTypeInfo.AsType()); + } + + public void Release(ControllerContext context, object controller) + { + // Nothing to do + } + } +} diff --git a/src/NzbDrone.Host/WebHost/Middleware/IAspNetCoreMiddleware.cs b/src/NzbDrone.Host/WebHost/Middleware/IAspNetCoreMiddleware.cs deleted file mode 100644 index 8121fd19b..000000000 --- a/src/NzbDrone.Host/WebHost/Middleware/IAspNetCoreMiddleware.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace NzbDrone.Host.Middleware -{ - public interface IAspNetCoreMiddleware - { - int Order { get; } - void Attach(IApplicationBuilder appBuilder); - } -} diff --git a/src/NzbDrone.Host/WebHost/Middleware/NancyMiddleware.cs b/src/NzbDrone.Host/WebHost/Middleware/NancyMiddleware.cs deleted file mode 100644 index 489bea524..000000000 --- a/src/NzbDrone.Host/WebHost/Middleware/NancyMiddleware.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Nancy.Bootstrapper; -using Nancy.Owin; - -namespace NzbDrone.Host.Middleware -{ - public class NancyMiddleware : IAspNetCoreMiddleware - { - private readonly INancyBootstrapper _nancyBootstrapper; - - public int Order => 2; - - public NancyMiddleware(INancyBootstrapper nancyBootstrapper) - { - _nancyBootstrapper = nancyBootstrapper; - } - - public void Attach(IApplicationBuilder appBuilder) - { - var options = new NancyOptions - { - Bootstrapper = _nancyBootstrapper, - PerformPassThrough = context => context.Request.Path.StartsWith("/signalr") - }; - - appBuilder.UseOwin(x => x.UseNancy(options)); - } - } -} diff --git a/src/NzbDrone.Host/WebHost/Middleware/SignalRMiddleware.cs b/src/NzbDrone.Host/WebHost/Middleware/SignalRMiddleware.cs deleted file mode 100644 index 84d967076..000000000 --- a/src/NzbDrone.Host/WebHost/Middleware/SignalRMiddleware.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.DependencyInjection; -using NLog; -using NzbDrone.Common.Composition; -using NzbDrone.Core.Configuration; -using NzbDrone.SignalR; - -namespace NzbDrone.Host.Middleware -{ - public class SignalRMiddleware : IAspNetCoreMiddleware - { - private readonly IContainer _container; - private readonly Logger _logger; - private static string API_KEY; - private static string URL_BASE; - public int Order => 1; - - public SignalRMiddleware(IContainer container, - IConfigFileProvider configFileProvider, - Logger logger) - { - _container = container; - _logger = logger; - API_KEY = configFileProvider.ApiKey; - URL_BASE = configFileProvider.UrlBase; - } - - public void Attach(IApplicationBuilder appBuilder) - { - appBuilder.UseWebSockets(); - - appBuilder.Use(async (context, next) => - { - if (context.Request.Path.StartsWithSegments("/signalr") && - !context.Request.Path.Value.EndsWith("/negotiate") && - (!context.Request.Query.ContainsKey("access_token") || - context.Request.Query["access_token"] != API_KEY)) - { - context.Response.StatusCode = 401; - await context.Response.WriteAsync("Unauthorized"); - return; - } - - try - { - await next(); - } - catch (OperationCanceledException e) - { - // Demote the exception to trace logging so users don't worry (as much). - _logger.Trace(e); - } - }); - - appBuilder.UseEndpoints(x => - { - x.MapHub(URL_BASE + "/signalr/messages"); - }); - - // This is a side effect of haing multiple IoC containers, TinyIoC and whatever - // Kestrel/SignalR is using. Ideally we'd have one IoC container, but that's non-trivial with TinyIoC - // TODO: Use a single IoC container if supported for TinyIoC or if we switch to another system (ie Autofac). - var hubContext = appBuilder.ApplicationServices.GetService>(); - _container.Register(hubContext); - } - } -} diff --git a/src/NzbDrone.Host/WebHost/WebHostController.cs b/src/NzbDrone.Host/WebHost/WebHostController.cs index 46e03bd86..7ddd33975 100644 --- a/src/NzbDrone.Host/WebHost/WebHostController.cs +++ b/src/NzbDrone.Host/WebHost/WebHostController.cs @@ -4,42 +4,59 @@ using System.IO; using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Internal; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NLog; using NLog.Extensions.Logging; +using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Exceptions; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; using NzbDrone.Host.AccessControl; -using NzbDrone.Host.Middleware; +using NzbDrone.SignalR; +using Readarr.Api.V1.System; +using Readarr.Http; +using Readarr.Http.Authentication; +using Readarr.Http.ErrorManagement; +using Readarr.Http.Frontend; +using Readarr.Http.Middleware; using LogLevel = Microsoft.Extensions.Logging.LogLevel; namespace NzbDrone.Host { public class WebHostController : IHostController { + private readonly IContainer _container; private readonly IRuntimeInfo _runtimeInfo; private readonly IConfigFileProvider _configFileProvider; private readonly IFirewallAdapter _firewallAdapter; - private readonly IEnumerable _middlewares; + private readonly ReadarrErrorPipeline _errorHandler; private readonly Logger _logger; private IWebHost _host; - public WebHostController(IRuntimeInfo runtimeInfo, + public WebHostController(IContainer container, + IRuntimeInfo runtimeInfo, IConfigFileProvider configFileProvider, IFirewallAdapter firewallAdapter, - IEnumerable middlewares, + ReadarrErrorPipeline errorHandler, Logger logger) { + _container = container; _runtimeInfo = runtimeInfo; _configFileProvider = configFileProvider; _firewallAdapter = firewallAdapter; - _middlewares = middlewares; + _errorHandler = errorHandler; _logger = logger; } @@ -103,24 +120,125 @@ namespace NzbDrone.Host }) .ConfigureServices(services => { + // So that we can resolve containers with our TinyIoC services + services.AddSingleton(_container); + services.AddSingleton(); + + // Bits used in our custom middleware + services.AddSingleton(_container.Resolve()); + services.AddSingleton(_container.Resolve()); + + // Used in authentication + services.AddSingleton(_container.Resolve()); + + services.AddRouting(options => options.LowercaseUrls = true); + + services.AddResponseCompression(); + + services.AddCors(options => + { + options.AddPolicy(VersionedApiControllerAttribute.API_CORS_POLICY, + builder => + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); + + options.AddPolicy("AllowGet", + builder => + builder.AllowAnyOrigin() + .WithMethods("GET", "OPTIONS") + .AllowAnyHeader()); + }); + + services + .AddControllers(options => + { + options.ReturnHttpNotAcceptable = true; + }) + .AddApplicationPart(typeof(SystemController).Assembly) + .AddApplicationPart(typeof(StaticResourceController).Assembly) + .AddJsonOptions(options => + { + STJson.ApplySerializerSettings(options.JsonSerializerOptions); + }); + services .AddSignalR() .AddJsonProtocol(options => { options.PayloadSerializerOptions = STJson.GetSerializerSettings(); }); + + services.AddAuthorization(options => + { + options.AddPolicy("UI", policy => + { + policy.AuthenticationSchemes.Add(_configFileProvider.AuthenticationMethod.ToString()); + policy.RequireAuthenticatedUser(); + }); + + options.AddPolicy("SignalR", policy => + { + policy.AuthenticationSchemes.Add("SignalR"); + policy.RequireAuthenticatedUser(); + }); + + // Require auth on everything except those marked [AllowAnonymous] + options.FallbackPolicy = new AuthorizationPolicyBuilder("API") + .RequireAuthenticatedUser() + .Build(); + }); + + services.AddAppAuthentication(_configFileProvider); }) .Configure(app => { + app.UseMiddleware(); + app.UsePathBase(new PathString(_configFileProvider.UrlBase)); + app.UseExceptionHandler(new ExceptionHandlerOptions + { + AllowStatusCode404Response = true, + ExceptionHandler = _errorHandler.HandleException + }); + app.UseRouting(); + app.UseCors(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseResponseCompression(); app.Properties["host.AppName"] = BuildInfo.AppName; - app.UsePathBase(_configFileProvider.UrlBase); - foreach (var middleWare in _middlewares.OrderBy(c => c.Order)) + app.UseMiddleware(); + app.UseMiddleware(_configFileProvider.UrlBase); + app.UseMiddleware(); + app.UseMiddleware(); + + app.Use((context, next) => { - _logger.Debug("Attaching {0} to host", middleWare.GetType().Name); - middleWare.Attach(app); - } + if (context.Request.Path.StartsWithSegments("/api/v1/command", StringComparison.CurrentCultureIgnoreCase)) + { + context.Request.EnableBuffering(); + } + + return next(); + }); + + app.UseWebSockets(); + + app.UseEndpoints(x => + { + x.MapHub("/signalr/messages").RequireAuthorization("SignalR"); + x.MapControllers(); + }); + + // This is a side effect of haing multiple IoC containers, TinyIoC and whatever + // Kestrel/SignalR is using. Ideally we'd have one IoC container, but that's non-trivial with TinyIoC + // TODO: Use a single IoC container if supported for TinyIoC or if we switch to another system (ie Autofac). + _container.Register(app.ApplicationServices); + _container.Register(app.ApplicationServices.GetService>()); + _container.Register(app.ApplicationServices.GetService()); + _container.Register(app.ApplicationServices.GetService()); + _container.Register(app.ApplicationServices.GetService()); }) .UseContentRoot(Directory.GetCurrentDirectory()) .Build(); diff --git a/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs index 4042e469a..440feb8ec 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs @@ -34,8 +34,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(0)] public void add_author_without_profileid_should_return_badrequest() { - IgnoreOnMonoVersions("5.12", "5.14"); - EnsureNoAuthor("14586394", "Andrew Hunter Murray"); var author = Author.Lookup("readarr:43765115").Single(); @@ -49,8 +47,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(0)] public void add_author_without_path_should_return_badrequest() { - IgnoreOnMonoVersions("5.12", "5.14"); - EnsureNoAuthor("14586394", "Andrew Hunter Murray"); var author = Author.Lookup("readarr:43765115").Single(); @@ -109,8 +105,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void get_author_by_unknown_id_should_return_404() { - IgnoreOnMonoVersions("5.12", "5.14"); - var result = Author.InvalidGet(1000000); } diff --git a/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs index 4fe766393..e4f018ad7 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs @@ -11,8 +11,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(0)] public void add_downloadclient_without_name_should_return_badrequest() { - IgnoreOnMonoVersions("5.12", "5.14"); - EnsureNoDownloadClient(); var schema = DownloadClients.Schema().First(v => v.Implementation == "UsenetBlackhole"); @@ -28,8 +26,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(0)] public void add_downloadclient_without_nzbfolder_should_return_badrequest() { - IgnoreOnMonoVersions("5.12", "5.14"); - EnsureNoDownloadClient(); var schema = DownloadClients.Schema().First(v => v.Implementation == "UsenetBlackhole"); @@ -45,8 +41,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(0)] public void add_downloadclient_without_watchfolder_should_return_badrequest() { - IgnoreOnMonoVersions("5.12", "5.14"); - EnsureNoDownloadClient(); var schema = DownloadClients.Schema().First(v => v.Implementation == "UsenetBlackhole"); @@ -101,8 +95,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void get_downloadclient_by_unknown_id_should_return_404() { - IgnoreOnMonoVersions("5.12", "5.14"); - var result = DownloadClients.InvalidGet(1000000); } diff --git a/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs index 87b7b0c81..e3cb00798 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs @@ -35,8 +35,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_get_bad_request_if_standard_format_is_empty() { - IgnoreOnMonoVersions("5.12", "5.14"); - var config = NamingConfig.GetSingle(); config.RenameBooks = true; config.StandardBookFormat = ""; @@ -48,8 +46,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_get_bad_request_if_standard_format_doesnt_contain_track_number_and_title() { - IgnoreOnMonoVersions("5.12", "5.14"); - var config = NamingConfig.GetSingle(); config.RenameBooks = true; config.StandardBookFormat = "{track:00}"; @@ -61,8 +57,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_not_require_format_when_rename_tracks_is_false() { - IgnoreOnMonoVersions("5.12", "5.14"); - var config = NamingConfig.GetSingle(); config.RenameBooks = false; config.StandardBookFormat = ""; @@ -74,8 +68,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_require_format_when_rename_tracks_is_true() { - IgnoreOnMonoVersions("5.12", "5.14"); - var config = NamingConfig.GetSingle(); config.RenameBooks = true; config.StandardBookFormat = ""; @@ -87,8 +79,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_get_bad_request_if_author_folder_format_does_not_contain_author_name() { - IgnoreOnMonoVersions("5.12", "5.14"); - var config = NamingConfig.GetSingle(); config.RenameBooks = true; config.AuthorFolderFormat = "This and That"; diff --git a/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs index b82407cbd..5887069c3 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs @@ -42,8 +42,6 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void invalid_path_should_return_bad_request() { - IgnoreOnMonoVersions("5.12", "5.14"); - var rootFolder = new RootFolderResource { Path = "invalid_path" diff --git a/src/NzbDrone.Integration.Test/Client/ClientBase.cs b/src/NzbDrone.Integration.Test/Client/ClientBase.cs index a73c96bdd..6ba21f8c5 100644 --- a/src/NzbDrone.Integration.Test/Client/ClientBase.cs +++ b/src/NzbDrone.Integration.Test/Client/ClientBase.cs @@ -73,9 +73,9 @@ namespace NzbDrone.Integration.Test.Client // cache control header gets reordered on net core var headers = response.Headers; ((string)headers.Single(c => c.Name == "Cache-Control").Value).Split(',').Select(x => x.Trim()) - .Should().BeEquivalentTo("no-store, must-revalidate, no-cache, max-age=0".Split(',').Select(x => x.Trim())); + .Should().BeEquivalentTo("no-store, no-cache".Split(',').Select(x => x.Trim())); headers.Single(c => c.Name == "Pragma").Value.Should().Be("no-cache"); - headers.Single(c => c.Name == "Expires").Value.Should().Be("0"); + headers.Single(c => c.Name == "Expires").Value.Should().Be("-1"); } } diff --git a/src/NzbDrone.Integration.Test/CorsFixture.cs b/src/NzbDrone.Integration.Test/CorsFixture.cs index 19f864c61..5e50049b4 100644 --- a/src/NzbDrone.Integration.Test/CorsFixture.cs +++ b/src/NzbDrone.Integration.Test/CorsFixture.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Integration.Test private RestRequest BuildGet(string route = "author") { var request = new RestRequest(route, Method.GET); + request.AddHeader("Origin", "http://a.different.domain"); request.AddHeader(AccessControlHeaders.RequestMethod, "POST"); return request; @@ -19,6 +20,8 @@ namespace NzbDrone.Integration.Test private RestRequest BuildOptions(string route = "author") { var request = new RestRequest(route, Method.OPTIONS); + request.AddHeader("Origin", "http://a.different.domain"); + request.AddHeader(AccessControlHeaders.RequestMethod, "POST"); return request; } diff --git a/src/NzbDrone.Integration.Test/GenericApiFixture.cs b/src/NzbDrone.Integration.Test/GenericApiFixture.cs index 63c6e9dbc..a42a23055 100644 --- a/src/NzbDrone.Integration.Test/GenericApiFixture.cs +++ b/src/NzbDrone.Integration.Test/GenericApiFixture.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using FluentAssertions; using NUnit.Framework; using RestSharp; @@ -33,8 +33,6 @@ namespace NzbDrone.Integration.Test [TestCase("application/junk")] public void should_get_unacceptable_with_accept_header(string header) { - IgnoreOnMonoVersions("5.12", "5.14"); - var request = new RestRequest("system/status") { RequestFormat = DataFormat.None diff --git a/src/NzbDrone.Integration.Test/HttpLogFixture.cs b/src/NzbDrone.Integration.Test/HttpLogFixture.cs index 15d3d2fdc..924e11455 100644 --- a/src/NzbDrone.Integration.Test/HttpLogFixture.cs +++ b/src/NzbDrone.Integration.Test/HttpLogFixture.cs @@ -11,8 +11,6 @@ namespace NzbDrone.Integration.Test [Test] public void should_log_on_error() { - IgnoreOnMonoVersions("5.12", "5.14"); - var config = HostConfig.Get(1); config.LogLevel = "Trace"; HostConfig.Put(config); diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs index 1059b9f31..f1e3b31dc 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs @@ -4,14 +4,12 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.AspNetCore.SignalR.Client; using NLog; using NLog.Config; using NLog.Targets; using NUnit.Framework; using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Processes; using NzbDrone.Core.MediaFiles.BookImport.Manual; using NzbDrone.Core.Qualities; using NzbDrone.Integration.Test.Client; @@ -159,22 +157,6 @@ namespace NzbDrone.Integration.Test } } - protected void IgnoreOnMonoVersions(params string[] version_strings) - { - if (!PlatformInfo.IsMono) - { - return; - } - - var current = PlatformInfo.GetVersion(); - var versions = version_strings.Select(x => new Version(x)).ToList(); - - if (versions.Any(x => x.Major == current.Major && x.Minor == current.Minor)) - { - throw new IgnoreException($"Ignored on mono {PlatformInfo.GetVersion()}"); - } - } - public string GetTempDirectory(params string[] args) { var path = Path.Combine(TempDirectory, Path.Combine(args)); diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs index d6d783556..ac7fadd60 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/SymlinkResolverFixture.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.IO; using FluentAssertions; -using Mono.Posix; using Mono.Unix; using NUnit.Framework; using NzbDrone.Mono.Disk; diff --git a/src/Readarr.Api.V1/Author/AuthorModule.cs b/src/Readarr.Api.V1/Author/AuthorController.cs similarity index 91% rename from src/Readarr.Api.V1/Author/AuthorModule.cs rename to src/Readarr.Api.V1/Author/AuthorController.cs index cbc67d3b4..ff1c933d3 100644 --- a/src/Readarr.Api.V1/Author/AuthorModule.cs +++ b/src/Readarr.Api.V1/Author/AuthorController.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.AuthorStats; using NzbDrone.Core.Books; @@ -15,14 +16,17 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Readarr.Api.V1.Books; using Readarr.Http; using Readarr.Http.Extensions; +using Readarr.Http.REST; namespace Readarr.Api.V1.Author { - public class AuthorModule : ReadarrRestModuleWithSignalR, + [V1ApiController] + public class AuthorController : RestControllerWithSignalR, IHandle, IHandle, IHandle, @@ -40,7 +44,7 @@ namespace Readarr.Api.V1.Author private readonly IManageCommandQueue _commandQueueManager; private readonly IRootFolderService _rootFolderService; - public AuthorModule(IBroadcastSignalRMessage signalRBroadcaster, + public AuthorController(IBroadcastSignalRMessage signalRBroadcaster, IAuthorService authorService, IBookService bookService, IAddAuthorService addAuthorService, @@ -67,12 +71,6 @@ namespace Readarr.Api.V1.Author _commandQueueManager = commandQueueManager; _rootFolderService = rootFolderService; - GetResourceAll = AllAuthors; - GetResourceById = GetAuthor; - CreateResource = AddAuthor; - UpdateResource = UpdateAuthor; - DeleteResource = DeleteAuthor; - Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId)); Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.MetadataProfileId)); @@ -97,7 +95,7 @@ namespace Readarr.Api.V1.Author PutValidator.RuleFor(s => s.Path).IsValidPath(); } - private AuthorResource GetAuthor(int id) + public override AuthorResource GetResourceById(int id) { var author = _authorService.GetAuthor(id); return GetAuthorResource(author); @@ -121,7 +119,8 @@ namespace Readarr.Api.V1.Author return resource; } - private List AllAuthors() + [HttpGet] + public List AllAuthors() { var authorStats = _authorStatisticsService.AuthorStatistics(); var authorResources = _authorService.GetAllAuthors().ToResource(); @@ -134,14 +133,16 @@ namespace Readarr.Api.V1.Author return authorResources; } - private int AddAuthor(AuthorResource authorResource) + [RestPostById] + public ActionResult AddAuthor(AuthorResource authorResource) { var author = _addAuthorService.AddAuthor(authorResource.ToModel()); - return author.Id; + return Created(author.Id); } - private void UpdateAuthor(AuthorResource authorResource) + [RestPutById] + public ActionResult UpdateAuthor(AuthorResource authorResource) { var moveFiles = Request.GetBooleanQueryParameter("moveFiles"); var author = _authorService.GetAuthor(authorResource.Id); @@ -165,13 +166,15 @@ namespace Readarr.Api.V1.Author _authorService.UpdateAuthor(model); BroadcastResourceChange(ModelAction.Updated, authorResource); + + return Accepted(authorResource.Id); } - private void DeleteAuthor(int id) + [RestDeleteById] + public void DeleteAuthor(int id) { var deleteFiles = Request.GetBooleanQueryParameter("deleteFiles"); var addImportListExclusion = Request.GetBooleanQueryParameter("addImportListExclusion"); - _authorService.DeleteAuthor(id, deleteFiles, addImportListExclusion); } @@ -240,16 +243,19 @@ namespace Readarr.Api.V1.Author resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path); } + [NonAction] public void Handle(BookImportedEvent message) { BroadcastResourceChange(ModelAction.Updated, GetAuthorResource(message.Author)); } + [NonAction] public void Handle(BookEditedEvent message) { BroadcastResourceChange(ModelAction.Updated, GetAuthorResource(message.Book.Author.Value)); } + [NonAction] public void Handle(BookFileDeletedEvent message) { if (message.Reason == DeleteMediaFileReason.Upgrade) @@ -260,26 +266,31 @@ namespace Readarr.Api.V1.Author BroadcastResourceChange(ModelAction.Updated, GetAuthorResource(message.BookFile.Author.Value)); } + [NonAction] public void Handle(AuthorUpdatedEvent message) { BroadcastResourceChange(ModelAction.Updated, GetAuthorResource(message.Author)); } + [NonAction] public void Handle(AuthorEditedEvent message) { BroadcastResourceChange(ModelAction.Updated, GetAuthorResource(message.Author)); } + [NonAction] public void Handle(AuthorDeletedEvent message) { BroadcastResourceChange(ModelAction.Deleted, message.Author.ToResource()); } + [NonAction] public void Handle(AuthorRenamedEvent message) { BroadcastResourceChange(ModelAction.Updated, message.Author.Id); } + [NonAction] public void Handle(MediaCoversUpdatedEvent message) { BroadcastResourceChange(ModelAction.Updated, GetAuthorResource(message.Author)); diff --git a/src/Readarr.Api.V1/Author/AuthorEditorModule.cs b/src/Readarr.Api.V1/Author/AuthorEditorController.cs similarity index 78% rename from src/Readarr.Api.V1/Author/AuthorEditorModule.cs rename to src/Readarr.Api.V1/Author/AuthorEditorController.cs index 1e8aa2760..f1180da10 100644 --- a/src/Readarr.Api.V1/Author/AuthorEditorModule.cs +++ b/src/Readarr.Api.V1/Author/AuthorEditorController.cs @@ -1,31 +1,29 @@ using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Books; using NzbDrone.Core.Books.Commands; using NzbDrone.Core.Messaging.Commands; -using Readarr.Http.Extensions; +using Readarr.Http; namespace Readarr.Api.V1.Author { - public class AuthorEditorModule : ReadarrV1Module + [V1ApiController("author/editor")] + public class AuthorEditorController : Controller { private readonly IAuthorService _authorService; private readonly IManageCommandQueue _commandQueueManager; - public AuthorEditorModule(IAuthorService authorService, IManageCommandQueue commandQueueManager) - : base("/author/editor") + public AuthorEditorController(IAuthorService authorService, IManageCommandQueue commandQueueManager) { _authorService = authorService; _commandQueueManager = commandQueueManager; - Put("/", author => SaveAll()); - Delete("/", author => DeleteAuthor()); } - private object SaveAll() + [HttpPut] + public IActionResult SaveAll([FromBody] AuthorEditorResource resource) { - var resource = Request.Body.FromJson(); var authorsToUpdate = _authorService.GetAuthors(resource.AuthorIds); var authorsToMove = new List(); @@ -85,15 +83,12 @@ namespace Readarr.Api.V1.Author }); } - return ResponseWithCode(_authorService.UpdateAuthors(authorsToUpdate, !resource.MoveFiles) - .ToResource(), - HttpStatusCode.Accepted); + return Accepted(_authorService.UpdateAuthors(authorsToUpdate, !resource.MoveFiles).ToResource()); } - private object DeleteAuthor() + [HttpDelete] + public object DeleteAuthor([FromBody] AuthorEditorResource resource) { - var resource = Request.Body.FromJson(); - foreach (var authorId in resource.AuthorIds) { _authorService.DeleteAuthor(authorId, false); diff --git a/src/Readarr.Api.V1/Author/AuthorImportModule.cs b/src/Readarr.Api.V1/Author/AuthorImportModule.cs deleted file mode 100644 index e0d63cd0c..000000000 --- a/src/Readarr.Api.V1/Author/AuthorImportModule.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using Nancy; -using NzbDrone.Core.Books; -using Readarr.Http; -using Readarr.Http.Extensions; - -namespace Readarr.Api.V1.Author -{ - public class AuthorImportModule : ReadarrRestModule - { - private readonly IAddAuthorService _addAuthorService; - - public AuthorImportModule(IAddAuthorService addAuthorService) - : base("/author/import") - { - _addAuthorService = addAuthorService; - Post("/", x => Import()); - } - - private object Import() - { - var resource = Request.Body.FromJson>(); - var newAuthors = resource.ToModel(); - - return _addAuthorService.AddAuthors(newAuthors).ToResource(); - } - } -} diff --git a/src/Readarr.Api.V1/Author/AuthorLookupModule.cs b/src/Readarr.Api.V1/Author/AuthorLookupController.cs similarity index 77% rename from src/Readarr.Api.V1/Author/AuthorLookupModule.cs rename to src/Readarr.Api.V1/Author/AuthorLookupController.cs index a0061ffca..d02669c97 100644 --- a/src/Readarr.Api.V1/Author/AuthorLookupModule.cs +++ b/src/Readarr.Api.V1/Author/AuthorLookupController.cs @@ -1,26 +1,26 @@ using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource; using Readarr.Http; namespace Readarr.Api.V1.Author { - public class AuthorLookupModule : ReadarrRestModule + [V1ApiController("author/lookup")] + public class AuthorLookupController : Controller { private readonly ISearchForNewAuthor _searchProxy; - public AuthorLookupModule(ISearchForNewAuthor searchProxy) - : base("/author/lookup") + public AuthorLookupController(ISearchForNewAuthor searchProxy) { _searchProxy = searchProxy; - Get("/", x => Search()); } - private object Search() + [HttpGet] + public object Search([FromQuery] string term) { - var searchResults = _searchProxy.SearchForNewAuthor((string)Request.Query.term); + var searchResults = _searchProxy.SearchForNewAuthor(term); return MapToResource(searchResults).ToList(); } diff --git a/src/Readarr.Api.V1/Blacklist/BlacklistController.cs b/src/Readarr.Api.V1/Blacklist/BlacklistController.cs new file mode 100644 index 000000000..8c5beab82 --- /dev/null +++ b/src/Readarr.Api.V1/Blacklist/BlacklistController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Blacklisting; +using NzbDrone.Core.Datastore; +using NzbDrone.Http.REST.Attributes; +using Readarr.Http; +using Readarr.Http.Extensions; + +namespace Readarr.Api.V1.Blacklist +{ + [V1ApiController] + public class BlacklistController : Controller + { + private readonly IBlacklistService _blacklistService; + + public BlacklistController(IBlacklistService blacklistService) + { + _blacklistService = blacklistService; + } + + [HttpGet] + public PagingResource GetBlacklist() + { + var pagingResource = Request.ReadPagingResourceFromRequest(); + var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); + + return pagingSpec.ApplyToPage(_blacklistService.Paged, BlacklistResourceMapper.MapToResource); + } + + [RestDeleteById] + public void DeleteBlacklist(int id) + { + _blacklistService.Delete(id); + } + + [HttpDelete("bulk")] + public object Remove([FromBody] BlacklistBulkResource resource) + { + _blacklistService.Delete(resource.Ids); + + return new object(); + } + } +} diff --git a/src/Readarr.Api.V1/Blacklist/BlacklistModule.cs b/src/Readarr.Api.V1/Blacklist/BlacklistModule.cs deleted file mode 100644 index 7b5eb961d..000000000 --- a/src/Readarr.Api.V1/Blacklist/BlacklistModule.cs +++ /dev/null @@ -1,42 +0,0 @@ -using NzbDrone.Core.Blacklisting; -using NzbDrone.Core.Datastore; -using Readarr.Http; -using Readarr.Http.Extensions; - -namespace Readarr.Api.V1.Blacklist -{ - public class BlacklistModule : ReadarrRestModule - { - private readonly IBlacklistService _blacklistService; - - public BlacklistModule(IBlacklistService blacklistService) - { - _blacklistService = blacklistService; - GetResourcePaged = GetBlacklist; - DeleteResource = DeleteBlacklist; - - Delete("/bulk", x => Remove()); - } - - private PagingResource GetBlacklist(PagingResource pagingResource) - { - var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); - - return ApplyToPage(_blacklistService.Paged, pagingSpec, BlacklistResourceMapper.MapToResource); - } - - private void DeleteBlacklist(int id) - { - _blacklistService.Delete(id); - } - - private object Remove() - { - var resource = Request.Body.FromJson(); - - _blacklistService.Delete(resource.Ids); - - return new object(); - } - } -} diff --git a/src/Readarr.Api.V1/BookFiles/BookFileModule.cs b/src/Readarr.Api.V1/BookFiles/BookFileController.cs similarity index 66% rename from src/Readarr.Api.V1/BookFiles/BookFileModule.cs rename to src/Readarr.Api.V1/BookFiles/BookFileController.cs index 48a174e3b..9a2c21306 100644 --- a/src/Readarr.Api.V1/BookFiles/BookFileModule.cs +++ b/src/Readarr.Api.V1/BookFiles/BookFileController.cs @@ -1,7 +1,6 @@ -using System; using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Books; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.DecisionEngine.Specifications; @@ -9,14 +8,17 @@ using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Readarr.Http; -using Readarr.Http.Extensions; +using Readarr.Http.REST; +using BadRequestException = NzbDrone.Core.Exceptions.BadRequestException; using HttpStatusCode = System.Net.HttpStatusCode; namespace Readarr.Api.V1.BookFiles { - public class BookFileModule : ReadarrRestModuleWithSignalR, + [V1ApiController] + public class BookFileController : RestControllerWithSignalR, IHandle, IHandle { @@ -27,7 +29,7 @@ namespace Readarr.Api.V1.BookFiles private readonly IBookService _bookService; private readonly IUpgradableSpecification _upgradableSpecification; - public BookFileModule(IBroadcastSignalRMessage signalRBroadcaster, + public BookFileController(IBroadcastSignalRMessage signalRBroadcaster, IMediaFileService mediaFileService, IDeleteMediaFiles mediaFileDeletionService, IAudioTagService audioTagService, @@ -42,14 +44,6 @@ namespace Readarr.Api.V1.BookFiles _authorService = authorService; _bookService = bookService; _upgradableSpecification = upgradableSpecification; - - GetResourceById = GetBookFile; - GetResourceAll = GetBookFiles; - UpdateResource = SetQuality; - DeleteResource = DeleteBookFile; - - Put("/editor", trackFiles => SetQuality()); - Delete("/bulk", trackFiles => DeleteBookFiles()); } private BookFileResource MapToResource(BookFile bookFile) @@ -64,47 +58,36 @@ namespace Readarr.Api.V1.BookFiles } } - private BookFileResource GetBookFile(int id) + public override BookFileResource GetResourceById(int id) { var resource = MapToResource(_mediaFileService.Get(id)); resource.AudioTags = _audioTagService.ReadTags(resource.Path); return resource; } - private List GetBookFiles() + [HttpGet] + public List GetBookFiles(int? authorId, [FromQuery]List bookFileIds, [FromQuery(Name="bookId")]List bookIds, bool? unmapped) { - var authorIdQuery = Request.Query.AuthorId; - var bookFileIdsQuery = Request.Query.TrackFileIds; - var bookIdQuery = Request.Query.BookId; - var unmappedQuery = Request.Query.Unmapped; - - if (!authorIdQuery.HasValue && !bookFileIdsQuery.HasValue && !bookIdQuery.HasValue && !unmappedQuery.HasValue) + if (!authorId.HasValue && !bookFileIds.Any() && !bookIds.Any() && !unmapped.HasValue) { - throw new Readarr.Http.REST.BadRequestException("authorId, bookId, bookFileIds or unmapped must be provided"); + throw new BadRequestException("authorId, bookId, bookFileIds or unmapped must be provided"); } - if (unmappedQuery.HasValue && Convert.ToBoolean(unmappedQuery.Value)) + if (unmapped.HasValue && unmapped.Value) { var files = _mediaFileService.GetUnmappedFiles(); return files.ConvertAll(f => MapToResource(f)); } - if (authorIdQuery.HasValue && !bookIdQuery.HasValue) + if (authorId.HasValue && !bookIds.Any()) { - int authorId = Convert.ToInt32(authorIdQuery.Value); - var author = _authorService.GetAuthor(authorId); + var author = _authorService.GetAuthor(authorId.Value); - return _mediaFileService.GetFilesByAuthor(authorId).ConvertAll(f => f.ToResource(author, _upgradableSpecification)); + return _mediaFileService.GetFilesByAuthor(authorId.Value).ConvertAll(f => f.ToResource(author, _upgradableSpecification)); } - if (bookIdQuery.HasValue) + if (bookIds.Any()) { - string bookIdValue = bookIdQuery.Value.ToString(); - - var bookIds = bookIdValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(e => Convert.ToInt32(e)) - .ToList(); - var result = new List(); foreach (var bookId in bookIds) { @@ -117,28 +100,24 @@ namespace Readarr.Api.V1.BookFiles } else { - string bookFileIdsValue = bookFileIdsQuery.Value.ToString(); - - var bookFileIds = bookFileIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(e => Convert.ToInt32(e)) - .ToList(); - // trackfiles will come back with the author already populated var bookFiles = _mediaFileService.Get(bookFileIds); return bookFiles.ConvertAll(e => MapToResource(e)); } } - private void SetQuality(BookFileResource bookFileResource) + [RestPutById] + public ActionResult SetQuality(BookFileResource bookFileResource) { var bookFile = _mediaFileService.Get(bookFileResource.Id); bookFile.Quality = bookFileResource.Quality; _mediaFileService.Update(bookFile); + return Accepted(bookFile.Id); } - private object SetQuality() + [HttpPut("editor")] + public IActionResult SetQuality([FromBody] BookFileListResource resource) { - var resource = Request.Body.FromJson(); var bookFiles = _mediaFileService.Get(resource.BookFileIds); foreach (var bookFile in bookFiles) @@ -151,11 +130,11 @@ namespace Readarr.Api.V1.BookFiles _mediaFileService.Update(bookFiles); - return ResponseWithCode(bookFiles.ConvertAll(f => f.ToResource(bookFiles.First().Author.Value, _upgradableSpecification)), - Nancy.HttpStatusCode.Accepted); + return Accepted(bookFiles.ConvertAll(f => f.ToResource(bookFiles.First().Author.Value, _upgradableSpecification))); } - private void DeleteBookFile(int id) + [RestDeleteById] + public void DeleteBookFile(int id) { var bookFile = _mediaFileService.Get(id); @@ -174,9 +153,9 @@ namespace Readarr.Api.V1.BookFiles } } - private object DeleteBookFiles() + [HttpDelete("bulk")] + public IActionResult DeleteBookFiles([FromBody] BookFileListResource resource) { - var resource = Request.Body.FromJson(); var bookFiles = _mediaFileService.Get(resource.BookFileIds); var author = bookFiles.First().Author.Value; @@ -185,14 +164,16 @@ namespace Readarr.Api.V1.BookFiles _mediaFileDeletionService.DeleteTrackFile(author, bookFile); } - return new object(); + return Ok(); } + [NonAction] public void Handle(BookFileAddedEvent message) { BroadcastResourceChange(ModelAction.Updated, MapToResource(message.BookFile)); } + [NonAction] public void Handle(BookFileDeletedEvent message) { BroadcastResourceChange(ModelAction.Deleted, MapToResource(message.BookFile)); diff --git a/src/Readarr.Api.V1/BookShelf/BookshelfModule.cs b/src/Readarr.Api.V1/BookShelf/BookshelfController.cs similarity index 70% rename from src/Readarr.Api.V1/BookShelf/BookshelfModule.cs rename to src/Readarr.Api.V1/BookShelf/BookshelfController.cs index 6e7989b78..2a6baa7e4 100644 --- a/src/Readarr.Api.V1/BookShelf/BookshelfModule.cs +++ b/src/Readarr.Api.V1/BookShelf/BookshelfController.cs @@ -1,27 +1,26 @@ using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Books; -using Readarr.Http.Extensions; +using Readarr.Http; namespace Readarr.Api.V1.Bookshelf { - public class BookshelfModule : ReadarrV1Module + [V1ApiController] + public class BookshelfController : Controller { private readonly IAuthorService _authorService; private readonly IBookMonitoredService _bookMonitoredService; - public BookshelfModule(IAuthorService authorService, IBookMonitoredService bookMonitoredService) - : base("/bookshelf") + public BookshelfController(IAuthorService authorService, IBookMonitoredService bookMonitoredService) { _authorService = authorService; _bookMonitoredService = bookMonitoredService; - Post("/", author => UpdateAll()); } - private object UpdateAll() + [HttpPost] + public IActionResult UpdateAll([FromBody] BookshelfResource request) { //Read from request - var request = Request.Body.FromJson(); var authorToUpdate = _authorService.GetAuthors(request.Authors.Select(s => s.Id)); foreach (var s in request.Authors) @@ -41,7 +40,7 @@ namespace Readarr.Api.V1.Bookshelf _bookMonitoredService.SetBookMonitoredStatus(author, request.MonitoringOptions); } - return ResponseWithCode("ok", HttpStatusCode.Accepted); + return Accepted(); } } } diff --git a/src/Readarr.Api.V1/Books/BookModule.cs b/src/Readarr.Api.V1/Books/BookController.cs similarity index 78% rename from src/Readarr.Api.V1/Books/BookModule.cs rename to src/Readarr.Api.V1/Books/BookController.cs index 3734abc6e..bfc82429b 100644 --- a/src/Readarr.Api.V1/Books/BookModule.cs +++ b/src/Readarr.Api.V1/Books/BookController.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using FluentValidation; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.AuthorStats; using NzbDrone.Core.Books; @@ -16,12 +16,15 @@ using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; +using Readarr.Http; using Readarr.Http.Extensions; namespace Readarr.Api.V1.Books { - public class BookModule : BookModuleWithSignalR, + [V1ApiController] + public class BookController : BookControllerWithSignalR, IHandle, IHandle, IHandle, @@ -33,7 +36,7 @@ namespace Readarr.Api.V1.Books protected readonly IEditionService _editionService; protected readonly IAddBookService _addBookService; - public BookModule(IAuthorService authorService, + public BookController(IAuthorService authorService, IBookService bookService, IAddBookService addBookService, IEditionService editionService, @@ -51,12 +54,6 @@ namespace Readarr.Api.V1.Books _editionService = editionService; _addBookService = addBookService; - GetResourceAll = GetBooks; - CreateResource = AddBook; - UpdateResource = UpdateBook; - DeleteResource = DeleteBook; - Put("/monitor", x => SetBooksMonitored()); - PostValidator.RuleFor(s => s.ForeignBookId).NotEmpty(); PostValidator.RuleFor(s => s.Author.QualityProfileId).SetValidator(qualityProfileExistsValidator); PostValidator.RuleFor(s => s.Author.MetadataProfileId).SetValidator(metadataProfileExistsValidator); @@ -64,14 +61,13 @@ namespace Readarr.Api.V1.Books PostValidator.RuleFor(s => s.Author.ForeignAuthorId).NotEmpty(); } - private List GetBooks() + [HttpGet] + public List GetBooks([FromQuery]int? authorId, + [FromQuery]List bookIds, + [FromQuery]string titleSlug, + [FromQuery]bool includeAllAuthorBooks = false) { - var authorIdQuery = Request.Query.AuthorId; - var bookIdsQuery = Request.Query.BookIds; - var slugQuery = Request.Query.TitleSlug; - var includeAllAuthorBooksQuery = Request.Query.IncludeAllAuthorBooks; - - if (!Request.Query.AuthorId.HasValue && !bookIdsQuery.HasValue && !slugQuery.HasValue) + if (!authorId.HasValue && !bookIds.Any() && titleSlug.IsNullOrWhiteSpace()) { var books = _bookService.GetAllBooks(); @@ -94,13 +90,12 @@ namespace Readarr.Api.V1.Books return MapToResource(books, false); } - if (authorIdQuery.HasValue) + if (authorId.HasValue) { - int authorId = Convert.ToInt32(authorIdQuery.Value); - var books = _bookService.GetBooksByAuthor(authorId); + var books = _bookService.GetBooksByAuthor(authorId.Value); - var author = _authorService.GetAuthor(authorId); - var editions = _editionService.GetEditionsByAuthor(authorId) + var author = _authorService.GetAuthor(authorId.Value); + var editions = _editionService.GetEditionsByAuthor(authorId.Value) .GroupBy(x => x.BookId) .ToDictionary(x => x.Key, y => y.ToList()); @@ -120,10 +115,8 @@ namespace Readarr.Api.V1.Books return MapToResource(books, false); } - if (slugQuery.HasValue) + if (titleSlug.IsNotNullOrWhiteSpace()) { - string titleSlug = slugQuery.Value.ToString(); - var book = _bookService.FindBySlug(titleSlug); if (book == null) @@ -131,7 +124,7 @@ namespace Readarr.Api.V1.Books return MapToResource(new List(), false); } - if (includeAllAuthorBooksQuery.HasValue && Convert.ToBoolean(includeAllAuthorBooksQuery.Value)) + if (includeAllAuthorBooks) { return MapToResource(_bookService.GetBooksByAuthor(book.AuthorId), false); } @@ -141,23 +134,19 @@ namespace Readarr.Api.V1.Books } } - string bookIdsValue = bookIdsQuery.Value.ToString(); - - var bookIds = bookIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(e => Convert.ToInt32(e)) - .ToList(); - return MapToResource(_bookService.GetBooks(bookIds), false); } - private int AddBook(BookResource bookResource) + [RestPostById] + public ActionResult AddBook(BookResource bookResource) { var book = _addBookService.AddBook(bookResource.ToModel()); - return book.Id; + return Created(book.Id); } - private void UpdateBook(BookResource bookResource) + [RestPutById] + public ActionResult UpdateBook(BookResource bookResource) { var book = _bookService.GetBook(bookResource.Id); @@ -167,9 +156,12 @@ namespace Readarr.Api.V1.Books _editionService.UpdateMany(model.Editions.Value); BroadcastResourceChange(ModelAction.Updated, model.Id); + + return Accepted(model.Id); } - private void DeleteBook(int id) + [RestDeleteById] + public void DeleteBook(int id) { var deleteFiles = Request.GetBooleanQueryParameter("deleteFiles"); var addImportListExclusion = Request.GetBooleanQueryParameter("addImportListExclusion"); @@ -177,15 +169,15 @@ namespace Readarr.Api.V1.Books _bookService.DeleteBook(id, deleteFiles, addImportListExclusion); } - private object SetBooksMonitored() + [HttpPut("monitor")] + public IActionResult SetBooksMonitored([FromBody]BooksMonitoredResource resource) { - var resource = Request.Body.FromJson(); - _bookService.SetMonitored(resource.BookIds, resource.Monitored); - return ResponseWithCode(MapToResource(_bookService.GetBooks(resource.BookIds), false), HttpStatusCode.Accepted); + return Accepted(MapToResource(_bookService.GetBooks(resource.BookIds), false)); } + [NonAction] public void Handle(BookGrabbedEvent message) { foreach (var book in message.Book.Books) @@ -197,31 +189,37 @@ namespace Readarr.Api.V1.Books } } + [NonAction] public void Handle(BookEditedEvent message) { BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Book, true)); } + [NonAction] public void Handle(BookUpdatedEvent message) { BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Book, true)); } + [NonAction] public void Handle(BookDeletedEvent message) { BroadcastResourceChange(ModelAction.Deleted, message.Book.ToResource()); } + [NonAction] public void Handle(BookImportedEvent message) { BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Book, true)); } + [NonAction] public void Handle(TrackImportedEvent message) { BroadcastResourceChange(ModelAction.Updated, message.BookInfo.Book.ToResource()); } + [NonAction] public void Handle(BookFileDeletedEvent message) { if (message.Reason == DeleteMediaFileReason.Upgrade) diff --git a/src/Readarr.Api.V1/Books/BookModuleWithSignalR.cs b/src/Readarr.Api.V1/Books/BookControllerWithSignalR.cs similarity index 78% rename from src/Readarr.Api.V1/Books/BookModuleWithSignalR.cs rename to src/Readarr.Api.V1/Books/BookControllerWithSignalR.cs index 26e8f761e..8b20ee27d 100644 --- a/src/Readarr.Api.V1/Books/BookModuleWithSignalR.cs +++ b/src/Readarr.Api.V1/Books/BookControllerWithSignalR.cs @@ -1,17 +1,16 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Common.Extensions; using NzbDrone.Core.AuthorStats; using NzbDrone.Core.Books; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.MediaCover; using NzbDrone.SignalR; using Readarr.Api.V1.Author; -using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Books { - public abstract class BookModuleWithSignalR : ReadarrRestModuleWithSignalR + public abstract class BookControllerWithSignalR : RestControllerWithSignalR { protected readonly IBookService _bookService; protected readonly ISeriesBookLinkService _seriesBookLinkService; @@ -19,7 +18,7 @@ namespace Readarr.Api.V1.Books protected readonly IUpgradableSpecification _qualityUpgradableSpecification; protected readonly IMapCoversToLocal _coverMapper; - protected BookModuleWithSignalR(IBookService bookService, + protected BookControllerWithSignalR(IBookService bookService, ISeriesBookLinkService seriesBookLinkService, IAuthorStatisticsService authorStatisticsService, IMapCoversToLocal coverMapper, @@ -32,29 +31,9 @@ namespace Readarr.Api.V1.Books _authorStatisticsService = authorStatisticsService; _coverMapper = coverMapper; _qualityUpgradableSpecification = qualityUpgradableSpecification; - - GetResourceById = GetBook; - } - - protected BookModuleWithSignalR(IBookService bookService, - ISeriesBookLinkService seriesBookLinkService, - IAuthorStatisticsService authorStatisticsService, - IMapCoversToLocal coverMapper, - IUpgradableSpecification qualityUpgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster, - string resource) - : base(signalRBroadcaster, resource) - { - _bookService = bookService; - _seriesBookLinkService = seriesBookLinkService; - _authorStatisticsService = authorStatisticsService; - _coverMapper = coverMapper; - _qualityUpgradableSpecification = qualityUpgradableSpecification; - - GetResourceById = GetBook; } - protected BookResource GetBook(int id) + public override BookResource GetResourceById(int id) { var book = _bookService.GetBook(id); var resource = MapToResource(book, true); diff --git a/src/Readarr.Api.V1/Books/BookLookupModule.cs b/src/Readarr.Api.V1/Books/BookLookupController.cs similarity index 73% rename from src/Readarr.Api.V1/Books/BookLookupModule.cs rename to src/Readarr.Api.V1/Books/BookLookupController.cs index d62fa403e..1b8f52643 100644 --- a/src/Readarr.Api.V1/Books/BookLookupModule.cs +++ b/src/Readarr.Api.V1/Books/BookLookupController.cs @@ -1,26 +1,26 @@ using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource; using Readarr.Http; namespace Readarr.Api.V1.Books { - public class BookLookupModule : ReadarrRestModule + [V1ApiController("book/lookup")] + public class BookLookupController : Controller { private readonly ISearchForNewBook _searchProxy; - public BookLookupModule(ISearchForNewBook searchProxy) - : base("/book/lookup") + public BookLookupController(ISearchForNewBook searchProxy) { _searchProxy = searchProxy; - Get("/", x => Search()); } - private object Search() + [HttpGet] + public object Search(string term) { - var searchResults = _searchProxy.SearchForNewBook((string)Request.Query.term, null); + var searchResults = _searchProxy.SearchForNewBook(term, null); return MapToResource(searchResults).ToList(); } diff --git a/src/Readarr.Api.V1/Books/BookResource.cs b/src/Readarr.Api.V1/Books/BookResource.cs index 6593c2155..4443a6ba9 100644 --- a/src/Readarr.Api.V1/Books/BookResource.cs +++ b/src/Readarr.Api.V1/Books/BookResource.cs @@ -6,7 +6,6 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Books; using NzbDrone.Core.MediaCover; using Readarr.Api.V1.Author; -using Readarr.Api.V1.BookFiles; using Readarr.Http.REST; namespace Readarr.Api.V1.Books diff --git a/src/Readarr.Api.V1/Books/RenameBookController.cs b/src/Readarr.Api.V1/Books/RenameBookController.cs new file mode 100644 index 000000000..8684b0a19 --- /dev/null +++ b/src/Readarr.Api.V1/Books/RenameBookController.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.MediaFiles; +using Readarr.Http; + +namespace Readarr.Api.V1.Books +{ + [V1ApiController("rename")] + public class RenameBookController : Controller + { + private readonly IRenameBookFileService _renameBookFileService; + + public RenameBookController(IRenameBookFileService renameBookFileService) + { + _renameBookFileService = renameBookFileService; + } + + [HttpGet] + public List GetBookFiles(int authorId, int? bookId) + { + if (bookId.HasValue) + { + return _renameBookFileService.GetRenamePreviews(authorId, bookId.Value).ToResource(); + } + + return _renameBookFileService.GetRenamePreviews(authorId).ToResource(); + } + } +} diff --git a/src/Readarr.Api.V1/Books/RenameBookModule.cs b/src/Readarr.Api.V1/Books/RenameBookModule.cs deleted file mode 100644 index fe29a4b56..000000000 --- a/src/Readarr.Api.V1/Books/RenameBookModule.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.MediaFiles; -using Readarr.Http; -using Readarr.Http.REST; - -namespace Readarr.Api.V1.Books -{ - public class RenameBookModule : ReadarrRestModule - { - private readonly IRenameBookFileService _renameBookFileService; - - public RenameBookModule(IRenameBookFileService renameBookFileService) - : base("rename") - { - _renameBookFileService = renameBookFileService; - - GetResourceAll = GetBookFiles; - } - - private List GetBookFiles() - { - int authorId; - - if (Request.Query.AuthorId.HasValue) - { - authorId = (int)Request.Query.AuthorId; - } - else - { - throw new BadRequestException("authorId is missing"); - } - - if (Request.Query.bookId.HasValue) - { - var bookId = (int)Request.Query.bookId; - return _renameBookFileService.GetRenamePreviews(authorId, bookId).ToResource(); - } - - return _renameBookFileService.GetRenamePreviews(authorId).ToResource(); - } - } -} diff --git a/src/Readarr.Api.V1/Books/RetagBookModule.cs b/src/Readarr.Api.V1/Books/RetagBookController.cs similarity index 53% rename from src/Readarr.Api.V1/Books/RetagBookModule.cs rename to src/Readarr.Api.V1/Books/RetagBookController.cs index 8e879d46c..dc86609f2 100644 --- a/src/Readarr.Api.V1/Books/RetagBookModule.cs +++ b/src/Readarr.Api.V1/Books/RetagBookController.cs @@ -1,34 +1,32 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.MediaFiles; using Readarr.Http; using Readarr.Http.REST; namespace Readarr.Api.V1.Books { - public class RetagBookModule : ReadarrRestModule + [V1ApiController("retag")] + public class RetagBookController : Controller { private readonly IAudioTagService _audioTagService; - public RetagBookModule(IAudioTagService audioTagService) - : base("retag") + public RetagBookController(IAudioTagService audioTagService) { _audioTagService = audioTagService; - - GetResourceAll = GetBooks; } - private List GetBooks() + [HttpGet] + public List GetBooks(int? authorId, int? bookId) { - if (Request.Query.bookId.HasValue) + if (bookId.HasValue) { - var bookId = (int)Request.Query.bookId; - return _audioTagService.GetRetagPreviewsByBook(bookId).Where(x => x.Changes.Any()).ToResource(); + return _audioTagService.GetRetagPreviewsByBook(bookId.Value).Where(x => x.Changes.Any()).ToResource(); } - else if (Request.Query.AuthorId.HasValue) + else if (authorId.HasValue) { - var authorId = (int)Request.Query.AuthorId; - return _audioTagService.GetRetagPreviewsByAuthor(authorId).Where(x => x.Changes.Any()).ToResource(); + return _audioTagService.GetRetagPreviewsByAuthor(authorId.Value).Where(x => x.Changes.Any()).ToResource(); } else { diff --git a/src/Readarr.Api.V1/Calendar/CalendarModule.cs b/src/Readarr.Api.V1/Calendar/CalendarController.cs similarity index 54% rename from src/Readarr.Api.V1/Calendar/CalendarModule.cs rename to src/Readarr.Api.V1/Calendar/CalendarController.cs index 3483d850a..72e06921d 100644 --- a/src/Readarr.Api.V1/Calendar/CalendarModule.cs +++ b/src/Readarr.Api.V1/Calendar/CalendarController.cs @@ -1,53 +1,41 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.AuthorStats; using NzbDrone.Core.Books; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.MediaCover; using NzbDrone.SignalR; using Readarr.Api.V1.Books; +using Readarr.Http; using Readarr.Http.Extensions; namespace Readarr.Api.V1.Calendar { - public class CalendarModule : BookModuleWithSignalR + [V1ApiController] + public class CalendarController : BookControllerWithSignalR { - public CalendarModule(IBookService bookService, + public CalendarController(IBookService bookService, ISeriesBookLinkService seriesBookLinkService, IAuthorStatisticsService authorStatisticsService, IMapCoversToLocal coverMapper, IUpgradableSpecification upgradableSpecification, IBroadcastSignalRMessage signalRBroadcaster) - : base(bookService, seriesBookLinkService, authorStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster, "calendar") + : base(bookService, seriesBookLinkService, authorStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster) { - GetResourceAll = GetCalendar; } - private List GetCalendar() + [HttpGet] + public List GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeAuthor = false) { - var start = DateTime.Today; - var end = DateTime.Today.AddDays(2); - var includeUnmonitored = Request.GetBooleanQueryParameter("unmonitored"); - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); - - //TODO: Add Book Image support to BookModuleWithSignalR + //TODO: Add Book Image support to BookControllerWithSignalR var includeBookImages = Request.GetBooleanQueryParameter("includeBookImages"); - var queryStart = Request.Query.Start; - var queryEnd = Request.Query.End; - - if (queryStart.HasValue) - { - start = DateTime.Parse(queryStart.Value); - } - - if (queryEnd.HasValue) - { - end = DateTime.Parse(queryEnd.Value); - } + var startUse = start ?? DateTime.Today; + var endUse = end ?? DateTime.Today.AddDays(2); - var resources = MapToResource(_bookService.BooksBetweenDates(start, end, includeUnmonitored), includeAuthor); + var resources = MapToResource(_bookService.BooksBetweenDates(startUse, endUse, unmonitored), includeAuthor); return resources.OrderBy(e => e.ReleaseDate).ToList(); } diff --git a/src/Readarr.Api.V1/Calendar/CalendarFeedModule.cs b/src/Readarr.Api.V1/Calendar/CalendarFeedController.cs similarity index 64% rename from src/Readarr.Api.V1/Calendar/CalendarFeedModule.cs rename to src/Readarr.Api.V1/Calendar/CalendarFeedController.cs index dd6b4ea26..6f5b605ab 100644 --- a/src/Readarr.Api.V1/Calendar/CalendarFeedModule.cs +++ b/src/Readarr.Api.V1/Calendar/CalendarFeedController.cs @@ -5,60 +5,38 @@ using Ical.Net; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Serialization; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Books; using NzbDrone.Core.Tags; -using Readarr.Http.Extensions; +using Readarr.Http; namespace Readarr.Api.V1.Calendar { - public class CalendarFeedModule : ReadarrV1FeedModule + [V1FeedController("calendar")] + public class CalendarFeedController : Controller { private readonly IBookService _bookService; private readonly IAuthorService _authorService; private readonly ITagService _tagService; - public CalendarFeedModule(IBookService bookService, IAuthorService authorService, ITagService tagService) - : base("calendar") + public CalendarFeedController(IBookService bookService, IAuthorService authorService, ITagService tagService) { _bookService = bookService; _authorService = authorService; _tagService = tagService; - - Get("/Readarr.ics", options => GetCalendarFeed()); } - private object GetCalendarFeed() + [HttpGet("Readarr.ics")] + public IActionResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, string tagList = "", bool unmonitored = false) { - var pastDays = 7; - var futureDays = 28; var start = DateTime.Today.AddDays(-pastDays); var end = DateTime.Today.AddDays(futureDays); - var unmonitored = Request.GetBooleanQueryParameter("unmonitored"); var tags = new List(); - var queryPastDays = Request.Query.PastDays; - var queryFutureDays = Request.Query.FutureDays; - var queryTags = Request.Query.Tags; - - if (queryPastDays.HasValue) - { - pastDays = int.Parse(queryPastDays.Value); - start = DateTime.Today.AddDays(-pastDays); - } - - if (queryFutureDays.HasValue) - { - futureDays = int.Parse(queryFutureDays.Value); - end = DateTime.Today.AddDays(futureDays); - } - - if (queryTags.HasValue) + if (tagList.IsNotNullOrWhiteSpace()) { - var tagInput = (string)queryTags.Value.ToString(); - tags.AddRange(tagInput.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); + tags.AddRange(tagList.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); } var books = _bookService.BooksBetweenDates(start, end, unmonitored); @@ -95,7 +73,7 @@ namespace Readarr.Api.V1.Calendar var serializer = (IStringSerializer)new SerializerFactory().Build(calendar.GetType(), new SerializationContext()); var icalendar = serializer.SerializeToString(calendar); - return new TextResponse(icalendar, "text/calendar"); + return Content(icalendar, "text/calendar"); } } } diff --git a/src/Readarr.Api.V1/Commands/CommandModule.cs b/src/Readarr.Api.V1/Commands/CommandController.cs similarity index 75% rename from src/Readarr.Api.V1/Commands/CommandModule.cs rename to src/Readarr.Api.V1/Commands/CommandController.cs index 0f562d70c..0978180b4 100644 --- a/src/Readarr.Api.V1/Commands/CommandModule.cs +++ b/src/Readarr.Api.V1/Commands/CommandController.cs @@ -1,27 +1,32 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common; +using NzbDrone.Common.Serializer; using NzbDrone.Common.TPL; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ProgressMessaging; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Readarr.Http; -using Readarr.Http.Extensions; +using Readarr.Http.REST; using Readarr.Http.Validation; namespace Readarr.Api.V1.Commands { - public class CommandModule : ReadarrRestModuleWithSignalR, IHandle + [V1ApiController] + public class CommandController : RestControllerWithSignalR, IHandle { private readonly IManageCommandQueue _commandQueueManager; private readonly IServiceFactory _serviceFactory; private readonly Debouncer _debouncer; private readonly Dictionary _pendingUpdates; - public CommandModule(IManageCommandQueue commandQueueManager, + public CommandController(IManageCommandQueue commandQueueManager, IBroadcastSignalRMessage signalRBroadcaster, IServiceFactory serviceFactory) : base(signalRBroadcaster) @@ -29,48 +34,52 @@ namespace Readarr.Api.V1.Commands _commandQueueManager = commandQueueManager; _serviceFactory = serviceFactory; - GetResourceById = GetCommand; - CreateResource = StartCommand; - GetResourceAll = GetStartedCommands; - DeleteResource = CancelCommand; - PostValidator.RuleFor(c => c.Name).NotBlank(); _debouncer = new Debouncer(SendUpdates, TimeSpan.FromSeconds(0.1)); _pendingUpdates = new Dictionary(); } - private CommandResource GetCommand(int id) + public override CommandResource GetResourceById(int id) { return _commandQueueManager.Get(id).ToResource(); } - private int StartCommand(CommandResource commandResource) + [RestPostById] + public ActionResult StartCommand(CommandResource commandResource) { var commandType = _serviceFactory.GetImplementations(typeof(Command)) .Single(c => c.Name.Replace("Command", "") .Equals(commandResource.Name, StringComparison.InvariantCultureIgnoreCase)); - dynamic command = Request.Body.FromJson(commandType); + Request.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(Request.Body); + var body = reader.ReadToEnd(); + + dynamic command = STJson.Deserialize(body, commandType); + command.Trigger = CommandTrigger.Manual; command.SuppressMessages = !command.SendUpdatesToClient; command.SendUpdatesToClient = true; var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual); - return trackedCommand.Id; + return Created(trackedCommand.Id); } - private List GetStartedCommands() + [HttpGet] + public List GetStartedCommands() { return _commandQueueManager.All().ToResource(); } - private void CancelCommand(int id) + [RestDeleteById] + public void CancelCommand(int id) { _commandQueueManager.Cancel(id); } + [NonAction] public void Handle(CommandUpdatedEvent message) { if (message.Command.Body.SendUpdatesToClient) diff --git a/src/Readarr.Api.V1/Config/ConfigController.cs b/src/Readarr.Api.V1/Config/ConfigController.cs new file mode 100644 index 000000000..ebdf666a8 --- /dev/null +++ b/src/Readarr.Api.V1/Config/ConfigController.cs @@ -0,0 +1,48 @@ +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Configuration; +using NzbDrone.Http.REST.Attributes; +using Readarr.Http.REST; + +namespace Readarr.Api.V1.Config +{ + public abstract class ConfigController : RestController + where TResource : RestResource, new() + { + private readonly IConfigService _configService; + + protected ConfigController(IConfigService configService) + { + _configService = configService; + } + + public override TResource GetResourceById(int id) + { + return GetConfig(); + } + + [HttpGet] + public TResource GetConfig() + { + var resource = ToResource(_configService); + resource.Id = 1; + + return resource; + } + + [RestPutById] + public ActionResult SaveConfig(TResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configService.SaveConfigDictionary(dictionary); + + return Accepted(resource.Id); + } + + protected abstract TResource ToResource(IConfigService model); + } +} diff --git a/src/Readarr.Api.V1/Config/DownloadClientConfigModule.cs b/src/Readarr.Api.V1/Config/DownloadClientConfigController.cs similarity index 57% rename from src/Readarr.Api.V1/Config/DownloadClientConfigModule.cs rename to src/Readarr.Api.V1/Config/DownloadClientConfigController.cs index 31ff0c788..e5bb8e0a3 100644 --- a/src/Readarr.Api.V1/Config/DownloadClientConfigModule.cs +++ b/src/Readarr.Api.V1/Config/DownloadClientConfigController.cs @@ -1,10 +1,12 @@ using NzbDrone.Core.Configuration; +using Readarr.Http; namespace Readarr.Api.V1.Config { - public class DownloadClientConfigModule : ReadarrConfigModule + [V1ApiController("config/downloadclient")] + public class DownloadClientConfigController : ConfigController { - public DownloadClientConfigModule(IConfigService configService) + public DownloadClientConfigController(IConfigService configService) : base(configService) { } diff --git a/src/Readarr.Api.V1/Config/HostConfigModule.cs b/src/Readarr.Api.V1/Config/HostConfigController.cs similarity index 83% rename from src/Readarr.Api.V1/Config/HostConfigModule.cs rename to src/Readarr.Api.V1/Config/HostConfigController.cs index 24b4f1b8c..e42771372 100644 --- a/src/Readarr.Api.V1/Config/HostConfigModule.cs +++ b/src/Readarr.Api.V1/Config/HostConfigController.cs @@ -3,36 +3,35 @@ using System.Linq; using System.Reflection; using System.Security.Cryptography.X509Certificates; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Config { - public class HostConfigModule : ReadarrRestModule + [V1ApiController("config/host")] + public class HostConfigController : RestController { private readonly IConfigFileProvider _configFileProvider; private readonly IConfigService _configService; private readonly IUserService _userService; - public HostConfigModule(IConfigFileProvider configFileProvider, - IConfigService configService, - IUserService userService, - FileExistsValidator fileExistsValidator) - : base("/config/host") + public HostConfigController(IConfigFileProvider configFileProvider, + IConfigService configService, + IUserService userService, + FileExistsValidator fileExistsValidator) { _configFileProvider = configFileProvider; _configService = configService; _userService = userService; - GetResourceSingle = GetHostConfig; - GetResourceById = GetHostConfig; - UpdateResource = SaveHostConfig; - SharedValidator.RuleFor(c => c.BindAddress) .ValidIp4Address() .NotListenAllIp4Address() @@ -79,7 +78,13 @@ namespace Readarr.Api.V1.Config return cert != null; } - private HostConfigResource GetHostConfig() + public override HostConfigResource GetResourceById(int id) + { + return GetHostConfig(); + } + + [HttpGet] + public HostConfigResource GetHostConfig() { var resource = _configFileProvider.ToResource(_configService); resource.Id = 1; @@ -94,12 +99,8 @@ namespace Readarr.Api.V1.Config return resource; } - private HostConfigResource GetHostConfig(int id) - { - return GetHostConfig(); - } - - private void SaveHostConfig(HostConfigResource resource) + [RestPutById] + public ActionResult SaveHostConfig(HostConfigResource resource) { var dictionary = resource.GetType() .GetProperties(BindingFlags.Instance | BindingFlags.Public) @@ -112,6 +113,8 @@ namespace Readarr.Api.V1.Config { _userService.Upsert(resource.Username, resource.Password); } + + return Accepted(resource.Id); } } } diff --git a/src/Readarr.Api.V1/Config/IndexerConfigModule.cs b/src/Readarr.Api.V1/Config/IndexerConfigController.cs similarity index 79% rename from src/Readarr.Api.V1/Config/IndexerConfigModule.cs rename to src/Readarr.Api.V1/Config/IndexerConfigController.cs index 7b50a327e..58c5c0eda 100644 --- a/src/Readarr.Api.V1/Config/IndexerConfigModule.cs +++ b/src/Readarr.Api.V1/Config/IndexerConfigController.cs @@ -1,12 +1,14 @@ using FluentValidation; using NzbDrone.Core.Configuration; +using Readarr.Http; using Readarr.Http.Validation; namespace Readarr.Api.V1.Config { - public class IndexerConfigModule : ReadarrConfigModule + [V1ApiController("config/indexer")] + public class IndexerConfigController : ConfigController { - public IndexerConfigModule(IConfigService configService) + public IndexerConfigController(IConfigService configService) : base(configService) { SharedValidator.RuleFor(c => c.MinimumAge) diff --git a/src/Readarr.Api.V1/Config/MediaManagementConfigModule.cs b/src/Readarr.Api.V1/Config/MediaManagementConfigController.cs similarity index 74% rename from src/Readarr.Api.V1/Config/MediaManagementConfigModule.cs rename to src/Readarr.Api.V1/Config/MediaManagementConfigController.cs index fcc637946..95b4929ab 100644 --- a/src/Readarr.Api.V1/Config/MediaManagementConfigModule.cs +++ b/src/Readarr.Api.V1/Config/MediaManagementConfigController.cs @@ -3,12 +3,14 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using Readarr.Http; namespace Readarr.Api.V1.Config { - public class MediaManagementConfigModule : ReadarrConfigModule + [V1ApiController("config/mediamanagement")] + public class MediaManagementConfigController : ConfigController { - public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator) + public MediaManagementConfigController(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator) : base(configService) { SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); diff --git a/src/Readarr.Api.V1/Config/MetadataProviderConfigModule.cs b/src/Readarr.Api.V1/Config/MetadataProviderConfigController.cs similarity index 68% rename from src/Readarr.Api.V1/Config/MetadataProviderConfigModule.cs rename to src/Readarr.Api.V1/Config/MetadataProviderConfigController.cs index abee235e8..089a4cc0e 100644 --- a/src/Readarr.Api.V1/Config/MetadataProviderConfigModule.cs +++ b/src/Readarr.Api.V1/Config/MetadataProviderConfigController.cs @@ -2,12 +2,14 @@ using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Validation; +using Readarr.Http; namespace Readarr.Api.V1.Config { - public class MetadataProviderConfigModule : ReadarrConfigModule + [V1ApiController("config/metadataprovider")] + public class MetadataProviderConfigController : ConfigController { - public MetadataProviderConfigModule(IConfigService configService) + public MetadataProviderConfigController(IConfigService configService) : base(configService) { SharedValidator.RuleFor(c => c.MetadataSource).IsValidUrl().When(c => !c.MetadataSource.IsNullOrWhiteSpace()); diff --git a/src/Readarr.Api.V1/Config/NamingConfigModule.cs b/src/Readarr.Api.V1/Config/NamingConfigController.cs similarity index 82% rename from src/Readarr.Api.V1/Config/NamingConfigModule.cs rename to src/Readarr.Api.V1/Config/NamingConfigController.cs index 8ddd71448..1ba8e8457 100644 --- a/src/Readarr.Api.V1/Config/NamingConfigModule.cs +++ b/src/Readarr.Api.V1/Config/NamingConfigController.cs @@ -2,49 +2,44 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; using FluentValidation.Results; -using Nancy.ModelBinding; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Organizer; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Config { - public class NamingConfigModule : ReadarrRestModule + [V1ApiController("config/naming")] + public class NamingConfigController : RestController { private readonly INamingConfigService _namingConfigService; private readonly IFilenameSampleService _filenameSampleService; private readonly IFilenameValidationService _filenameValidationService; private readonly IBuildFileNames _filenameBuilder; - public NamingConfigModule(INamingConfigService namingConfigService, + public NamingConfigController(INamingConfigService namingConfigService, IFilenameSampleService filenameSampleService, IFilenameValidationService filenameValidationService, IBuildFileNames filenameBuilder) - : base("config/naming") { _namingConfigService = namingConfigService; _filenameSampleService = filenameSampleService; _filenameValidationService = filenameValidationService; _filenameBuilder = filenameBuilder; - GetResourceSingle = GetNamingConfig; - GetResourceById = GetNamingConfig; - UpdateResource = UpdateNamingConfig; - - Get("/examples", x => GetExamples(this.Bind())); SharedValidator.RuleFor(c => c.StandardBookFormat).ValidBookFormat(); SharedValidator.RuleFor(c => c.AuthorFolderFormat).ValidAuthorFolderFormat(); } - private void UpdateNamingConfig(NamingConfigResource resource) + public override NamingConfigResource GetResourceById(int id) { - var nameSpec = resource.ToModel(); - ValidateFormatResult(nameSpec); - - _namingConfigService.Save(nameSpec); + return GetNamingConfig(); } - private NamingConfigResource GetNamingConfig() + [HttpGet] + public NamingConfigResource GetNamingConfig() { var nameSpec = _namingConfigService.GetConfig(); var resource = nameSpec.ToResource(); @@ -58,12 +53,19 @@ namespace Readarr.Api.V1.Config return resource; } - private NamingConfigResource GetNamingConfig(int id) + [RestPutById] + public ActionResult UpdateNamingConfig(NamingConfigResource resource) { - return GetNamingConfig(); + var nameSpec = resource.ToModel(); + ValidateFormatResult(nameSpec); + + _namingConfigService.Save(nameSpec); + + return Accepted(resource.Id); } - private object GetExamples(NamingConfigResource config) + [HttpGet("examples")] + public object GetExamples([FromQuery]NamingConfigResource config) { if (config.Id == 0) { diff --git a/src/Readarr.Api.V1/Config/ReadarrConfigModule.cs b/src/Readarr.Api.V1/Config/ReadarrConfigModule.cs deleted file mode 100644 index fdcd92db6..000000000 --- a/src/Readarr.Api.V1/Config/ReadarrConfigModule.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Linq; -using System.Reflection; -using NzbDrone.Core.Configuration; -using Readarr.Http; -using Readarr.Http.REST; - -namespace Readarr.Api.V1.Config -{ - public abstract class ReadarrConfigModule : ReadarrRestModule - where TResource : RestResource, new() - { - private readonly IConfigService _configService; - - protected ReadarrConfigModule(IConfigService configService) - : this(new TResource().ResourceName.Replace("config", ""), configService) - { - } - - protected ReadarrConfigModule(string resource, IConfigService configService) - : base("config/" + resource.Trim('/')) - { - _configService = configService; - - GetResourceSingle = GetConfig; - GetResourceById = GetConfig; - UpdateResource = SaveConfig; - } - - private TResource GetConfig() - { - var resource = ToResource(_configService); - resource.Id = 1; - - return resource; - } - - protected abstract TResource ToResource(IConfigService model); - - private TResource GetConfig(int id) - { - return GetConfig(); - } - - private void SaveConfig(TResource resource) - { - var dictionary = resource.GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); - - _configService.SaveConfigDictionary(dictionary); - } - } -} diff --git a/src/Readarr.Api.V1/Config/UiConfigModule.cs b/src/Readarr.Api.V1/Config/UiConfigController.cs similarity index 53% rename from src/Readarr.Api.V1/Config/UiConfigModule.cs rename to src/Readarr.Api.V1/Config/UiConfigController.cs index 74a5e23e3..cc028ea90 100644 --- a/src/Readarr.Api.V1/Config/UiConfigModule.cs +++ b/src/Readarr.Api.V1/Config/UiConfigController.cs @@ -1,10 +1,12 @@ -using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration; +using Readarr.Http; namespace Readarr.Api.V1.Config { - public class UiConfigModule : ReadarrConfigModule + [V1ApiController("config/ui")] + public class UiConfigController : ConfigController { - public UiConfigModule(IConfigService configService) + public UiConfigController(IConfigService configService) : base(configService) { } diff --git a/src/Readarr.Api.V1/CustomFilters/CustomFilterController.cs b/src/Readarr.Api.V1/CustomFilters/CustomFilterController.cs new file mode 100644 index 000000000..fd64d5ff6 --- /dev/null +++ b/src/Readarr.Api.V1/CustomFilters/CustomFilterController.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.CustomFilters; +using NzbDrone.Http.REST.Attributes; +using Readarr.Http; +using Readarr.Http.REST; + +namespace Readarr.Api.V1.CustomFilters +{ + [V1ApiController] + public class CustomFilterController : RestController + { + private readonly ICustomFilterService _customFilterService; + + public CustomFilterController(ICustomFilterService customFilterService) + { + _customFilterService = customFilterService; + } + + public override CustomFilterResource GetResourceById(int id) + { + return _customFilterService.Get(id).ToResource(); + } + + [HttpGet] + public List GetCustomFilters() + { + return _customFilterService.All().ToResource(); + } + + [RestPostById] + public ActionResult AddCustomFilter(CustomFilterResource resource) + { + var customFilter = _customFilterService.Add(resource.ToModel()); + + return Created(customFilter.Id); + } + + [RestPutById] + public ActionResult UpdateCustomFilter(CustomFilterResource resource) + { + _customFilterService.Update(resource.ToModel()); + return Accepted(resource.Id); + } + + [RestDeleteById] + public void DeleteCustomResource(int id) + { + _customFilterService.Delete(id); + } + } +} diff --git a/src/Readarr.Api.V1/CustomFilters/CustomFilterModule.cs b/src/Readarr.Api.V1/CustomFilters/CustomFilterModule.cs deleted file mode 100644 index 73febc904..000000000 --- a/src/Readarr.Api.V1/CustomFilters/CustomFilterModule.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.CustomFilters; -using Readarr.Http; - -namespace Readarr.Api.V1.CustomFilters -{ - public class CustomFilterModule : ReadarrRestModule - { - private readonly ICustomFilterService _customFilterService; - - public CustomFilterModule(ICustomFilterService customFilterService) - { - _customFilterService = customFilterService; - - GetResourceById = GetCustomFilter; - GetResourceAll = GetCustomFilters; - CreateResource = AddCustomFilter; - UpdateResource = UpdateCustomFilter; - DeleteResource = DeleteCustomResource; - } - - private CustomFilterResource GetCustomFilter(int id) - { - return _customFilterService.Get(id).ToResource(); - } - - private List GetCustomFilters() - { - return _customFilterService.All().ToResource(); - } - - private int AddCustomFilter(CustomFilterResource resource) - { - var customFilter = _customFilterService.Add(resource.ToModel()); - - return customFilter.Id; - } - - private void UpdateCustomFilter(CustomFilterResource resource) - { - _customFilterService.Update(resource.ToModel()); - } - - private void DeleteCustomResource(int id) - { - _customFilterService.Delete(id); - } - } -} diff --git a/src/Readarr.Api.V1/DiskSpace/DiskSpaceModule.cs b/src/Readarr.Api.V1/DiskSpace/DiskSpaceController.cs similarity index 62% rename from src/Readarr.Api.V1/DiskSpace/DiskSpaceModule.cs rename to src/Readarr.Api.V1/DiskSpace/DiskSpaceController.cs index c71f38c90..b314cd27b 100644 --- a/src/Readarr.Api.V1/DiskSpace/DiskSpaceModule.cs +++ b/src/Readarr.Api.V1/DiskSpace/DiskSpaceController.cs @@ -1,20 +1,21 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.DiskSpace; using Readarr.Http; namespace Readarr.Api.V1.DiskSpace { - public class DiskSpaceModule : ReadarrRestModule + [V1ApiController("diskspace")] + public class DiskSpaceController : Controller { private readonly IDiskSpaceService _diskSpaceService; - public DiskSpaceModule(IDiskSpaceService diskSpaceService) - : base("diskspace") + public DiskSpaceController(IDiskSpaceService diskSpaceService) { _diskSpaceService = diskSpaceService; - GetResourceAll = GetFreeSpace; } + [HttpGet] public List GetFreeSpace() { return _diskSpaceService.GetFreeSpace().ConvertAll(DiskSpaceResourceMapper.MapToResource); diff --git a/src/Readarr.Api.V1/DownloadClient/DownloadClientModule.cs b/src/Readarr.Api.V1/DownloadClient/DownloadClientController.cs similarity index 64% rename from src/Readarr.Api.V1/DownloadClient/DownloadClientModule.cs rename to src/Readarr.Api.V1/DownloadClient/DownloadClientController.cs index e01433102..eaa1a2201 100644 --- a/src/Readarr.Api.V1/DownloadClient/DownloadClientModule.cs +++ b/src/Readarr.Api.V1/DownloadClient/DownloadClientController.cs @@ -1,12 +1,14 @@ -using NzbDrone.Core.Download; +using NzbDrone.Core.Download; +using Readarr.Http; namespace Readarr.Api.V1.DownloadClient { - public class DownloadClientModule : ProviderModuleBase + [V1ApiController] + public class DownloadClientController : ProviderControllerBase { public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); - public DownloadClientModule(IDownloadClientFactory downloadClientFactory) + public DownloadClientController(IDownloadClientFactory downloadClientFactory) : base(downloadClientFactory, "downloadclient", ResourceMapper) { } diff --git a/src/Readarr.Api.V1/FileSystem/FileSystemModule.cs b/src/Readarr.Api.V1/FileSystem/FileSystemController.cs similarity index 54% rename from src/Readarr.Api.V1/FileSystem/FileSystemModule.cs rename to src/Readarr.Api.V1/FileSystem/FileSystemController.cs index 13b6fcf25..116c759e0 100644 --- a/src/Readarr.Api.V1/FileSystem/FileSystemModule.cs +++ b/src/Readarr.Api.V1/FileSystem/FileSystemController.cs @@ -1,44 +1,36 @@ using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Disk; using NzbDrone.Core.MediaFiles; -using Readarr.Http.Extensions; +using Readarr.Http; namespace Readarr.Api.V1.FileSystem { - public class FileSystemModule : ReadarrV1Module + [V1ApiController] + public class FileSystemController : Controller { private readonly IFileSystemLookupService _fileSystemLookupService; private readonly IDiskProvider _diskProvider; private readonly IDiskScanService _diskScanService; - public FileSystemModule(IFileSystemLookupService fileSystemLookupService, + public FileSystemController(IFileSystemLookupService fileSystemLookupService, IDiskProvider diskProvider, IDiskScanService diskScanService) - : base("/filesystem") { _fileSystemLookupService = fileSystemLookupService; _diskProvider = diskProvider; _diskScanService = diskScanService; - Get("/", x => GetContents()); - Get("/type", x => GetEntityType()); - Get("/mediafiles", x => GetMediaFiles()); } - private object GetContents() + [HttpGet] + public IActionResult GetContents(string path, bool includeFiles = false, bool allowFoldersWithoutTrailingSlashes = false) { - var pathQuery = Request.Query.path; - var includeFiles = Request.GetBooleanQueryParameter("includeFiles"); - var allowFoldersWithoutTrailingSlashes = Request.GetBooleanQueryParameter("allowFoldersWithoutTrailingSlashes"); - - return _fileSystemLookupService.LookupContents((string)pathQuery.Value, includeFiles, allowFoldersWithoutTrailingSlashes); + return Ok(_fileSystemLookupService.LookupContents(path, includeFiles, allowFoldersWithoutTrailingSlashes)); } - private object GetEntityType() + [HttpGet("type")] + public object GetEntityType(string path) { - var pathQuery = Request.Query.path; - var path = (string)pathQuery.Value; - if (_diskProvider.FileExists(path)) { return new { type = "file" }; @@ -48,11 +40,9 @@ namespace Readarr.Api.V1.FileSystem return new { type = "folder" }; } - private object GetMediaFiles() + [HttpGet("mediafiles")] + public object GetMediaFiles(string path) { - var pathQuery = Request.Query.path; - var path = (string)pathQuery.Value; - if (!_diskProvider.FolderExists(path)) { return new string[0]; diff --git a/src/Readarr.Api.V1/Health/HealthModule.cs b/src/Readarr.Api.V1/Health/HealthController.cs similarity index 54% rename from src/Readarr.Api.V1/Health/HealthModule.cs rename to src/Readarr.Api.V1/Health/HealthController.cs index f7f6199c7..cac947fd4 100644 --- a/src/Readarr.Api.V1/Health/HealthModule.cs +++ b/src/Readarr.Api.V1/Health/HealthController.cs @@ -1,29 +1,39 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.HealthCheck; using NzbDrone.Core.Messaging.Events; using NzbDrone.SignalR; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Health { - public class HealthModule : ReadarrRestModuleWithSignalR, + [V1ApiController] + public class HealthController : RestControllerWithSignalR, IHandle { private readonly IHealthCheckService _healthCheckService; - public HealthModule(IBroadcastSignalRMessage signalRBroadcaster, IHealthCheckService healthCheckService) + public HealthController(IBroadcastSignalRMessage signalRBroadcaster, IHealthCheckService healthCheckService) : base(signalRBroadcaster) { _healthCheckService = healthCheckService; - GetResourceAll = GetHealth; } - private List GetHealth() + public override HealthResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + [HttpGet] + public List GetHealth() { return _healthCheckService.Results().ToResource(); } + [NonAction] public void Handle(HealthCheckCompleteEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Readarr.Api.V1/History/HistoryModule.cs b/src/Readarr.Api.V1/History/HistoryController.cs similarity index 54% rename from src/Readarr.Api.V1/History/HistoryModule.cs rename to src/Readarr.Api.V1/History/HistoryController.cs index aba81b82c..bb20066eb 100644 --- a/src/Readarr.Api.V1/History/HistoryModule.cs +++ b/src/Readarr.Api.V1/History/HistoryController.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Download; @@ -10,28 +10,23 @@ using Readarr.Api.V1.Author; using Readarr.Api.V1.Books; using Readarr.Http; using Readarr.Http.Extensions; -using Readarr.Http.REST; namespace Readarr.Api.V1.History { - public class HistoryModule : ReadarrRestModule + [V1ApiController] + public class HistoryController : Controller { private readonly IHistoryService _historyService; private readonly IUpgradableSpecification _upgradableSpecification; private readonly IFailedDownloadService _failedDownloadService; - public HistoryModule(IHistoryService historyService, + public HistoryController(IHistoryService historyService, IUpgradableSpecification upgradableSpecification, IFailedDownloadService failedDownloadService) { _historyService = historyService; _upgradableSpecification = upgradableSpecification; _failedDownloadService = failedDownloadService; - GetResourcePaged = GetHistory; - - Get("/since", x => GetHistorySince()); - Get("/author", x => GetAuthorHistory()); - Post("/failed", x => MarkAsFailed()); } protected HistoryResource MapToResource(NzbDrone.Core.History.History model, bool includeAuthor, bool includeBook) @@ -56,11 +51,11 @@ namespace Readarr.Api.V1.History return resource; } - private PagingResource GetHistory(PagingResource pagingResource) + [HttpGet] + public PagingResource GetHistory(bool includeAuthor = false, bool includeBook = false) { + var pagingResource = Request.ReadPagingResourceFromRequest(); var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); - var includeBook = Request.GetBooleanQueryParameter("includeBook"); var eventTypeFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "eventType"); var bookIdFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "bookId"); @@ -84,66 +79,29 @@ namespace Readarr.Api.V1.History pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId); } - return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeAuthor, includeBook)); + return pagingSpec.ApplyToPage(_historyService.Paged, h => MapToResource(h, includeAuthor, includeBook)); } - private List GetHistorySince() + [HttpGet("since")] + public List GetHistorySince(DateTime date, HistoryEventType? eventType = null, bool includeAuthor = false, bool includeBook = false) { - var queryDate = Request.Query.Date; - var queryEventType = Request.Query.EventType; - - if (!queryDate.HasValue) - { - throw new BadRequestException("date is missing"); - } - - DateTime date = DateTime.Parse(queryDate.Value); - HistoryEventType? eventType = null; - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); - var includeBook = Request.GetBooleanQueryParameter("includeBook"); - - if (queryEventType.HasValue) - { - eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); - } - return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeAuthor, includeBook)).ToList(); } - private List GetAuthorHistory() + [HttpGet("author")] + public List GetAuthorHistory(int authorId, int? bookId = null, HistoryEventType? eventType = null, bool includeAuthor = false, bool includeBook = false) { - var queryAuthorId = Request.Query.AuthorId; - var queryBookId = Request.Query.BookId; - var queryEventType = Request.Query.EventType; - - if (!queryAuthorId.HasValue) - { - throw new BadRequestException("authorId is missing"); - } - - int authorId = Convert.ToInt32(queryAuthorId.Value); - HistoryEventType? eventType = null; - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); - var includeBook = Request.GetBooleanQueryParameter("includeBook"); - - if (queryEventType.HasValue) + if (bookId.HasValue) { - eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); - } - - if (queryBookId.HasValue) - { - int bookId = Convert.ToInt32(queryBookId.Value); - - return _historyService.GetByBook(bookId, eventType).Select(h => MapToResource(h, includeAuthor, includeBook)).ToList(); + return _historyService.GetByBook(bookId.Value, eventType).Select(h => MapToResource(h, includeAuthor, includeBook)).ToList(); } return _historyService.GetByAuthor(authorId, eventType).Select(h => MapToResource(h, includeAuthor, includeBook)).ToList(); } - private object MarkAsFailed() + [HttpPost("failed")] + public object MarkAsFailed([FromBody] int id) { - var id = (int)Request.Form.Id; _failedDownloadService.MarkAsFailed(id); return new object(); } diff --git a/src/Readarr.Api.V1/ImportLists/ImportListModule.cs b/src/Readarr.Api.V1/ImportLists/ImportListController.cs similarity index 84% rename from src/Readarr.Api.V1/ImportLists/ImportListModule.cs rename to src/Readarr.Api.V1/ImportLists/ImportListController.cs index 8e4431610..bd57d4220 100644 --- a/src/Readarr.Api.V1/ImportLists/ImportListModule.cs +++ b/src/Readarr.Api.V1/ImportLists/ImportListController.cs @@ -1,14 +1,16 @@ using NzbDrone.Core.ImportLists; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using Readarr.Http; namespace Readarr.Api.V1.ImportLists { - public class ImportListModule : ProviderModuleBase + [V1ApiController] + public class ImportListController : ProviderControllerBase { public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); - public ImportListModule(ImportListFactory importListFactory, + public ImportListController(ImportListFactory importListFactory, QualityProfileExistsValidator qualityProfileExistsValidator, MetadataProfileExistsValidator metadataProfileExistsValidator) : base(importListFactory, "importlist", ResourceMapper) diff --git a/src/Readarr.Api.V1/ImportLists/ImportListExclusionModule.cs b/src/Readarr.Api.V1/ImportLists/ImportListExclusionController.cs similarity index 56% rename from src/Readarr.Api.V1/ImportLists/ImportListExclusionModule.cs rename to src/Readarr.Api.V1/ImportLists/ImportListExclusionController.cs index 40fdae059..1b2e2bd10 100644 --- a/src/Readarr.Api.V1/ImportLists/ImportListExclusionModule.cs +++ b/src/Readarr.Api.V1/ImportLists/ImportListExclusionController.cs @@ -1,54 +1,57 @@ using System.Collections.Generic; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.Validation; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.ImportLists { - public class ImportListExclusionModule : ReadarrRestModule + [V1ApiController] + public class ImportListExclusionController : RestController { private readonly IImportListExclusionService _importListExclusionService; - public ImportListExclusionModule(IImportListExclusionService importListExclusionService, + public ImportListExclusionController(IImportListExclusionService importListExclusionService, ImportListExclusionExistsValidator importListExclusionExistsValidator, GuidValidator guidValidator) { _importListExclusionService = importListExclusionService; - GetResourceById = GetImportListExclusion; - GetResourceAll = GetImportListExclusions; - CreateResource = AddImportListExclusion; - UpdateResource = UpdateImportListExclusion; - DeleteResource = DeleteImportListExclusionResource; - SharedValidator.RuleFor(c => c.ForeignId).NotEmpty().SetValidator(guidValidator).SetValidator(importListExclusionExistsValidator); SharedValidator.RuleFor(c => c.AuthorName).NotEmpty(); } - private ImportListExclusionResource GetImportListExclusion(int id) + public override ImportListExclusionResource GetResourceById(int id) { return _importListExclusionService.Get(id).ToResource(); } - private List GetImportListExclusions() + [HttpGet] + public List GetImportListExclusions() { return _importListExclusionService.All().ToResource(); } - private int AddImportListExclusion(ImportListExclusionResource resource) + [RestPostById] + public ActionResult AddImportListExclusion(ImportListExclusionResource resource) { var customFilter = _importListExclusionService.Add(resource.ToModel()); - return customFilter.Id; + return Created(customFilter.Id); } - private void UpdateImportListExclusion(ImportListExclusionResource resource) + [RestPutById] + public ActionResult UpdateImportListExclusion(ImportListExclusionResource resource) { _importListExclusionService.Update(resource.ToModel()); + return Accepted(resource.Id); } - private void DeleteImportListExclusionResource(int id) + [RestDeleteById] + public void DeleteImportListExclusionResource(int id) { _importListExclusionService.Delete(id); } diff --git a/src/Readarr.Api.V1/Indexers/IndexerModule.cs b/src/Readarr.Api.V1/Indexers/IndexerController.cs similarity index 67% rename from src/Readarr.Api.V1/Indexers/IndexerModule.cs rename to src/Readarr.Api.V1/Indexers/IndexerController.cs index aafcc4ba1..e5ddba576 100644 --- a/src/Readarr.Api.V1/Indexers/IndexerModule.cs +++ b/src/Readarr.Api.V1/Indexers/IndexerController.cs @@ -1,12 +1,14 @@ -using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers; +using Readarr.Http; namespace Readarr.Api.V1.Indexers { - public class IndexerModule : ProviderModuleBase + [V1ApiController] + public class IndexerController : ProviderControllerBase { public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper(); - public IndexerModule(IndexerFactory indexerFactory) + public IndexerController(IndexerFactory indexerFactory) : base(indexerFactory, "indexer", ResourceMapper) { } diff --git a/src/Readarr.Api.V1/Indexers/ReleaseModule.cs b/src/Readarr.Api.V1/Indexers/ReleaseController.cs similarity index 91% rename from src/Readarr.Api.V1/Indexers/ReleaseModule.cs rename to src/Readarr.Api.V1/Indexers/ReleaseController.cs index 3cd25fa91..0af2e70a6 100644 --- a/src/Readarr.Api.V1/Indexers/ReleaseModule.cs +++ b/src/Readarr.Api.V1/Indexers/ReleaseController.cs @@ -1,7 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using FluentValidation; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; @@ -14,11 +15,13 @@ using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; +using Readarr.Http; using HttpStatusCode = System.Net.HttpStatusCode; namespace Readarr.Api.V1.Indexers { - public class ReleaseModule : ReleaseModuleBase + [V1ApiController] + public class ReleaseController : ReleaseControllerBase { private readonly IFetchAndParseRss _rssFetcherAndParser; private readonly ISearchForNzb _nzbSearchService; @@ -32,7 +35,7 @@ namespace Readarr.Api.V1.Indexers private readonly ICached _remoteBookCache; - public ReleaseModule(IFetchAndParseRss rssFetcherAndParser, + public ReleaseController(IFetchAndParseRss rssFetcherAndParser, ISearchForNzb nzbSearchService, IMakeDownloadDecision downloadDecisionMaker, IPrioritizeDownloadDecision prioritizeDownloadDecision, @@ -53,17 +56,17 @@ namespace Readarr.Api.V1.Indexers _parsingService = parsingService; _logger = logger; - GetResourceAll = GetReleases; - Post("/", x => DownloadRelease(ReadResourceFromRequest())); - PostValidator.RuleFor(s => s.IndexerId).ValidId(); PostValidator.RuleFor(s => s.Guid).NotEmpty(); _remoteBookCache = cacheManager.GetCache(GetType(), "remoteBooks"); } - private object DownloadRelease(ReleaseResource release) + [HttpPost] + public ActionResult Create(ReleaseResource release) { + ValidateResource(release); + var remoteBook = _remoteBookCache.Find(GetCacheKey(release)); if (remoteBook == null) @@ -129,19 +132,20 @@ namespace Readarr.Api.V1.Indexers throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed"); } - return release; + return Ok(release); } - private List GetReleases() + [HttpGet] + public List GetReleases(int? bookId, int? authorId) { - if (Request.Query.bookId.HasValue) + if (bookId.HasValue) { - return GetBookReleases(Request.Query.bookId); + return GetBookReleases(int.Parse(Request.Query["bookId"])); } - if (Request.Query.authorId.HasValue) + if (authorId.HasValue) { - return GetAuthorReleases(Request.Query.authorId); + return GetAuthorReleases(int.Parse(Request.Query["authorId"])); } return GetRss(); diff --git a/src/Readarr.Api.V1/Indexers/ReleaseModuleBase.cs b/src/Readarr.Api.V1/Indexers/ReleaseModuleBase.cs index f9254467e..ea00b5446 100644 --- a/src/Readarr.Api.V1/Indexers/ReleaseModuleBase.cs +++ b/src/Readarr.Api.V1/Indexers/ReleaseModuleBase.cs @@ -1,11 +1,17 @@ +using System; using System.Collections.Generic; using NzbDrone.Core.DecisionEngine; -using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Indexers { - public abstract class ReleaseModuleBase : ReadarrRestModule + public abstract class ReleaseControllerBase : RestController { + public override ReleaseResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + protected virtual List MapDecisions(IEnumerable decisions) { var result = new List(); diff --git a/src/Readarr.Api.V1/Indexers/ReleasePushModule.cs b/src/Readarr.Api.V1/Indexers/ReleasePushController.cs similarity index 90% rename from src/Readarr.Api.V1/Indexers/ReleasePushModule.cs rename to src/Readarr.Api.V1/Indexers/ReleasePushController.cs index f32a4556f..c5756810b 100644 --- a/src/Readarr.Api.V1/Indexers/ReleasePushModule.cs +++ b/src/Readarr.Api.V1/Indexers/ReleasePushController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; @@ -9,17 +10,19 @@ using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; +using Readarr.Http; namespace Readarr.Api.V1.Indexers { - internal class ReleasePushModule : ReleaseModuleBase + [V1ApiController("release/push")] + public class ReleasePushController : ReleaseControllerBase { private readonly IMakeDownloadDecision _downloadDecisionMaker; private readonly IProcessDownloadDecisions _downloadDecisionProcessor; private readonly IIndexerFactory _indexerFactory; private readonly Logger _logger; - public ReleasePushModule(IMakeDownloadDecision downloadDecisionMaker, + public ReleasePushController(IMakeDownloadDecision downloadDecisionMaker, IProcessDownloadDecisions downloadDecisionProcessor, IIndexerFactory indexerFactory, Logger logger) @@ -29,18 +32,19 @@ namespace Readarr.Api.V1.Indexers _indexerFactory = indexerFactory; _logger = logger; - Post("/push", x => ProcessRelease(ReadResourceFromRequest())); - PostValidator.RuleFor(s => s.Title).NotEmpty(); PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty(); PostValidator.RuleFor(s => s.Protocol).NotEmpty(); PostValidator.RuleFor(s => s.PublishDate).NotEmpty(); } - private object ProcessRelease(ReleaseResource release) + [HttpPost] + public ActionResult Create(ReleaseResource release) { _logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl); + ValidateResource(release); + var info = release.ToModel(); info.Guid = "PUSH-" + info.DownloadUrl; diff --git a/src/Readarr.Api.V1/Logs/LogModule.cs b/src/Readarr.Api.V1/Logs/LogController.cs similarity index 82% rename from src/Readarr.Api.V1/Logs/LogModule.cs rename to src/Readarr.Api.V1/Logs/LogController.cs index e29398bb4..7020e2a6b 100644 --- a/src/Readarr.Api.V1/Logs/LogModule.cs +++ b/src/Readarr.Api.V1/Logs/LogController.cs @@ -1,21 +1,25 @@ using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Instrumentation; using Readarr.Http; +using Readarr.Http.Extensions; namespace Readarr.Api.V1.Logs { - public class LogModule : ReadarrRestModule + [V1ApiController] + public class LogController : Controller { private readonly ILogService _logService; - public LogModule(ILogService logService) + public LogController(ILogService logService) { _logService = logService; - GetResourcePaged = GetLogs; } - private PagingResource GetLogs(PagingResource pagingResource) + [HttpGet] + public PagingResource GetLogs() { + var pagingResource = Request.ReadPagingResourceFromRequest(); var pageSpec = pagingResource.MapToPagingSpec(); if (pageSpec.SortKey == "time") @@ -50,7 +54,7 @@ namespace Readarr.Api.V1.Logs } } - var response = ApplyToPage(_logService.Paged, pageSpec, LogResourceMapper.ToResource); + var response = pageSpec.ApplyToPage(_logService.Paged, LogResourceMapper.ToResource); if (pageSpec.SortKey == "id") { diff --git a/src/Readarr.Api.V1/Logs/LogFileModule.cs b/src/Readarr.Api.V1/Logs/LogFileController.cs similarity index 83% rename from src/Readarr.Api.V1/Logs/LogFileModule.cs rename to src/Readarr.Api.V1/Logs/LogFileController.cs index 507d22d2e..0f63adfc4 100644 --- a/src/Readarr.Api.V1/Logs/LogFileModule.cs +++ b/src/Readarr.Api.V1/Logs/LogFileController.cs @@ -1,18 +1,20 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; +using Readarr.Http; namespace Readarr.Api.V1.Logs { - public class LogFileModule : LogFileModuleBase + [V1ApiController("log/file")] + public class LogFileController : LogFileControllerBase { private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; - public LogFileModule(IAppFolderInfo appFolderInfo, + public LogFileController(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider) : base(diskProvider, configFileProvider, "") diff --git a/src/Readarr.Api.V1/Logs/LogFileModuleBase.cs b/src/Readarr.Api.V1/Logs/LogFileModuleBase.cs index 02dd98ccf..500e497ad 100644 --- a/src/Readarr.Api.V1/Logs/LogFileModuleBase.cs +++ b/src/Readarr.Api.V1/Logs/LogFileModuleBase.cs @@ -1,35 +1,32 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; -using Readarr.Http; namespace Readarr.Api.V1.Logs { - public abstract class LogFileModuleBase : ReadarrRestModule + public abstract class LogFileControllerBase : Controller { protected const string LOGFILE_ROUTE = @"/(?[-.a-zA-Z0-9]+?\.txt)"; + protected string _resource; private readonly IDiskProvider _diskProvider; private readonly IConfigFileProvider _configFileProvider; - public LogFileModuleBase(IDiskProvider diskProvider, + public LogFileControllerBase(IDiskProvider diskProvider, IConfigFileProvider configFileProvider, - string route) - : base("log/file" + route) + string resource) { _diskProvider = diskProvider; _configFileProvider = configFileProvider; - GetResourceAll = GetLogFilesResponse; - - Get(LOGFILE_ROUTE, options => GetLogFileResponse(options.filename)); + _resource = resource; } - private List GetLogFilesResponse() + [HttpGet] + public List GetLogFilesResponse() { var result = new List(); @@ -45,7 +42,7 @@ namespace Readarr.Api.V1.Logs Id = i + 1, Filename = filename, LastWriteTime = _diskProvider.FileGetLastWrite(file), - ContentsUrl = string.Format("{0}/api/v1/{1}/{2}", _configFileProvider.UrlBase, Resource, filename), + ContentsUrl = string.Format("{0}/api/v1/{1}/{2}", _configFileProvider.UrlBase, _resource, filename), DownloadUrl = string.Format("{0}/{1}/{2}", _configFileProvider.UrlBase, DownloadUrlRoot, filename) }); } @@ -53,7 +50,8 @@ namespace Readarr.Api.V1.Logs return result.OrderByDescending(l => l.LastWriteTime).ToList(); } - private object GetLogFileResponse(string filename) + [HttpGet(@"{filename:regex([[-.a-zA-Z0-9]]+?\.txt)}")] + public IActionResult GetLogFileResponse(string filename) { LogManager.Flush(); @@ -61,12 +59,10 @@ namespace Readarr.Api.V1.Logs if (!_diskProvider.FileExists(filePath)) { - return new NotFoundResponse(); + return NotFound(); } - var data = _diskProvider.ReadAllText(filePath); - - return new TextResponse(data); + return PhysicalFile(filePath, "text/plain"); } protected abstract IEnumerable GetLogFiles(); diff --git a/src/Readarr.Api.V1/Logs/UpdateLogFileModule.cs b/src/Readarr.Api.V1/Logs/UpdateLogFileController.cs similarity index 83% rename from src/Readarr.Api.V1/Logs/UpdateLogFileModule.cs rename to src/Readarr.Api.V1/Logs/UpdateLogFileController.cs index 6c62e968c..8c057d612 100644 --- a/src/Readarr.Api.V1/Logs/UpdateLogFileModule.cs +++ b/src/Readarr.Api.V1/Logs/UpdateLogFileController.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -6,18 +6,20 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; +using Readarr.Http; namespace Readarr.Api.V1.Logs { - public class UpdateLogFileModule : LogFileModuleBase + [V1ApiController("log/file/update")] + public class UpdateLogFileController : LogFileControllerBase { private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; - public UpdateLogFileModule(IAppFolderInfo appFolderInfo, + public UpdateLogFileController(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider) - : base(diskProvider, configFileProvider, "/update") + : base(diskProvider, configFileProvider, "update") { _appFolderInfo = appFolderInfo; _diskProvider = diskProvider; diff --git a/src/Readarr.Api.V1/ManualImport/ManualImportModule.cs b/src/Readarr.Api.V1/ManualImport/ManualImportController.cs similarity index 71% rename from src/Readarr.Api.V1/ManualImport/ManualImportModule.cs rename to src/Readarr.Api.V1/ManualImport/ManualImportController.cs index 6933d7cf1..39b41f678 100644 --- a/src/Readarr.Api.V1/ManualImport/ManualImportModule.cs +++ b/src/Readarr.Api.V1/ManualImport/ManualImportController.cs @@ -1,18 +1,17 @@ -using System; using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NLog; using NzbDrone.Core.Books; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.BookImport.Manual; using NzbDrone.Core.Qualities; using Readarr.Http; -using Readarr.Http.Extensions; namespace Readarr.Api.V1.ManualImport { - public class ManualImportModule : ReadarrRestModule + [V1ApiController] + public class ManualImportController : Controller { private readonly IAuthorService _authorService; private readonly IBookService _bookService; @@ -20,7 +19,7 @@ namespace Readarr.Api.V1.ManualImport private readonly IManualImportService _manualImportService; private readonly Logger _logger; - public ManualImportModule(IManualImportService manualImportService, + public ManualImportController(IManualImportService manualImportService, IAuthorService authorService, IEditionService editionService, IBookService bookService, @@ -31,31 +30,25 @@ namespace Readarr.Api.V1.ManualImport _editionService = editionService; _manualImportService = manualImportService; _logger = logger; + } - GetResourceAll = GetMediaFiles; - - Put("/", options => - { - var resource = Request.Body.FromJson>(); - return ResponseWithCode(UpdateImportItems(resource), HttpStatusCode.Accepted); - }); + [HttpPut] + public IActionResult UpdateItems(List resource) + { + return Accepted(UpdateImportItems(resource)); } - private List GetMediaFiles() + [HttpGet] + public List GetMediaFiles(string folder, string downloadId, int? authorId, bool filterExistingFiles = true, bool replaceExistingFiles = true) { - var folder = (string)Request.Query.folder; - var downloadId = (string)Request.Query.downloadId; NzbDrone.Core.Books.Author author = null; - var authorIdQuery = Request.GetNullableIntegerQueryParameter("authorId", null); - - if (authorIdQuery.HasValue && authorIdQuery.Value > 0) + if (authorId > 0) { - author = _authorService.GetAuthor(Convert.ToInt32(authorIdQuery.Value)); + author = _authorService.GetAuthor(authorId.Value); } - var filter = Request.GetBooleanQueryParameter("filterExistingFiles", true) ? FilterFilesType.Matched : FilterFilesType.None; - var replaceExistingFiles = Request.GetBooleanQueryParameter("replaceExistingFiles", true); + var filter = filterExistingFiles ? FilterFilesType.Matched : FilterFilesType.None; return _manualImportService.GetMediaFiles(folder, downloadId, author, filter, replaceExistingFiles).ToResource().Select(AddQualityWeight).ToList(); } diff --git a/src/Readarr.Api.V1/MediaCovers/MediaCoverModule.cs b/src/Readarr.Api.V1/MediaCovers/MediaCoverController.cs similarity index 62% rename from src/Readarr.Api.V1/MediaCovers/MediaCoverModule.cs rename to src/Readarr.Api.V1/MediaCovers/MediaCoverController.cs index 692a83108..8252714b3 100644 --- a/src/Readarr.Api.V1/MediaCovers/MediaCoverModule.cs +++ b/src/Readarr.Api.V1/MediaCovers/MediaCoverController.cs @@ -1,34 +1,32 @@ using System.IO; using System.Text.RegularExpressions; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using Readarr.Http; namespace Readarr.Api.V1.MediaCovers { - public class MediaCoverModule : ReadarrV1Module + [V1ApiController] + public class MediaCoverController : Controller { - private const string MEDIA_COVER_AUTHOR_ROUTE = @"/Author/(?\d+)/(?(.+)\.(jpg|png|gif))"; - private const string MEDIA_COVER_BOOK_ROUTE = @"/Book/(?\d+)/(?(.+)\.(jpg|png|gif))"; - private static readonly Regex RegexResizedImage = new Regex(@"-\d+(?=\.(jpg|png|gif)$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; + private readonly IContentTypeProvider _mimeTypeProvider; - public MediaCoverModule(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) - : base("MediaCover") + public MediaCoverController(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) { _appFolderInfo = appFolderInfo; _diskProvider = diskProvider; - - Get(MEDIA_COVER_AUTHOR_ROUTE, options => GetAuthorMediaCover(options.authorId, options.filename)); - Get(MEDIA_COVER_BOOK_ROUTE, options => GetBookMediaCover(options.authorId, options.filename)); + _mimeTypeProvider = new FileExtensionContentTypeProvider(); } - private object GetAuthorMediaCover(int authorId, string filename) + [HttpGet(@"author/{authorId:int}/{filename:regex((.+)\.(jpg|png|gif))}")] + public IActionResult GetAuthorMediaCover(int authorId, string filename) { var filePath = Path.Combine(_appFolderInfo.GetAppDataPath(), "MediaCover", authorId.ToString(), filename); @@ -39,16 +37,17 @@ namespace Readarr.Api.V1.MediaCovers var basefilePath = RegexResizedImage.Replace(filePath, ""); if (basefilePath == filePath || !_diskProvider.FileExists(basefilePath)) { - return new NotFoundResponse(); + return NotFound(); } filePath = basefilePath; } - return new StreamResponse(() => File.OpenRead(filePath), MimeTypes.GetMimeType(filePath)); + return PhysicalFile(filePath, GetContentType(filePath)); } - private object GetBookMediaCover(int bookId, string filename) + [HttpGet(@"book/{bookId:int}/{filename:regex((.+)\.(jpg|png|gif))}")] + public IActionResult GetBookMediaCover(int bookId, string filename) { var filePath = Path.Combine(_appFolderInfo.GetAppDataPath(), "MediaCover", "Books", bookId.ToString(), filename); @@ -59,13 +58,23 @@ namespace Readarr.Api.V1.MediaCovers var basefilePath = RegexResizedImage.Replace(filePath, ""); if (basefilePath == filePath || !_diskProvider.FileExists(basefilePath)) { - return new NotFoundResponse(); + return NotFound(); } filePath = basefilePath; } - return new StreamResponse(() => File.OpenRead(filePath), MimeTypes.GetMimeType(filePath)); + return PhysicalFile(filePath, GetContentType(filePath)); + } + + private string GetContentType(string filePath) + { + if (!_mimeTypeProvider.TryGetContentType(filePath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + return contentType; } } } diff --git a/src/Readarr.Api.V1/Metadata/MetadataModule.cs b/src/Readarr.Api.V1/Metadata/MetadataController.cs similarity index 65% rename from src/Readarr.Api.V1/Metadata/MetadataModule.cs rename to src/Readarr.Api.V1/Metadata/MetadataController.cs index 6284d8a4e..5bf1e40c5 100644 --- a/src/Readarr.Api.V1/Metadata/MetadataModule.cs +++ b/src/Readarr.Api.V1/Metadata/MetadataController.cs @@ -1,12 +1,14 @@ -using NzbDrone.Core.Extras.Metadata; +using NzbDrone.Core.Extras.Metadata; +using Readarr.Http; namespace Readarr.Api.V1.Metadata { - public class MetadataModule : ProviderModuleBase + [V1ApiController] + public class MetadataController : ProviderControllerBase { public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper(); - public MetadataModule(IMetadataFactory metadataFactory) + public MetadataController(IMetadataFactory metadataFactory) : base(metadataFactory, "metadata", ResourceMapper) { } diff --git a/src/Readarr.Api.V1/Notifications/NotificationModule.cs b/src/Readarr.Api.V1/Notifications/NotificationController.cs similarity index 64% rename from src/Readarr.Api.V1/Notifications/NotificationModule.cs rename to src/Readarr.Api.V1/Notifications/NotificationController.cs index c465ad6e1..9abcd1d2c 100644 --- a/src/Readarr.Api.V1/Notifications/NotificationModule.cs +++ b/src/Readarr.Api.V1/Notifications/NotificationController.cs @@ -1,12 +1,14 @@ -using NzbDrone.Core.Notifications; +using NzbDrone.Core.Notifications; +using Readarr.Http; namespace Readarr.Api.V1.Notifications { - public class NotificationModule : ProviderModuleBase + [V1ApiController] + public class NotificationController : ProviderControllerBase { public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); - public NotificationModule(NotificationFactory notificationFactory) + public NotificationController(NotificationFactory notificationFactory) : base(notificationFactory, "notification", ResourceMapper) { } diff --git a/src/Readarr.Api.V1/Parse/ParseModule.cs b/src/Readarr.Api.V1/Parse/ParseController.cs similarity index 80% rename from src/Readarr.Api.V1/Parse/ParseModule.cs rename to src/Readarr.Api.V1/Parse/ParseController.cs index e122c590d..b00bc4fc9 100644 --- a/src/Readarr.Api.V1/Parse/ParseModule.cs +++ b/src/Readarr.Api.V1/Parse/ParseController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Parser; using Readarr.Api.V1.Author; using Readarr.Api.V1.Books; @@ -5,20 +6,19 @@ using Readarr.Http; namespace Readarr.Api.V1.Parse { - public class ParseModule : ReadarrRestModule + [V1ApiController] + public class ParseController : Controller { private readonly IParsingService _parsingService; - public ParseModule(IParsingService parsingService) + public ParseController(IParsingService parsingService) { _parsingService = parsingService; - - GetResourceSingle = Parse; } - private ParseResource Parse() + [HttpGet] + public ParseResource Parse(string title) { - var title = Request.Query.Title.Value as string; var parsedBookInfo = Parser.ParseBookTitle(title); if (parsedBookInfo == null) diff --git a/src/Readarr.Api.V1/Profiles/Delay/DelayProfileModule.cs b/src/Readarr.Api.V1/Profiles/Delay/DelayProfileController.cs similarity index 65% rename from src/Readarr.Api.V1/Profiles/Delay/DelayProfileModule.cs rename to src/Readarr.Api.V1/Profiles/Delay/DelayProfileController.cs index 3fbdccb1b..373c986ab 100644 --- a/src/Readarr.Api.V1/Profiles/Delay/DelayProfileModule.cs +++ b/src/Readarr.Api.V1/Profiles/Delay/DelayProfileController.cs @@ -1,29 +1,23 @@ -using System; using System.Collections.Generic; using FluentValidation; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; using Readarr.Http.REST; using Readarr.Http.Validation; namespace Readarr.Api.V1.Profiles.Delay { - public class DelayProfileModule : ReadarrRestModule + [V1ApiController] + public class DelayProfileController : RestController { private readonly IDelayProfileService _delayProfileService; - public DelayProfileModule(IDelayProfileService delayProfileService, DelayProfileTagInUseValidator tagInUseValidator) + public DelayProfileController(IDelayProfileService delayProfileService, DelayProfileTagInUseValidator tagInUseValidator) { _delayProfileService = delayProfileService; - GetResourceAll = GetAll; - GetResourceById = GetById; - UpdateResource = Update; - CreateResource = Create; - DeleteResource = DeleteProfile; - Put(@"/reorder/(?[\d]{1,10})", options => Reorder(options.Id)); - SharedValidator.RuleFor(d => d.Tags).NotEmpty().When(d => d.Id != 1); SharedValidator.RuleFor(d => d.Tags).EmptyCollection().When(d => d.Id == 1); SharedValidator.RuleFor(d => d.Tags).SetValidator(tagInUseValidator); @@ -39,15 +33,17 @@ namespace Readarr.Api.V1.Profiles.Delay }); } - private int Create(DelayProfileResource resource) + [RestPostById] + public ActionResult Create(DelayProfileResource resource) { var model = resource.ToModel(); model = _delayProfileService.Add(model); - return model.Id; + return Created(model.Id); } - private void DeleteProfile(int id) + [RestDeleteById] + public void DeleteProfile(int id) { if (id == 1) { @@ -57,29 +53,30 @@ namespace Readarr.Api.V1.Profiles.Delay _delayProfileService.Delete(id); } - private void Update(DelayProfileResource resource) + [RestPutById] + public ActionResult Update(DelayProfileResource resource) { var model = resource.ToModel(); _delayProfileService.Update(model); + return Accepted(model.Id); } - private DelayProfileResource GetById(int id) + public override DelayProfileResource GetResourceById(int id) { return _delayProfileService.Get(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { return _delayProfileService.All().ToResource(); } - private object Reorder(int id) + [HttpPut("reorder/{id:int}")] + public object Reorder(int id, [FromQuery] int? afterId = null) { ValidateId(id); - var afterIdQuery = Request.Query.After; - int? afterId = afterIdQuery.HasValue ? Convert.ToInt32(afterIdQuery.Value) : null; - return _delayProfileService.Reorder(id, afterId).ToResource(); } } diff --git a/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs b/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileController.cs similarity index 53% rename from src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs rename to src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileController.cs index 9b64a05f3..785d7a202 100644 --- a/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs +++ b/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileController.cs @@ -1,51 +1,55 @@ using System.Collections.Generic; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Profiles.Metadata { - public class MetadataProfileModule : ReadarrRestModule + [V1ApiController] + public class MetadataProfileController : RestController { private readonly IMetadataProfileService _profileService; - public MetadataProfileModule(IMetadataProfileService profileService) + public MetadataProfileController(IMetadataProfileService profileService) { _profileService = profileService; SharedValidator.RuleFor(c => c.Name).NotEqual("None").WithMessage("'None' is a reserved profile name").NotEmpty(); - - GetResourceAll = GetAll; - GetResourceById = GetById; - UpdateResource = Update; - CreateResource = Create; - DeleteResource = DeleteProfile; } - private int Create(MetadataProfileResource resource) + [RestPostById] + public ActionResult Create(MetadataProfileResource resource) { var model = resource.ToModel(); model = _profileService.Add(model); - return model.Id; + return Created(model.Id); } - private void DeleteProfile(int id) + [RestDeleteById] + public void DeleteProfile(int id) { _profileService.Delete(id); } - private void Update(MetadataProfileResource resource) + [RestPutById] + public ActionResult Update(MetadataProfileResource resource) { var model = resource.ToModel(); _profileService.Update(model); + + return Accepted(model.Id); } - private MetadataProfileResource GetById(int id) + public override MetadataProfileResource GetResourceById(int id) { return _profileService.Get(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { var profiles = _profileService.All().ToResource(); diff --git a/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs b/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaController.cs similarity index 51% rename from src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs rename to src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaController.cs index d9fb24261..66f8f71d2 100644 --- a/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs +++ b/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaController.cs @@ -1,17 +1,14 @@ +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Profiles.Metadata; using Readarr.Http; namespace Readarr.Api.V1.Profiles.Metadata { - public class MetadataProfileSchemaModule : ReadarrRestModule + [V1ApiController("metadataprofile/schema")] + public class MetadataProfileSchemaController : Controller { - public MetadataProfileSchemaModule() - : base("/metadataprofile/schema") - { - GetResourceSingle = GetAll; - } - - private MetadataProfileResource GetAll() + [HttpGet] + public MetadataProfileResource GetAll() { var profile = new MetadataProfile { diff --git a/src/Readarr.Api.V1/Profiles/Quality/QualityProfileModule.cs b/src/Readarr.Api.V1/Profiles/Quality/QualityProfileController.cs similarity index 54% rename from src/Readarr.Api.V1/Profiles/Quality/QualityProfileModule.cs rename to src/Readarr.Api.V1/Profiles/Quality/QualityProfileController.cs index 937288c34..21fbe20db 100644 --- a/src/Readarr.Api.V1/Profiles/Quality/QualityProfileModule.cs +++ b/src/Readarr.Api.V1/Profiles/Quality/QualityProfileController.cs @@ -1,53 +1,57 @@ using System.Collections.Generic; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Profiles.Quality { - public class ProfileModule : ReadarrRestModule + [V1ApiController] + public class QualityProfileController : RestController { private readonly IProfileService _profileService; - public ProfileModule(IProfileService profileService) + public QualityProfileController(IProfileService profileService) { _profileService = profileService; SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Cutoff).ValidCutoff(); SharedValidator.RuleFor(c => c.Items).ValidItems(); - - GetResourceAll = GetAll; - GetResourceById = GetById; - UpdateResource = Update; - CreateResource = Create; - DeleteResource = DeleteProfile; } - private int Create(QualityProfileResource resource) + [RestPostById] + public ActionResult Create(QualityProfileResource resource) { var model = resource.ToModel(); model = _profileService.Add(model); - return model.Id; + return Created(model.Id); } - private void DeleteProfile(int id) + [RestDeleteById] + public void DeleteProfile(int id) { _profileService.Delete(id); } - private void Update(QualityProfileResource resource) + [RestPutById] + public ActionResult Update(QualityProfileResource resource) { var model = resource.ToModel(); _profileService.Update(model); + + return Accepted(model.Id); } - private QualityProfileResource GetById(int id) + public override QualityProfileResource GetResourceById(int id) { return _profileService.Get(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { return _profileService.All().ToResource(); } diff --git a/src/Readarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs b/src/Readarr.Api.V1/Profiles/Quality/QualityProfileSchemaController.cs similarity index 57% rename from src/Readarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs rename to src/Readarr.Api.V1/Profiles/Quality/QualityProfileSchemaController.cs index 308ad4732..d7955d8e8 100644 --- a/src/Readarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs +++ b/src/Readarr.Api.V1/Profiles/Quality/QualityProfileSchemaController.cs @@ -1,20 +1,21 @@ +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Profiles.Qualities; using Readarr.Http; namespace Readarr.Api.V1.Profiles.Quality { - public class QualityProfileSchemaModule : ReadarrRestModule + [V1ApiController("qualityprofile/schema")] + public class QualityProfileSchemaController : Controller { private readonly IProfileService _profileService; - public QualityProfileSchemaModule(IProfileService profileService) - : base("/qualityprofile/schema") + public QualityProfileSchemaController(IProfileService profileService) { _profileService = profileService; - GetResourceSingle = GetSchema; } - private QualityProfileResource GetSchema() + [HttpGet] + public QualityProfileResource GetSchema() { QualityProfile qualityProfile = _profileService.GetDefaultProfile(string.Empty); diff --git a/src/Readarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs b/src/Readarr.Api.V1/Profiles/Release/ReleaseProfileController.cs similarity index 62% rename from src/Readarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs rename to src/Readarr.Api.V1/Profiles/Release/ReleaseProfileController.cs index 8301c43fc..2ea3d8466 100644 --- a/src/Readarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs +++ b/src/Readarr.Api.V1/Profiles/Release/ReleaseProfileController.cs @@ -1,28 +1,26 @@ using System.Collections.Generic; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Profiles.Release { - public class ReleaseProfileModule : ReadarrRestModule + [V1ApiController] + public class ReleaseProfileController : RestController { private readonly IReleaseProfileService _releaseProfileService; private readonly IIndexerFactory _indexerFactory; - public ReleaseProfileModule(IReleaseProfileService releaseProfileService, IIndexerFactory indexerFactory) + public ReleaseProfileController(IReleaseProfileService releaseProfileService, IIndexerFactory indexerFactory) { _releaseProfileService = releaseProfileService; _indexerFactory = indexerFactory; - GetResourceById = GetById; - GetResourceAll = GetAll; - CreateResource = Create; - UpdateResource = Update; - DeleteResource = DeleteById; - SharedValidator.RuleFor(r => r).Custom((restriction, context) => { if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace() && restriction.Preferred.Empty()) @@ -37,27 +35,32 @@ namespace Readarr.Api.V1.Profiles.Release }); } - private ReleaseProfileResource GetById(int id) + public override ReleaseProfileResource GetResourceById(int id) { return _releaseProfileService.Get(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { return _releaseProfileService.All().ToResource(); } - private int Create(ReleaseProfileResource resource) + [RestPostById] + public ActionResult Create(ReleaseProfileResource resource) { - return _releaseProfileService.Add(resource.ToModel()).Id; + return Created(_releaseProfileService.Add(resource.ToModel()).Id); } - private void Update(ReleaseProfileResource resource) + [RestPutById] + public ActionResult Update(ReleaseProfileResource resource) { _releaseProfileService.Update(resource.ToModel()); + return Accepted(resource.Id); } - private void DeleteById(int id) + [RestDeleteById] + public void DeleteById(int id) { _releaseProfileService.Delete(id); } diff --git a/src/Readarr.Api.V1/ProviderModuleBase.cs b/src/Readarr.Api.V1/ProviderModuleBase.cs index 592a5b6d5..a64429539 100644 --- a/src/Readarr.Api.V1/ProviderModuleBase.cs +++ b/src/Readarr.Api.V1/ProviderModuleBase.cs @@ -2,15 +2,16 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; using FluentValidation.Results; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Serializer; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; -using Readarr.Http; +using NzbDrone.Http.REST.Attributes; +using Readarr.Http.REST; namespace Readarr.Api.V1 { - public abstract class ProviderModuleBase : ReadarrRestModule + public abstract class ProviderControllerBase : RestController where TProviderDefinition : ProviderDefinition, new() where TProvider : IProvider where TProviderResource : ProviderResource, new() @@ -18,23 +19,11 @@ namespace Readarr.Api.V1 private readonly IProviderFactory _providerFactory; private readonly ProviderResourceMapper _resourceMapper; - protected ProviderModuleBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper) - : base(resource) + protected ProviderControllerBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper) { _providerFactory = providerFactory; _resourceMapper = resourceMapper; - Get("schema", x => GetTemplates()); - Post("test", x => Test(ReadResourceFromRequest(true))); - Post("testall", x => TestAll()); - Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true, true))); - - GetResourceAll = GetAll; - GetResourceById = GetProviderById; - CreateResource = CreateProvider; - UpdateResource = UpdateProvider; - DeleteResource = DeleteProvider; - SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); SharedValidator.RuleFor(c => c.Implementation).NotEmpty(); @@ -43,7 +32,7 @@ namespace Readarr.Api.V1 PostValidator.RuleFor(c => c.Fields).NotNull(); } - private TProviderResource GetProviderById(int id) + public override TProviderResource GetResourceById(int id) { var definition = _providerFactory.Get(id); _providerFactory.SetProviderCharacteristics(definition); @@ -51,7 +40,8 @@ namespace Readarr.Api.V1 return _resourceMapper.ToResource(definition); } - private List GetAll() + [HttpGet] + public List GetAll() { var providerDefinitions = _providerFactory.All().OrderBy(p => p.ImplementationName); @@ -67,7 +57,8 @@ namespace Readarr.Api.V1 return result.OrderBy(p => p.Name).ToList(); } - private int CreateProvider(TProviderResource providerResource) + [RestPostById] + public ActionResult CreateProvider(TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource, false); @@ -78,10 +69,11 @@ namespace Readarr.Api.V1 providerDefinition = _providerFactory.Create(providerDefinition); - return providerDefinition.Id; + return Created(providerDefinition.Id); } - private void UpdateProvider(TProviderResource providerResource) + [RestPutById] + public ActionResult UpdateProvider(TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource, false); @@ -91,6 +83,8 @@ namespace Readarr.Api.V1 } _providerFactory.Update(providerDefinition); + + return Accepted(providerResource.Id); } private TProviderDefinition GetDefinition(TProviderResource providerResource, bool includeWarnings = false, bool validate = true) @@ -105,12 +99,14 @@ namespace Readarr.Api.V1 return definition; } - private void DeleteProvider(int id) + [RestDeleteById] + public void DeleteProvider(int id) { _providerFactory.Delete(id); } - private object GetTemplates() + [HttpGet("schema")] + public List GetTemplates() { var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList(); @@ -131,7 +127,9 @@ namespace Readarr.Api.V1 return result; } - private object Test(TProviderResource providerResource) + [SkipValidation(true, false)] + [HttpPost("test")] + public object Test([FromBody] TProviderResource providerResource) { var providerDefinition = GetDefinition(providerResource, true); @@ -140,7 +138,8 @@ namespace Readarr.Api.V1 return "{}"; } - private object TestAll() + [HttpPost("testall")] + public IActionResult TestAll() { var providerDefinitions = _providerFactory.All() .Where(c => c.Settings.Validate().IsValid && c.Enable) @@ -158,19 +157,20 @@ namespace Readarr.Api.V1 }); } - return ResponseWithCode(result, result.Any(c => !c.IsValid) ? HttpStatusCode.BadRequest : HttpStatusCode.OK); + return result.Any(c => !c.IsValid) ? BadRequest(result) : Ok(result); } - private object RequestAction(string action, TProviderResource providerResource) + [SkipValidation] + [HttpPost("action/{name}")] + public IActionResult RequestAction(string name, [FromBody] TProviderResource resource) { - var providerDefinition = GetDefinition(providerResource, true, false); + var providerDefinition = GetDefinition(resource, true, false); + + var query = Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString()); - var query = ((IDictionary)Request.Query.ToDictionary()).ToDictionary(k => k.Key, k => k.Value.ToString()); + var data = _providerFactory.RequestAction(providerDefinition, name, query); - var data = _providerFactory.RequestAction(providerDefinition, action, query); - Response resp = data.ToJson(); - resp.ContentType = "application/json"; - return resp; + return Content(data.ToJson(), "application/json"); } protected virtual void Validate(TProviderDefinition definition, bool includeWarnings) diff --git a/src/Readarr.Api.V1/Qualities/QualityDefinitionController.cs b/src/Readarr.Api.V1/Qualities/QualityDefinitionController.cs new file mode 100644 index 000000000..49f25f9e1 --- /dev/null +++ b/src/Readarr.Api.V1/Qualities/QualityDefinitionController.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Qualities; +using NzbDrone.Http.REST.Attributes; +using Readarr.Http; +using Readarr.Http.REST; + +namespace Readarr.Api.V1.Qualities +{ + [V1ApiController] + public class QualityDefinitionController : RestController + { + private readonly IQualityDefinitionService _qualityDefinitionService; + + public QualityDefinitionController(IQualityDefinitionService qualityDefinitionService) + { + _qualityDefinitionService = qualityDefinitionService; + } + + [RestPutById] + public ActionResult Update(QualityDefinitionResource resource) + { + var model = resource.ToModel(); + _qualityDefinitionService.Update(model); + return Accepted(model.Id); + } + + public override QualityDefinitionResource GetResourceById(int id) + { + return _qualityDefinitionService.GetById(id).ToResource(); + } + + [HttpGet] + public List GetAll() + { + return _qualityDefinitionService.All().ToResource(); + } + + [HttpPut("update")] + public object UpdateMany([FromBody] List resource) + { + //Read from request + var qualityDefinitions = resource + .ToModel() + .ToList(); + + _qualityDefinitionService.UpdateMany(qualityDefinitions); + + return Accepted(_qualityDefinitionService.All() + .ToResource()); + } + } +} diff --git a/src/Readarr.Api.V1/Qualities/QualityDefinitionModule.cs b/src/Readarr.Api.V1/Qualities/QualityDefinitionModule.cs deleted file mode 100644 index e356c4f3b..000000000 --- a/src/Readarr.Api.V1/Qualities/QualityDefinitionModule.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Nancy; -using NzbDrone.Core.Qualities; -using Readarr.Http; -using Readarr.Http.Extensions; - -namespace Readarr.Api.V1.Qualities -{ - public class QualityDefinitionModule : ReadarrRestModule - { - private readonly IQualityDefinitionService _qualityDefinitionService; - - public QualityDefinitionModule(IQualityDefinitionService qualityDefinitionService) - { - _qualityDefinitionService = qualityDefinitionService; - - GetResourceAll = GetAll; - GetResourceById = GetById; - UpdateResource = Update; - Put("/update", d => UpdateMany()); - } - - private void Update(QualityDefinitionResource resource) - { - var model = resource.ToModel(); - _qualityDefinitionService.Update(model); - } - - private QualityDefinitionResource GetById(int id) - { - return _qualityDefinitionService.GetById(id).ToResource(); - } - - private List GetAll() - { - return _qualityDefinitionService.All().ToResource(); - } - - private object UpdateMany() - { - //Read from request - var qualityDefinitions = Request.Body.FromJson>() - .ToModel() - .ToList(); - - _qualityDefinitionService.UpdateMany(qualityDefinitions); - - return ResponseWithCode(_qualityDefinitionService.All() - .ToResource(), - HttpStatusCode.Accepted); - } - } -} diff --git a/src/Readarr.Api.V1/Queue/QueueActionController.cs b/src/Readarr.Api.V1/Queue/QueueActionController.cs new file mode 100644 index 000000000..ab8c9b4d1 --- /dev/null +++ b/src/Readarr.Api.V1/Queue/QueueActionController.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Pending; +using Readarr.Http; +using Readarr.Http.REST; + +namespace Readarr.Api.V1.Queue +{ + [V1ApiController("queue")] + public class QueueActionController : Controller + { + private readonly IPendingReleaseService _pendingReleaseService; + private readonly IDownloadService _downloadService; + + public QueueActionController(IPendingReleaseService pendingReleaseService, + IDownloadService downloadService) + { + _pendingReleaseService = pendingReleaseService; + _downloadService = downloadService; + } + + [HttpPost("grab/{id:int}")] + public object Grab(int id) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease == null) + { + throw new NotFoundException(); + } + + _downloadService.DownloadReport(pendingRelease.RemoteBook); + + return new object(); + } + + [HttpPost("grab/bulk")] + public object Grab([FromBody] QueueBulkResource resource) + { + foreach (var id in resource.Ids) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease == null) + { + throw new NotFoundException(); + } + + _downloadService.DownloadReport(pendingRelease.RemoteBook); + } + + return new object(); + } + } +} diff --git a/src/Readarr.Api.V1/Queue/QueueActionModule.cs b/src/Readarr.Api.V1/Queue/QueueActionModule.cs deleted file mode 100644 index eacf3a963..000000000 --- a/src/Readarr.Api.V1/Queue/QueueActionModule.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System.Collections.Generic; -using Nancy; -using NzbDrone.Core.Download; -using NzbDrone.Core.Download.Pending; -using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.Queue; -using Readarr.Http; -using Readarr.Http.Extensions; -using Readarr.Http.REST; - -namespace Readarr.Api.V1.Queue -{ - public class QueueActionModule : ReadarrRestModule - { - private readonly IQueueService _queueService; - private readonly ITrackedDownloadService _trackedDownloadService; - private readonly IFailedDownloadService _failedDownloadService; - private readonly IIgnoredDownloadService _ignoredDownloadService; - private readonly IProvideDownloadClient _downloadClientProvider; - private readonly IPendingReleaseService _pendingReleaseService; - private readonly IDownloadService _downloadService; - - public QueueActionModule(IQueueService queueService, - ITrackedDownloadService trackedDownloadService, - IFailedDownloadService failedDownloadService, - IIgnoredDownloadService ignoredDownloadService, - IProvideDownloadClient downloadClientProvider, - IPendingReleaseService pendingReleaseService, - IDownloadService downloadService) - { - _queueService = queueService; - _trackedDownloadService = trackedDownloadService; - _failedDownloadService = failedDownloadService; - _ignoredDownloadService = ignoredDownloadService; - _downloadClientProvider = downloadClientProvider; - _pendingReleaseService = pendingReleaseService; - _downloadService = downloadService; - - Post(@"/grab/(?[\d]{1,10})", x => Grab((int)x.Id)); - Post("/grab/bulk", x => Grab()); - - Delete(@"/(?[\d]{1,10})", x => Remove((int)x.Id)); - Delete("/bulk", x => Remove()); - } - - private object Grab(int id) - { - var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); - - if (pendingRelease == null) - { - throw new NotFoundException(); - } - - _downloadService.DownloadReport(pendingRelease.RemoteBook); - - return new object(); - } - - private object Grab() - { - var resource = Request.Body.FromJson(); - - foreach (var id in resource.Ids) - { - var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); - - if (pendingRelease == null) - { - throw new NotFoundException(); - } - - _downloadService.DownloadReport(pendingRelease.RemoteBook); - } - - return new object(); - } - - private object Remove(int id) - { - var removeFromClient = Request.GetBooleanQueryParameter("removeFromClient", true); - var blacklist = Request.GetBooleanQueryParameter("blacklist"); - var skipReDownload = Request.GetBooleanQueryParameter("skipredownload"); - - var trackedDownload = Remove(id, removeFromClient, blacklist, skipReDownload); - - if (trackedDownload != null) - { - _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); - } - - return new object(); - } - - private object Remove() - { - var removeFromClient = Request.GetBooleanQueryParameter("removeFromClient", true); - var blacklist = Request.GetBooleanQueryParameter("blacklist"); - var skipReDownload = Request.GetBooleanQueryParameter("skipredownload"); - - var resource = Request.Body.FromJson(); - var trackedDownloadIds = new List(); - - foreach (var id in resource.Ids) - { - var trackedDownload = Remove(id, removeFromClient, blacklist, skipReDownload); - - if (trackedDownload != null) - { - trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId); - } - } - - _trackedDownloadService.StopTracking(trackedDownloadIds); - - return new object(); - } - - private TrackedDownload Remove(int id, bool removeFromClient, bool blacklist, bool skipReDownload) - { - var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); - - if (pendingRelease != null) - { - _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); - - return null; - } - - var trackedDownload = GetTrackedDownload(id); - - if (trackedDownload == null) - { - throw new NotFoundException(); - } - - if (removeFromClient) - { - var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); - - if (downloadClient == null) - { - throw new BadRequestException(); - } - - downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadId, true); - } - - if (blacklist) - { - _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipReDownload); - } - - if (!removeFromClient && !blacklist) - { - if (!_ignoredDownloadService.IgnoreDownload(trackedDownload)) - { - return null; - } - } - - return trackedDownload; - } - - private TrackedDownload GetTrackedDownload(int queueId) - { - var queueItem = _queueService.Find(queueId); - - if (queueItem == null) - { - throw new NotFoundException(); - } - - var trackedDownload = _trackedDownloadService.Find(queueItem.DownloadId); - - if (trackedDownload == null) - { - throw new NotFoundException(); - } - - return trackedDownload; - } - } -} diff --git a/src/Readarr.Api.V1/Queue/QueueModule.cs b/src/Readarr.Api.V1/Queue/QueueController.cs similarity index 56% rename from src/Readarr.Api.V1/Queue/QueueModule.cs rename to src/Readarr.Api.V1/Queue/QueueController.cs index bfefc0151..15cd95dc1 100644 --- a/src/Readarr.Api.V1/Queue/QueueModule.cs +++ b/src/Readarr.Api.V1/Queue/QueueController.cs @@ -1,48 +1,101 @@ using System; +using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download; using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Queue; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Readarr.Http; using Readarr.Http.Extensions; +using Readarr.Http.REST; namespace Readarr.Api.V1.Queue { - public class QueueModule : ReadarrRestModuleWithSignalR, + [V1ApiController] + public class QueueController : RestControllerWithSignalR, IHandle, IHandle { private readonly IQueueService _queueService; private readonly IPendingReleaseService _pendingReleaseService; private readonly QualityModelComparer _qualityComparer; + private readonly ITrackedDownloadService _trackedDownloadService; + private readonly IFailedDownloadService _failedDownloadService; + private readonly IIgnoredDownloadService _ignoredDownloadService; + private readonly IProvideDownloadClient _downloadClientProvider; - public QueueModule(IBroadcastSignalRMessage broadcastSignalRMessage, + public QueueController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService, - QualityProfileService qualityProfileService) + QualityProfileService qualityProfileService, + ITrackedDownloadService trackedDownloadService, + IFailedDownloadService failedDownloadService, + IIgnoredDownloadService ignoredDownloadService, + IProvideDownloadClient downloadClientProvider) : base(broadcastSignalRMessage) { _queueService = queueService; _pendingReleaseService = pendingReleaseService; - GetResourcePaged = GetQueue; + _trackedDownloadService = trackedDownloadService; + _failedDownloadService = failedDownloadService; + _ignoredDownloadService = ignoredDownloadService; + _downloadClientProvider = downloadClientProvider; _qualityComparer = new QualityModelComparer(qualityProfileService.GetDefaultProfile(string.Empty)); } - private PagingResource GetQueue(PagingResource pagingResource) + public override QueueResource GetResourceById(int id) { + throw new NotImplementedException(); + } + + [RestDeleteById] + public void RemoveAction(int id, bool removeFromClient = true, bool blacklist = false, bool skipReDownload = false) + { + var trackedDownload = Remove(id, removeFromClient, blacklist, skipReDownload); + + if (trackedDownload != null) + { + _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); + } + } + + [HttpDelete] + public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] bool removeFromClient = true, [FromQuery] bool blacklist = false, [FromQuery] bool skipReDownload = false) + { + var trackedDownloadIds = new List(); + + foreach (var id in resource.Ids) + { + var trackedDownload = Remove(id, removeFromClient, blacklist, skipReDownload); + + if (trackedDownload != null) + { + trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId); + } + } + + _trackedDownloadService.StopTracking(trackedDownloadIds); + + return new object(); + } + + [HttpGet] + public PagingResource GetQueue(bool includeUnknownAuthorItems = false, bool includeAuthor = false, bool includeBook = false) + { + var pagingResource = Request.ReadPagingResourceFromRequest(); var pagingSpec = pagingResource.MapToPagingSpec("timeleft", SortDirection.Ascending); - var includeUnknownAuthorItems = Request.GetBooleanQueryParameter("includeUnknownAuthorItems"); - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); - var includeBook = Request.GetBooleanQueryParameter("includeBook"); - return ApplyToPage((spec) => GetQueue(spec, includeUnknownAuthorItems), pagingSpec, (q) => MapToResource(q, includeAuthor, includeBook)); + return pagingSpec.ApplyToPage((spec) => GetQueue(spec, includeUnknownAuthorItems), (q) => MapToResource(q, includeAuthor, includeBook)); } private PagingSpec GetQueue(PagingSpec pagingSpec, bool includeUnknownAuthorItems) @@ -138,16 +191,83 @@ namespace Readarr.Api.V1.Queue } } + private TrackedDownload Remove(int id, bool removeFromClient, bool blacklist, bool skipReDownload) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease != null) + { + _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); + + return null; + } + + var trackedDownload = GetTrackedDownload(id); + + if (trackedDownload == null) + { + throw new NotFoundException(); + } + + if (removeFromClient) + { + var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); + + if (downloadClient == null) + { + throw new BadRequestException(); + } + + downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadId, true); + } + + if (blacklist) + { + _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipReDownload); + } + + if (!removeFromClient && !blacklist) + { + if (!_ignoredDownloadService.IgnoreDownload(trackedDownload)) + { + return null; + } + } + + return trackedDownload; + } + + private TrackedDownload GetTrackedDownload(int queueId) + { + var queueItem = _queueService.Find(queueId); + + if (queueItem == null) + { + throw new NotFoundException(); + } + + var trackedDownload = _trackedDownloadService.Find(queueItem.DownloadId); + + if (trackedDownload == null) + { + throw new NotFoundException(); + } + + return trackedDownload; + } + private QueueResource MapToResource(NzbDrone.Core.Queue.Queue queueItem, bool includeAuthor, bool includeBook) { return queueItem.ToResource(includeAuthor, includeBook); } + [NonAction] public void Handle(QueueUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); } + [NonAction] public void Handle(PendingReleasesUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Readarr.Api.V1/Queue/QueueDetailsModule.cs b/src/Readarr.Api.V1/Queue/QueueDetailsController.cs similarity index 52% rename from src/Readarr.Api.V1/Queue/QueueDetailsModule.cs rename to src/Readarr.Api.V1/Queue/QueueDetailsController.cs index 2c41d4c20..22789b29d 100644 --- a/src/Readarr.Api.V1/Queue/QueueDetailsModule.cs +++ b/src/Readarr.Api.V1/Queue/QueueDetailsController.cs @@ -1,65 +1,63 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Queue; using NzbDrone.SignalR; using Readarr.Http; -using Readarr.Http.Extensions; +using Readarr.Http.REST; namespace Readarr.Api.V1.Queue { - public class QueueDetailsModule : ReadarrRestModuleWithSignalR, + [V1ApiController("queue/details")] + public class QueueDetailsController : RestControllerWithSignalR, IHandle, IHandle { private readonly IQueueService _queueService; private readonly IPendingReleaseService _pendingReleaseService; - public QueueDetailsModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) - : base(broadcastSignalRMessage, "queue/details") + public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + : base(broadcastSignalRMessage) { _queueService = queueService; _pendingReleaseService = pendingReleaseService; - GetResourceAll = GetQueue; } - private List GetQueue() + public override QueueResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + [HttpGet] + public List GetQueue(int? authorId, [FromQuery]List bookIds, bool includeAuthor = false, bool includeBook = true) { - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); - var includeBook = Request.GetBooleanQueryParameter("includeBook", true); var queue = _queueService.GetQueue(); var pending = _pendingReleaseService.GetPendingQueue(); var fullQueue = queue.Concat(pending); - var authorIdQuery = Request.Query.AuthorId; - var bookIdsQuery = Request.Query.BookIds; - - if (authorIdQuery.HasValue) + if (authorId.HasValue) { - return fullQueue.Where(q => q.Author?.Id == (int)authorIdQuery).ToResource(includeAuthor, includeBook); + return fullQueue.Where(q => q.Author?.Id == authorId.Value).ToResource(includeAuthor, includeBook); } - if (bookIdsQuery.HasValue) + if (bookIds.Any()) { - string bookIdsValue = bookIdsQuery.Value.ToString(); - - var bookIds = bookIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(e => Convert.ToInt32(e)) - .ToList(); - return fullQueue.Where(q => q.Book != null && bookIds.Contains(q.Book.Id)).ToResource(includeAuthor, includeBook); } return fullQueue.ToResource(includeAuthor, includeBook); } + [NonAction] public void Handle(QueueUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); } + [NonAction] public void Handle(PendingReleasesUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Readarr.Api.V1/Queue/QueueStatusModule.cs b/src/Readarr.Api.V1/Queue/QueueStatusController.cs similarity index 77% rename from src/Readarr.Api.V1/Queue/QueueStatusModule.cs rename to src/Readarr.Api.V1/Queue/QueueStatusController.cs index 8d6f66591..39d7b6a71 100644 --- a/src/Readarr.Api.V1/Queue/QueueStatusModule.cs +++ b/src/Readarr.Api.V1/Queue/QueueStatusController.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.TPL; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Download.Pending; @@ -8,33 +9,34 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Queue; using NzbDrone.SignalR; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Queue { - public class QueueStatusModule : ReadarrRestModuleWithSignalR, + [V1ApiController("queue/status")] + public class QueueStatusController : RestControllerWithSignalR, IHandle, IHandle { private readonly IQueueService _queueService; private readonly IPendingReleaseService _pendingReleaseService; private readonly Debouncer _broadcastDebounce; - public QueueStatusModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) - : base(broadcastSignalRMessage, "queue/status") + public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + : base(broadcastSignalRMessage) { _queueService = queueService; _pendingReleaseService = pendingReleaseService; _broadcastDebounce = new Debouncer(BroadcastChange, TimeSpan.FromSeconds(5)); - - Get("/", x => GetQueueStatusResponse()); } - private object GetQueueStatusResponse() + public override QueueStatusResource GetResourceById(int id) { - return GetQueueStatus(); + throw new NotImplementedException(); } - private QueueStatusResource GetQueueStatus() + [HttpGet] + public QueueStatusResource GetQueueStatus() { _broadcastDebounce.Pause(); @@ -62,11 +64,13 @@ namespace Readarr.Api.V1.Queue BroadcastResourceChange(ModelAction.Updated, GetQueueStatus()); } + [NonAction] public void Handle(QueueUpdatedEvent message) { _broadcastDebounce.Execute(); } + [NonAction] public void Handle(PendingReleasesUpdatedEvent message) { _broadcastDebounce.Execute(); diff --git a/src/Readarr.Api.V1/Readarr.Api.V1.csproj b/src/Readarr.Api.V1/Readarr.Api.V1.csproj index c262c1695..0430517d0 100644 --- a/src/Readarr.Api.V1/Readarr.Api.V1.csproj +++ b/src/Readarr.Api.V1/Readarr.Api.V1.csproj @@ -9,11 +9,9 @@ + - - - diff --git a/src/Readarr.Api.V1/ReadarrV1FeedModule.cs b/src/Readarr.Api.V1/ReadarrV1FeedModule.cs deleted file mode 100644 index 8c8188123..000000000 --- a/src/Readarr.Api.V1/ReadarrV1FeedModule.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Readarr.Http; - -namespace Readarr.Api.V1 -{ - public abstract class ReadarrV1FeedModule : ReadarrModule - { - protected ReadarrV1FeedModule(string resource) - : base("/feed/v1/" + resource.Trim('/')) - { - } - } -} diff --git a/src/Readarr.Api.V1/ReadarrV1Module.cs b/src/Readarr.Api.V1/ReadarrV1Module.cs deleted file mode 100644 index ba88c81ec..000000000 --- a/src/Readarr.Api.V1/ReadarrV1Module.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Readarr.Http; - -namespace Readarr.Api.V1 -{ - public abstract class ReadarrV1Module : ReadarrModule - { - protected ReadarrV1Module(string resource) - : base("/api/v1/" + resource.Trim('/')) - { - } - } -} diff --git a/src/Readarr.Api.V1/RemotePathMappings/RemotePathMappingModule.cs b/src/Readarr.Api.V1/RemotePathMappings/RemotePathMappingController.cs similarity index 60% rename from src/Readarr.Api.V1/RemotePathMappings/RemotePathMappingModule.cs rename to src/Readarr.Api.V1/RemotePathMappings/RemotePathMappingController.cs index fbbbf657e..58aa7f06d 100644 --- a/src/Readarr.Api.V1/RemotePathMappings/RemotePathMappingModule.cs +++ b/src/Readarr.Api.V1/RemotePathMappings/RemotePathMappingController.cs @@ -1,27 +1,25 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.Validation.Paths; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.RemotePathMappings { - public class RemotePathMappingModule : ReadarrRestModule + [V1ApiController] + public class RemotePathMappingController : RestController { private readonly IRemotePathMappingService _remotePathMappingService; - public RemotePathMappingModule(IRemotePathMappingService remotePathMappingService, + public RemotePathMappingController(IRemotePathMappingService remotePathMappingService, PathExistsValidator pathExistsValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator) { _remotePathMappingService = remotePathMappingService; - GetResourceAll = GetMappings; - GetResourceById = GetMappingById; - CreateResource = CreateMapping; - DeleteResource = DeleteMapping; - UpdateResource = UpdateMapping; - SharedValidator.RuleFor(c => c.Host) .NotEmpty(); @@ -36,33 +34,37 @@ namespace Readarr.Api.V1.RemotePathMappings .SetValidator(pathExistsValidator); } - private RemotePathMappingResource GetMappingById(int id) + public override RemotePathMappingResource GetResourceById(int id) { return _remotePathMappingService.Get(id).ToResource(); } - private int CreateMapping(RemotePathMappingResource resource) + [RestPostById] + public ActionResult CreateMapping(RemotePathMappingResource resource) { var model = resource.ToModel(); - return _remotePathMappingService.Add(model).Id; + return Created(_remotePathMappingService.Add(model).Id); } - private List GetMappings() + [HttpGet] + public List GetMappings() { return _remotePathMappingService.All().ToResource(); } - private void DeleteMapping(int id) + [RestDeleteById] + public void DeleteMapping(int id) { _remotePathMappingService.Remove(id); } - private void UpdateMapping(RemotePathMappingResource resource) + [RestPutById] + public ActionResult UpdateMapping(RemotePathMappingResource resource) { var mapping = resource.ToModel(); - _remotePathMappingService.Update(mapping); + return Accepted(_remotePathMappingService.Update(mapping)); } } } diff --git a/src/Readarr.Api.V1/RootFolders/RootFolderModule.cs b/src/Readarr.Api.V1/RootFolders/RootFolderController.cs similarity index 86% rename from src/Readarr.Api.V1/RootFolders/RootFolderModule.cs rename to src/Readarr.Api.V1/RootFolders/RootFolderController.cs index 686342a2b..f81d73e06 100644 --- a/src/Readarr.Api.V1/RootFolders/RootFolderModule.cs +++ b/src/Readarr.Api.V1/RootFolders/RootFolderController.cs @@ -2,24 +2,27 @@ using System; using System.Collections.Generic; using System.Linq; using FluentValidation; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Books.Calibre; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Readarr.Http; using Readarr.Http.REST; namespace Readarr.Api.V1.RootFolders { - public class RootFolderModule : ReadarrRestModuleWithSignalR + [V1ApiController] + public class RootFolderController : RestControllerWithSignalR { private readonly IRootFolderService _rootFolderService; private readonly ICalibreProxy _calibreProxy; - public RootFolderModule(IRootFolderService rootFolderService, + public RootFolderController(IRootFolderService rootFolderService, ICalibreProxy calibreProxy, IBroadcastSignalRMessage signalRBroadcaster, RootFolderValidator rootFolderValidator, @@ -35,12 +38,6 @@ namespace Readarr.Api.V1.RootFolders _rootFolderService = rootFolderService; _calibreProxy = calibreProxy; - GetResourceAll = GetRootFolders; - GetResourceById = GetRootFolder; - CreateResource = CreateRootFolder; - UpdateResource = UpdateRootFolder; - DeleteResource = DeleteFolder; - SharedValidator.RuleFor(c => c.Path) .Cascade(CascadeMode.StopOnFirstFailure) .IsValidPath() @@ -95,12 +92,13 @@ namespace Readarr.Api.V1.RootFolders return HttpUri.CombinePath(HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase), settings.Library); } - private RootFolderResource GetRootFolder(int id) + public override RootFolderResource GetResourceById(int id) { return _rootFolderService.Get(id).ToResource(); } - private int CreateRootFolder(RootFolderResource rootFolderResource) + [RestPostById] + public ActionResult CreateRootFolder(RootFolderResource rootFolderResource) { var model = rootFolderResource.ToModel(); @@ -109,10 +107,11 @@ namespace Readarr.Api.V1.RootFolders _calibreProxy.Test(model.CalibreSettings); } - return _rootFolderService.Add(model).Id; + return Created(_rootFolderService.Add(model).Id); } - private void UpdateRootFolder(RootFolderResource rootFolderResource) + [RestPutById] + public ActionResult UpdateRootFolder(RootFolderResource rootFolderResource) { var model = rootFolderResource.ToModel(); @@ -127,14 +126,18 @@ namespace Readarr.Api.V1.RootFolders } _rootFolderService.Update(model); + + return Accepted(model.Id); } - private List GetRootFolders() + [HttpGet] + public List GetRootFolders() { return _rootFolderService.AllWithSpaceStats().ToResource(); } - private void DeleteFolder(int id) + [RestDeleteById] + public void DeleteFolder(int id) { _rootFolderService.Remove(id); } diff --git a/src/Readarr.Api.V1/Search/SearchModule.cs b/src/Readarr.Api.V1/Search/SearchController.cs similarity index 88% rename from src/Readarr.Api.V1/Search/SearchModule.cs rename to src/Readarr.Api.V1/Search/SearchController.cs index 603875d15..a6f03ef73 100644 --- a/src/Readarr.Api.V1/Search/SearchModule.cs +++ b/src/Readarr.Api.V1/Search/SearchController.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using Nancy; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource; using Readarr.Api.V1.Author; @@ -10,20 +10,20 @@ using Readarr.Http; namespace Readarr.Api.V1.Search { - public class SearchModule : ReadarrRestModule + [V1ApiController] + public class SearchController : Controller { private readonly ISearchForNewEntity _searchProxy; - public SearchModule(ISearchForNewEntity searchProxy) - : base("/search") + public SearchController(ISearchForNewEntity searchProxy) { _searchProxy = searchProxy; - Get("/", x => Search()); } - private object Search() + [HttpGet] + public object Search([FromQuery] string term) { - var searchResults = _searchProxy.SearchForNewEntity((string)Request.Query.term); + var searchResults = _searchProxy.SearchForNewEntity(term); return MapToResource(searchResults).ToList(); } diff --git a/src/Readarr.Api.V1/Series/SeriesController.cs b/src/Readarr.Api.V1/Series/SeriesController.cs new file mode 100644 index 000000000..f154b41fc --- /dev/null +++ b/src/Readarr.Api.V1/Series/SeriesController.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Books; +using Readarr.Http; + +namespace Readarr.Api.V1.Series +{ + [V1ApiController] + public class SeriesController : Controller + { + protected readonly ISeriesService _seriesService; + + public SeriesController(ISeriesService seriesService) + { + _seriesService = seriesService; + } + + [HttpGet] + public List GetSeries(int authorId) + { + return _seriesService.GetByAuthorId(authorId).ToResource(); + } + } +} diff --git a/src/Readarr.Api.V1/Series/SeriesModule.cs b/src/Readarr.Api.V1/Series/SeriesModule.cs deleted file mode 100644 index 96bca4321..000000000 --- a/src/Readarr.Api.V1/Series/SeriesModule.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Collections.Generic; -using Nancy; -using NzbDrone.Core.Books; -using Readarr.Http; -using Readarr.Http.REST; - -namespace Readarr.Api.V1.Series -{ - public class SeriesModule : ReadarrRestModule - { - protected readonly ISeriesService _seriesService; - - public SeriesModule(ISeriesService seriesService) - { - _seriesService = seriesService; - - GetResourceAll = GetSeries; - } - - private List GetSeries() - { - var authorIdQuery = Request.Query.AuthorId; - - if (!authorIdQuery.HasValue) - { - throw new BadRequestException("authorId must be provided"); - } - - int authorId = Convert.ToInt32(authorIdQuery.Value); - - return _seriesService.GetByAuthorId(authorId).ToResource(); - } - } -} diff --git a/src/Readarr.Api.V1/System/Backup/BackupModule.cs b/src/Readarr.Api.V1/System/Backup/BackupController.cs similarity index 84% rename from src/Readarr.Api.V1/System/Backup/BackupModule.cs rename to src/Readarr.Api.V1/System/Backup/BackupController.cs index 9ad58c302..70cb6c404 100644 --- a/src/Readarr.Api.V1/System/Backup/BackupModule.cs +++ b/src/Readarr.Api.V1/System/Backup/BackupController.cs @@ -1,17 +1,20 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Crypto; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Backup; +using NzbDrone.Http.REST.Attributes; using Readarr.Http; using Readarr.Http.REST; namespace Readarr.Api.V1.System.Backup { - public class BackupModule : ReadarrRestModule + [V1ApiController("system/backup")] + public class BackupController : Controller { private readonly IBackupService _backupService; private readonly IAppFolderInfo _appFolderInfo; @@ -19,21 +22,16 @@ namespace Readarr.Api.V1.System.Backup private static readonly List ValidExtensions = new List { ".zip", ".db", ".xml" }; - public BackupModule(IBackupService backupService, + public BackupController(IBackupService backupService, IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) - : base("system/backup") { _backupService = backupService; _appFolderInfo = appFolderInfo; _diskProvider = diskProvider; - GetResourceAll = GetBackupFiles; - DeleteResource = DeleteBackup; - - Post(@"/restore/(?[\d]{1,10})", x => Restore((int)x.Id)); - Post("/restore/upload", x => UploadAndRestore()); } + [HttpGet] public List GetBackupFiles() { var backups = _backupService.GetBackups(); @@ -50,7 +48,8 @@ namespace Readarr.Api.V1.System.Backup .ToList(); } - private void DeleteBackup(int id) + [RestDeleteById] + public void DeleteBackup(int id) { var backup = GetBackup(id); var path = GetBackupPath(backup); @@ -63,6 +62,7 @@ namespace Readarr.Api.V1.System.Backup _diskProvider.DeleteFile(path); } + [HttpPost("restore/{id:int}")] public object Restore(int id) { var backup = GetBackup(id); @@ -82,9 +82,10 @@ namespace Readarr.Api.V1.System.Backup }; } + [HttpPost("restore/upload")] public object UploadAndRestore() { - var files = Context.Request.Files.ToList(); + var files = Request.Form.Files; if (files.Empty()) { @@ -92,7 +93,7 @@ namespace Readarr.Api.V1.System.Backup } var file = files.First(); - var extension = Path.GetExtension(file.Name); + var extension = Path.GetExtension(file.FileName); if (!ValidExtensions.Contains(extension)) { @@ -101,7 +102,7 @@ namespace Readarr.Api.V1.System.Backup var path = Path.Combine(_appFolderInfo.TempFolder, $"readarr_backup_restore{extension}"); - _diskProvider.SaveStream(file.Value, path); + _diskProvider.SaveStream(file.OpenReadStream(), path); _backupService.Restore(path); // Cleanup restored file diff --git a/src/Readarr.Api.V1/System/SystemModule.cs b/src/Readarr.Api.V1/System/SystemController.cs similarity index 62% rename from src/Readarr.Api.V1/System/SystemModule.cs rename to src/Readarr.Api.V1/System/SystemController.cs index dab9133f8..e8b3b468e 100644 --- a/src/Readarr.Api.V1/System/SystemModule.cs +++ b/src/Readarr.Api.V1/System/SystemController.cs @@ -1,52 +1,60 @@ +using System.IO; using System.Threading.Tasks; -using Nancy.Routing; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Internal; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Lifecycle; +using Readarr.Http; +using Readarr.Http.Validation; namespace Readarr.Api.V1.System { - public class SystemModule : ReadarrV1Module + [V1ApiController] + public class SystemController : Controller { private readonly IAppFolderInfo _appFolderInfo; private readonly IRuntimeInfo _runtimeInfo; private readonly IPlatformInfo _platformInfo; private readonly IOsInfo _osInfo; - private readonly IRouteCacheProvider _routeCacheProvider; private readonly IConfigFileProvider _configFileProvider; private readonly IMainDatabase _database; private readonly ILifecycleService _lifecycleService; private readonly IDeploymentInfoProvider _deploymentInfoProvider; + private readonly EndpointDataSource _endpointData; + private readonly DfaGraphWriter _graphWriter; + private readonly DuplicateEndpointDetector _detector; - public SystemModule(IAppFolderInfo appFolderInfo, - IRuntimeInfo runtimeInfo, - IPlatformInfo platformInfo, - IOsInfo osInfo, - IRouteCacheProvider routeCacheProvider, - IConfigFileProvider configFileProvider, - IMainDatabase database, - ILifecycleService lifecycleService, - IDeploymentInfoProvider deploymentInfoProvider) - : base("system") + public SystemController(IAppFolderInfo appFolderInfo, + IRuntimeInfo runtimeInfo, + IPlatformInfo platformInfo, + IOsInfo osInfo, + IConfigFileProvider configFileProvider, + IMainDatabase database, + ILifecycleService lifecycleService, + IDeploymentInfoProvider deploymentInfoProvider, + EndpointDataSource endpoints, + DfaGraphWriter graphWriter, + DuplicateEndpointDetector detector) { _appFolderInfo = appFolderInfo; _runtimeInfo = runtimeInfo; _platformInfo = platformInfo; _osInfo = osInfo; - _routeCacheProvider = routeCacheProvider; _configFileProvider = configFileProvider; _database = database; _lifecycleService = lifecycleService; _deploymentInfoProvider = deploymentInfoProvider; - Get("/status", x => GetStatus()); - Get("/routes", x => GetRoutes()); - Post("/shutdown", x => Shutdown()); - Post("/restart", x => Restart()); + _endpointData = endpoints; + _graphWriter = graphWriter; + _detector = detector; } - private object GetStatus() + [HttpGet("status")] + public object GetStatus() { return new { @@ -82,18 +90,32 @@ namespace Readarr.Api.V1.System }; } - private object GetRoutes() + [HttpGet("routes")] + public IActionResult GetRoutes() { - return _routeCacheProvider.GetCache().Values; + using (var sw = new StringWriter()) + { + _graphWriter.Write(_endpointData, sw); + var graph = sw.ToString(); + return Content(graph, "text/plain"); + } + } + + [HttpGet("routes/duplicate")] + public object DuplicateRoutes() + { + return _detector.GetDuplicateEndpoints(_endpointData); } - private object Shutdown() + [HttpPost("shutdown")] + public object Shutdown() { Task.Factory.StartNew(() => _lifecycleService.Shutdown()); return new { ShuttingDown = true }; } - private object Restart() + [HttpPost("restart")] + public object Restart() { Task.Factory.StartNew(() => _lifecycleService.Restart()); return new { Restarting = true }; diff --git a/src/Readarr.Api.V1/System/Tasks/TaskModule.cs b/src/Readarr.Api.V1/System/Tasks/TaskController.cs similarity index 76% rename from src/Readarr.Api.V1/System/Tasks/TaskModule.cs rename to src/Readarr.Api.V1/System/Tasks/TaskController.cs index 86f151568..69b0b02e9 100644 --- a/src/Readarr.Api.V1/System/Tasks/TaskModule.cs +++ b/src/Readarr.Api.V1/System/Tasks/TaskController.cs @@ -1,27 +1,29 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Jobs; using NzbDrone.Core.Messaging.Events; using NzbDrone.SignalR; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.System.Tasks { - public class TaskModule : ReadarrRestModuleWithSignalR, IHandle + [V1ApiController("system/task")] + public class TaskController : RestControllerWithSignalR, IHandle { private readonly ITaskManager _taskManager; - public TaskModule(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage) - : base(broadcastSignalRMessage, "system/task") + public TaskController(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage) + : base(broadcastSignalRMessage) { _taskManager = taskManager; - GetResourceAll = GetAll; - GetResourceById = GetTask; } - private List GetAll() + [HttpGet] + public List GetAll() { return _taskManager.GetAll() .Select(ConvertToResource) @@ -29,7 +31,7 @@ namespace Readarr.Api.V1.System.Tasks .ToList(); } - private TaskResource GetTask(int id) + public override TaskResource GetResourceById(int id) { var task = _taskManager.GetAll() .SingleOrDefault(t => t.Id == id); @@ -58,6 +60,7 @@ namespace Readarr.Api.V1.System.Tasks }; } + [NonAction] public void Handle(CommandExecutedEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Readarr.Api.V1/Tags/TagModule.cs b/src/Readarr.Api.V1/Tags/TagController.cs similarity index 51% rename from src/Readarr.Api.V1/Tags/TagModule.cs rename to src/Readarr.Api.V1/Tags/TagController.cs index bb6e60edf..5a738cb03 100644 --- a/src/Readarr.Api.V1/Tags/TagModule.cs +++ b/src/Readarr.Api.V1/Tags/TagController.cs @@ -1,54 +1,59 @@ +using System; using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Tags; +using NzbDrone.Http.REST.Attributes; using NzbDrone.SignalR; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Tags { - public class TagModule : ReadarrRestModuleWithSignalR, IHandle + [V1ApiController] + public class TagController : RestControllerWithSignalR, IHandle { private readonly ITagService _tagService; - public TagModule(IBroadcastSignalRMessage signalRBroadcaster, + public TagController(IBroadcastSignalRMessage signalRBroadcaster, ITagService tagService) : base(signalRBroadcaster) { _tagService = tagService; - - GetResourceById = GetTag; - GetResourceAll = GetAll; - CreateResource = Create; - UpdateResource = Update; - DeleteResource = DeleteTag; } - private TagResource GetTag(int id) + public override TagResource GetResourceById(int id) { return _tagService.GetTag(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { return _tagService.All().ToResource(); } - private int Create(TagResource resource) + [RestPostById] + public ActionResult Create(TagResource resource) { - return _tagService.Add(resource.ToModel()).Id; + return Created(_tagService.Add(resource.ToModel()).Id); } - private void Update(TagResource resource) + [RestPutById] + public ActionResult Update(TagResource resource) { _tagService.Update(resource.ToModel()); + return Accepted(resource.Id); } - private void DeleteTag(int id) + [RestDeleteById] + public void DeleteTag(int id) { _tagService.Delete(id); } + [NonAction] public void Handle(TagsUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); diff --git a/src/Readarr.Api.V1/Tags/TagDetailsModule.cs b/src/Readarr.Api.V1/Tags/TagDetailsController.cs similarity index 53% rename from src/Readarr.Api.V1/Tags/TagDetailsModule.cs rename to src/Readarr.Api.V1/Tags/TagDetailsController.cs index 60f270ac7..2708186ca 100644 --- a/src/Readarr.Api.V1/Tags/TagDetailsModule.cs +++ b/src/Readarr.Api.V1/Tags/TagDetailsController.cs @@ -1,28 +1,28 @@ using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Tags; using Readarr.Http; +using Readarr.Http.REST; namespace Readarr.Api.V1.Tags { - public class TagDetailsModule : ReadarrRestModule + [V1ApiController("tag/detail")] + public class TagDetailsController : RestController { private readonly ITagService _tagService; - public TagDetailsModule(ITagService tagService) - : base("/tag/detail") + public TagDetailsController(ITagService tagService) { _tagService = tagService; - - GetResourceById = GetTagDetails; - GetResourceAll = GetAll; } - private TagDetailsResource GetTagDetails(int id) + public override TagDetailsResource GetResourceById(int id) { return _tagService.Details(id).ToResource(); } - private List GetAll() + [HttpGet] + public List GetAll() { var tags = _tagService.Details().ToResource(); diff --git a/src/Readarr.Api.V1/Update/UpdateModule.cs b/src/Readarr.Api.V1/Update/UpdateController.cs similarity index 79% rename from src/Readarr.Api.V1/Update/UpdateModule.cs rename to src/Readarr.Api.V1/Update/UpdateController.cs index c8fcb2be0..6463f6cec 100644 --- a/src/Readarr.Api.V1/Update/UpdateModule.cs +++ b/src/Readarr.Api.V1/Update/UpdateController.cs @@ -1,22 +1,24 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Update; using Readarr.Http; namespace Readarr.Api.V1.Update { - public class UpdateModule : ReadarrRestModule + [V1ApiController] + public class UpdateController : Controller { private readonly IRecentUpdateProvider _recentUpdateProvider; - public UpdateModule(IRecentUpdateProvider recentUpdateProvider) + public UpdateController(IRecentUpdateProvider recentUpdateProvider) { _recentUpdateProvider = recentUpdateProvider; - GetResourceAll = GetRecentUpdates; } - private List GetRecentUpdates() + [HttpGet] + public List GetRecentUpdates() { var resources = _recentUpdateProvider.GetRecentUpdatePackages() .OrderByDescending(u => u.Version) diff --git a/src/Readarr.Api.V1/Wanted/CutoffModule.cs b/src/Readarr.Api.V1/Wanted/CutoffController.cs similarity index 76% rename from src/Readarr.Api.V1/Wanted/CutoffModule.cs rename to src/Readarr.Api.V1/Wanted/CutoffController.cs index 30fa7b59e..3d9907537 100644 --- a/src/Readarr.Api.V1/Wanted/CutoffModule.cs +++ b/src/Readarr.Api.V1/Wanted/CutoffController.cs @@ -1,4 +1,5 @@ using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.AuthorStats; using NzbDrone.Core.Books; using NzbDrone.Core.Datastore; @@ -11,25 +12,27 @@ using Readarr.Http.Extensions; namespace Readarr.Api.V1.Wanted { - public class CutoffModule : BookModuleWithSignalR + [V1ApiController("wanted/cutoff")] + public class CutoffController : BookControllerWithSignalR { private readonly IBookCutoffService _bookCutoffService; - public CutoffModule(IBookCutoffService bookCutoffService, + public CutoffController(IBookCutoffService bookCutoffService, IBookService bookService, ISeriesBookLinkService seriesBookLinkService, IAuthorStatisticsService authorStatisticsService, IMapCoversToLocal coverMapper, IUpgradableSpecification upgradableSpecification, IBroadcastSignalRMessage signalRBroadcaster) - : base(bookService, seriesBookLinkService, authorStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster, "wanted/cutoff") + : base(bookService, seriesBookLinkService, authorStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster) { _bookCutoffService = bookCutoffService; - GetResourcePaged = GetCutoffUnmetBooks; } - private PagingResource GetCutoffUnmetBooks(PagingResource pagingResource) + [HttpGet] + public PagingResource GetCutoffUnmetBooks(bool includeAuthor = false) { + var pagingResource = Request.ReadPagingResourceFromRequest(); var pagingSpec = new PagingSpec { Page = pagingResource.Page, @@ -38,7 +41,6 @@ namespace Readarr.Api.V1.Wanted SortDirection = pagingResource.SortDirection }; - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); var filter = pagingResource.Filters.FirstOrDefault(f => f.Key == "monitored"); if (filter != null && filter.Value == "false") @@ -50,9 +52,7 @@ namespace Readarr.Api.V1.Wanted pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Author.Value.Monitored == true); } - var resource = ApplyToPage(_bookCutoffService.BooksWhereCutoffUnmet, pagingSpec, v => MapToResource(v, includeAuthor)); - - return resource; + return pagingSpec.ApplyToPage(_bookCutoffService.BooksWhereCutoffUnmet, v => MapToResource(v, includeAuthor)); } } } diff --git a/src/Readarr.Api.V1/Wanted/MissingModule.cs b/src/Readarr.Api.V1/Wanted/MissingController.cs similarity index 75% rename from src/Readarr.Api.V1/Wanted/MissingModule.cs rename to src/Readarr.Api.V1/Wanted/MissingController.cs index 788a32a78..809d3fef3 100644 --- a/src/Readarr.Api.V1/Wanted/MissingModule.cs +++ b/src/Readarr.Api.V1/Wanted/MissingController.cs @@ -1,4 +1,5 @@ using System.Linq; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.AuthorStats; using NzbDrone.Core.Books; using NzbDrone.Core.Datastore; @@ -11,21 +12,23 @@ using Readarr.Http.Extensions; namespace Readarr.Api.V1.Wanted { - public class MissingModule : BookModuleWithSignalR + [V1ApiController("wanted/missing")] + public class MissingController : BookControllerWithSignalR { - public MissingModule(IBookService bookService, + public MissingController(IBookService bookService, ISeriesBookLinkService seriesBookLinkService, IAuthorStatisticsService authorStatisticsService, IMapCoversToLocal coverMapper, IUpgradableSpecification upgradableSpecification, IBroadcastSignalRMessage signalRBroadcaster) - : base(bookService, seriesBookLinkService, authorStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster, "wanted/missing") + : base(bookService, seriesBookLinkService, authorStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster) { - GetResourcePaged = GetMissingBooks; } - private PagingResource GetMissingBooks(PagingResource pagingResource) + [HttpGet] + public PagingResource GetMissingBooks(bool includeAuthor = false) { + var pagingResource = Request.ReadPagingResourceFromRequest(); var pagingSpec = new PagingSpec { Page = pagingResource.Page, @@ -34,7 +37,6 @@ namespace Readarr.Api.V1.Wanted SortDirection = pagingResource.SortDirection }; - var includeAuthor = Request.GetBooleanQueryParameter("includeAuthor"); var monitoredFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "monitored"); if (monitoredFilter != null && monitoredFilter.Value == "false") @@ -46,9 +48,7 @@ namespace Readarr.Api.V1.Wanted pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Author.Value.Monitored == true); } - var resource = ApplyToPage(_bookService.BooksWithoutFiles, pagingSpec, v => MapToResource(v, includeAuthor)); - - return resource; + return pagingSpec.ApplyToPage(_bookService.BooksWithoutFiles, v => MapToResource(v, includeAuthor)); } } } diff --git a/src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs b/src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs new file mode 100644 index 000000000..abb61eea6 --- /dev/null +++ b/src/Readarr.Http/Authentication/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Readarr.Http.Authentication +{ + public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions + { + public const string DefaultScheme = "API Key"; + public string Scheme => DefaultScheme; + public string AuthenticationType = DefaultScheme; + + public string HeaderName { get; set; } + public string QueryName { get; set; } + public string ApiKey { get; set; } + } + + public class ApiKeyAuthenticationHandler : AuthenticationHandler + { + public ApiKeyAuthenticationHandler(IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + private string ParseApiKey() + { + // Try query parameter + if (Request.Query.TryGetValue(Options.QueryName, out var value)) + { + return value.FirstOrDefault(); + } + + // No ApiKey query parameter found try headers + if (Request.Headers.TryGetValue(Options.HeaderName, out var headerValue)) + { + return headerValue.FirstOrDefault(); + } + + return Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", ""); + } + + protected override Task HandleAuthenticateAsync() + { + var providedApiKey = ParseApiKey(); + + if (string.IsNullOrWhiteSpace(providedApiKey)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (Options.ApiKey == providedApiKey) + { + var claims = new List + { + new Claim("ApiKey", "true") + }; + + var identity = new ClaimsIdentity(claims, Options.AuthenticationType); + var identities = new List { identity }; + var principal = new ClaimsPrincipal(identities); + var ticket = new AuthenticationTicket(principal, Options.Scheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + return Task.FromResult(AuthenticateResult.NoResult()); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = 401; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + Response.StatusCode = 403; + return Task.CompletedTask; + } + } +} diff --git a/src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs b/src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs new file mode 100644 index 000000000..6266004e6 --- /dev/null +++ b/src/Readarr.Http/Authentication/AuthenticationBuilderExtensions.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; + +namespace Readarr.Http.Authentication +{ + public static class AuthenticationBuilderExtensions + { + public static AuthenticationBuilder AddApiKey(this AuthenticationBuilder authenticationBuilder, string name, Action options) + { + return authenticationBuilder.AddScheme(name, options); + } + + public static AuthenticationBuilder AddBasicAuthentication(this AuthenticationBuilder authenticationBuilder) + { + return authenticationBuilder.AddScheme(AuthenticationType.Basic.ToString(), options => { }); + } + + public static AuthenticationBuilder AddNoAuthentication(this AuthenticationBuilder authenticationBuilder) + { + return authenticationBuilder.AddScheme(AuthenticationType.None.ToString(), options => { }); + } + + public static AuthenticationBuilder AddAppAuthentication(this IServiceCollection services, IConfigFileProvider config) + { + var authBuilder = services.AddAuthentication(config.AuthenticationMethod.ToString()); + + if (config.AuthenticationMethod == AuthenticationType.Basic) + { + authBuilder.AddBasicAuthentication(); + } + else if (config.AuthenticationMethod == AuthenticationType.Forms) + { + authBuilder.AddCookie(AuthenticationType.Forms.ToString(), options => + { + options.AccessDeniedPath = "/login?loginFailed=true"; + options.LoginPath = "/login"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + }); + } + else + { + authBuilder.AddNoAuthentication(); + } + + authBuilder.AddApiKey("API", options => + { + options.HeaderName = "X-Api-Key"; + options.QueryName = "apikey"; + options.ApiKey = config.ApiKey; + }); + + authBuilder.AddApiKey("SignalR", options => + { + options.HeaderName = "X-Api-Key"; + options.QueryName = "access_token"; + options.ApiKey = config.ApiKey; + }); + + return authBuilder; + } + } +} diff --git a/src/Readarr.Http/Authentication/AuthenticationController.cs b/src/Readarr.Http/Authentication/AuthenticationController.cs new file mode 100644 index 000000000..234567fca --- /dev/null +++ b/src/Readarr.Http/Authentication/AuthenticationController.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Configuration; + +namespace Readarr.Http.Authentication +{ + [AllowAnonymous] + [ApiController] + public class AuthenticationController : Controller + { + private readonly IAuthenticationService _authService; + private readonly IConfigFileProvider _configFileProvider; + + public AuthenticationController(IAuthenticationService authService, IConfigFileProvider configFileProvider) + { + _authService = authService; + _configFileProvider = configFileProvider; + } + + [HttpPost("login")] + public async Task Login([FromForm] LoginResource resource, [FromQuery] string returnUrl = null) + { + var user = _authService.Login(HttpContext.Request, resource.Username, resource.Password); + + if (user == null) + { + return Redirect($"~/login?returnUrl={returnUrl}&loginFailed=true"); + } + + var claims = new List + { + new Claim("user", user.Username), + new Claim("identifier", user.Identifier.ToString()), + new Claim("UiAuth", "true") + }; + + var authProperties = new AuthenticationProperties + { + IsPersistent = resource.RememberMe == "on" + }; + await HttpContext.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties); + + return Redirect("/"); + } + + [HttpGet("logout")] + public async Task Logout() + { + _authService.Logout(HttpContext); + await HttpContext.SignOutAsync(); + return Redirect("/"); + } + } +} diff --git a/src/Readarr.Http/Authentication/AuthenticationModule.cs b/src/Readarr.Http/Authentication/AuthenticationModule.cs deleted file mode 100644 index 3663f07ca..000000000 --- a/src/Readarr.Http/Authentication/AuthenticationModule.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Nancy; -using Nancy.Authentication.Forms; -using Nancy.Extensions; -using Nancy.ModelBinding; -using NzbDrone.Core.Configuration; - -namespace Readarr.Http.Authentication -{ - public class AuthenticationModule : NancyModule - { - private readonly IAuthenticationService _authService; - private readonly IConfigFileProvider _configFileProvider; - - public AuthenticationModule(IAuthenticationService authService, IConfigFileProvider configFileProvider) - { - _authService = authService; - _configFileProvider = configFileProvider; - Post("/login", x => Login(this.Bind())); - Get("/logout", x => Logout()); - } - - private Response Login(LoginResource resource) - { - var user = _authService.Login(Context, resource.Username, resource.Password); - - if (user == null) - { - var returnUrl = (string)Request.Query.returnUrl; - return Context.GetRedirect($"~/login?returnUrl={returnUrl}&loginFailed=true"); - } - - DateTime? expiry = null; - - if (resource.RememberMe) - { - expiry = DateTime.UtcNow.AddDays(7); - } - - return this.LoginAndRedirect(user.Identifier, expiry, _configFileProvider.UrlBase + "/"); - } - - private Response Logout() - { - _authService.Logout(Context); - - return this.LogoutAndRedirect(_configFileProvider.UrlBase + "/"); - } - } -} diff --git a/src/Readarr.Http/Authentication/AuthenticationService.cs b/src/Readarr.Http/Authentication/AuthenticationService.cs index ea580d3df..ab3aa5b2d 100644 --- a/src/Readarr.Http/Authentication/AuthenticationService.cs +++ b/src/Readarr.Http/Authentication/AuthenticationService.cs @@ -1,26 +1,16 @@ -using System; -using System.Linq; -using System.Security.Claims; -using System.Security.Principal; -using Nancy; -using Nancy.Authentication.Basic; -using Nancy.Authentication.Forms; +using Microsoft.AspNetCore.Http; using NLog; -using NzbDrone.Common.Extensions; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using Readarr.Http.Extensions; namespace Readarr.Http.Authentication { - public interface IAuthenticationService : IUserValidator, IUserMapper + public interface IAuthenticationService { - void SetContext(NancyContext context); - - void LogUnauthorized(NancyContext context); - User Login(NancyContext context, string username, string password); - void Logout(NancyContext context); - bool IsAuthenticated(NancyContext context); + void LogUnauthorized(HttpRequest context); + User Login(HttpRequest request, string username, string password); + void Logout(HttpContext context); } public class AuthenticationService : IAuthenticationService @@ -32,9 +22,6 @@ namespace Readarr.Http.Authentication private static string API_KEY; private static AuthenticationType AUTH_METHOD; - [ThreadStatic] - private static NancyContext _context; - public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService) { _userService = userService; @@ -42,13 +29,7 @@ namespace Readarr.Http.Authentication AUTH_METHOD = configFileProvider.AuthenticationMethod; } - public void SetContext(NancyContext context) - { - // Validate and GetUserIdentifier don't have access to the NancyContext so get it from the pipeline earlier - _context = context; - } - - public User Login(NancyContext context, string username, string password) + public User Login(HttpRequest request, string username, string password) { if (AUTH_METHOD == AuthenticationType.None) { @@ -59,174 +40,50 @@ namespace Readarr.Http.Authentication if (user != null) { - LogSuccess(context, username); + LogSuccess(request, username); return user; } - LogFailure(context, username); + LogFailure(request, username); return null; } - public void Logout(NancyContext context) + public void Logout(HttpContext context) { if (AUTH_METHOD == AuthenticationType.None) { return; } - if (context.CurrentUser != null) - { - LogLogout(context, context.CurrentUser.Identity.Name); - } - } - - public ClaimsPrincipal Validate(string username, string password) - { - if (AUTH_METHOD == AuthenticationType.None) - { - return new ClaimsPrincipal(new GenericIdentity(AnonymousUser)); - } - - var user = _userService.FindUser(username, password); - - if (user != null) - { - if (AUTH_METHOD != AuthenticationType.Basic) - { - // Don't log success for basic auth - LogSuccess(_context, username); - } - - return new ClaimsPrincipal(new GenericIdentity(user.Username)); - } - - LogFailure(_context, username); - - return null; - } - - public ClaimsPrincipal GetUserFromIdentifier(Guid identifier, NancyContext context) - { - if (AUTH_METHOD == AuthenticationType.None) - { - return new ClaimsPrincipal(new GenericIdentity(AnonymousUser)); - } - - var user = _userService.FindUser(identifier); - - if (user != null) - { - return new ClaimsPrincipal(new GenericIdentity(user.Username)); - } - - LogInvalidated(_context); - - return null; - } - - public bool IsAuthenticated(NancyContext context) - { - var apiKey = GetApiKey(context); - - if (context.Request.IsApiRequest()) + if (context.User != null) { - return ValidApiKey(apiKey); + LogLogout(context.Request, context.User.Identity.Name); } - - if (AUTH_METHOD == AuthenticationType.None) - { - return true; - } - - if (context.Request.IsFeedRequest()) - { - if (ValidUser(context) || ValidApiKey(apiKey)) - { - return true; - } - - return false; - } - - if (context.Request.IsLoginRequest()) - { - return true; - } - - if (context.Request.IsContentRequest()) - { - return true; - } - - if (ValidUser(context)) - { - return true; - } - - return false; - } - - private bool ValidUser(NancyContext context) - { - if (context.CurrentUser != null) - { - return true; - } - - return false; - } - - private bool ValidApiKey(string apiKey) - { - if (API_KEY.Equals(apiKey)) - { - return true; - } - - return false; - } - - private string GetApiKey(NancyContext context) - { - var apiKeyHeader = context.Request.Headers["X-Api-Key"].FirstOrDefault(); - var apiKeyQueryString = context.Request.Query["ApiKey"]; - - if (!apiKeyHeader.IsNullOrWhiteSpace()) - { - return apiKeyHeader; - } - - if (apiKeyQueryString.HasValue) - { - return apiKeyQueryString.Value; - } - - return context.Request.Headers.Authorization; } - public void LogUnauthorized(NancyContext context) + public void LogUnauthorized(HttpRequest context) { - _authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Request.Url.ToString()); + _authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.GetRemoteIP(), context.Path); } - private void LogInvalidated(NancyContext context) + private void LogInvalidated(HttpRequest context) { _authLogger.Info("Auth-Invalidated ip {0}", context.GetRemoteIP()); } - private void LogFailure(NancyContext context, string username) + private void LogFailure(HttpRequest context, string username) { _authLogger.Warn("Auth-Failure ip {0} username '{1}'", context.GetRemoteIP(), username); } - private void LogSuccess(NancyContext context, string username) + private void LogSuccess(HttpRequest context, string username) { _authLogger.Info("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username); } - private void LogLogout(NancyContext context, string username) + private void LogLogout(HttpRequest context, string username) { _authLogger.Info("Auth-Logout ip {0} username '{1}'", context.GetRemoteIP(), username); } diff --git a/src/Readarr.Http/Authentication/BasicAuthenticationHandler.cs b/src/Readarr.Http/Authentication/BasicAuthenticationHandler.cs new file mode 100644 index 000000000..b04e91fd4 --- /dev/null +++ b/src/Readarr.Http/Authentication/BasicAuthenticationHandler.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NzbDrone.Common.EnvironmentInfo; + +namespace Readarr.Http.Authentication +{ + public class BasicAuthenticationHandler : AuthenticationHandler + { + private readonly IAuthenticationService _authService; + + public BasicAuthenticationHandler(IAuthenticationService authService, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + _authService = authService; + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.ContainsKey("Authorization")) + { + return Task.FromResult(AuthenticateResult.Fail("Authorization header missing.")); + } + + // Get authorization key + var authorizationHeader = Request.Headers["Authorization"].ToString(); + var authHeaderRegex = new Regex(@"Basic (.*)"); + + if (!authHeaderRegex.IsMatch(authorizationHeader)) + { + return Task.FromResult(AuthenticateResult.Fail("Authorization code not formatted properly.")); + } + + var authBase64 = Encoding.UTF8.GetString(Convert.FromBase64String(authHeaderRegex.Replace(authorizationHeader, "$1"))); + var authSplit = authBase64.Split(':', 2); + var authUsername = authSplit[0]; + var authPassword = authSplit.Length > 1 ? authSplit[1] : throw new Exception("Unable to get password"); + + var user = _authService.Login(Request, authUsername, authPassword); + + if (user == null) + { + return Task.FromResult(AuthenticateResult.Fail("The username or password is not correct.")); + } + + var claims = new List + { + new Claim("user", user.Username), + new Claim("identifier", user.Identifier.ToString()), + new Claim("UiAuth", "true") + }; + + var identity = new ClaimsIdentity(claims, "Basic", "user", "identifier"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Basic"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.Headers.Add("WWW-Authenticate", $"Basic realm=\"{BuildInfo.AppName}\""); + Response.StatusCode = 401; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + Response.StatusCode = 403; + return Task.CompletedTask; + } + } +} diff --git a/src/Readarr.Http/Authentication/EnableAuthInNancy.cs b/src/Readarr.Http/Authentication/EnableAuthInNancy.cs deleted file mode 100644 index 39068cb1c..000000000 --- a/src/Readarr.Http/Authentication/EnableAuthInNancy.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Text; -using Nancy; -using Nancy.Authentication.Basic; -using Nancy.Authentication.Forms; -using Nancy.Bootstrapper; -using Nancy.Cookies; -using Nancy.Cryptography; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Authentication; -using NzbDrone.Core.Configuration; -using Readarr.Http.Extensions; -using Readarr.Http.Extensions.Pipelines; - -namespace Readarr.Http.Authentication -{ - public class EnableAuthInNancy : IRegisterNancyPipeline - { - private readonly IAuthenticationService _authenticationService; - private readonly IConfigService _configService; - private readonly IConfigFileProvider _configFileProvider; - private FormsAuthenticationConfiguration _formsAuthConfig; - - public EnableAuthInNancy(IAuthenticationService authenticationService, - IConfigService configService, - IConfigFileProvider configFileProvider) - { - _authenticationService = authenticationService; - _configService = configService; - _configFileProvider = configFileProvider; - } - - public int Order => 10; - - public void Register(IPipelines pipelines) - { - if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms) - { - RegisterFormsAuth(pipelines); - pipelines.AfterRequest.AddItemToEndOfPipeline(SlidingAuthenticationForFormsAuth); - } - else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic) - { - pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, BuildInfo.AppName)); - pipelines.BeforeRequest.AddItemToStartOfPipeline(CaptureContext); - } - - pipelines.BeforeRequest.AddItemToEndOfPipeline(RequiresAuthentication); - pipelines.AfterRequest.AddItemToEndOfPipeline(RemoveLoginHooksForApiCalls); - } - - private Response CaptureContext(NancyContext context) - { - _authenticationService.SetContext(context); - - return null; - } - - private Response RequiresAuthentication(NancyContext context) - { - Response response = null; - - if (!_authenticationService.IsAuthenticated(context)) - { - _authenticationService.LogUnauthorized(context); - response = new Response { StatusCode = HttpStatusCode.Unauthorized }; - } - - return response; - } - - private void RegisterFormsAuth(IPipelines pipelines) - { - FormsAuthentication.FormsAuthenticationCookieName = "ReadarrAuth"; - - var cryptographyConfiguration = new CryptographyConfiguration( - new AesEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase, Encoding.ASCII.GetBytes(_configService.RijndaelSalt))), - new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt)))); - - _formsAuthConfig = new FormsAuthenticationConfiguration - { - RedirectUrl = _configFileProvider.UrlBase + "/login", - UserMapper = _authenticationService, - Path = GetCookiePath(), - CryptographyConfiguration = cryptographyConfiguration - }; - - FormsAuthentication.Enable(pipelines, _formsAuthConfig); - } - - private void RemoveLoginHooksForApiCalls(NancyContext context) - { - if (context.Request.IsApiRequest()) - { - if ((context.Response.StatusCode == HttpStatusCode.SeeOther && - context.Response.Headers["Location"].StartsWith($"{_configFileProvider.UrlBase}/login", StringComparison.InvariantCultureIgnoreCase)) || - context.Response.StatusCode == HttpStatusCode.Unauthorized) - { - context.Response = new { Error = "Unauthorized" }.AsResponse(context, HttpStatusCode.Unauthorized); - } - } - } - - private void SlidingAuthenticationForFormsAuth(NancyContext context) - { - if (context.CurrentUser == null) - { - return; - } - - var formsAuthCookieName = FormsAuthentication.FormsAuthenticationCookieName; - - if (!context.Request.Path.Equals("/logout") && - context.Request.Cookies.ContainsKey(formsAuthCookieName)) - { - var formsAuthCookieValue = context.Request.Cookies[formsAuthCookieName]; - - if (FormsAuthentication.DecryptAndValidateAuthenticationCookie(formsAuthCookieValue, _formsAuthConfig).IsNotNullOrWhiteSpace()) - { - var formsAuthCookie = new NancyCookie(formsAuthCookieName, formsAuthCookieValue, true, false, DateTime.UtcNow.AddDays(7)) - { - Path = GetCookiePath() - }; - - context.Response.WithCookie(formsAuthCookie); - } - } - } - - private string GetCookiePath() - { - var urlBase = _configFileProvider.UrlBase; - - if (urlBase.IsNullOrWhiteSpace()) - { - return "/"; - } - - return urlBase; - } - } -} diff --git a/src/Readarr.Http/Authentication/LoginResource.cs b/src/Readarr.Http/Authentication/LoginResource.cs index d78fab679..d995581ae 100644 --- a/src/Readarr.Http/Authentication/LoginResource.cs +++ b/src/Readarr.Http/Authentication/LoginResource.cs @@ -1,9 +1,9 @@ -namespace Readarr.Http.Authentication +namespace Readarr.Http.Authentication { public class LoginResource { public string Username { get; set; } public string Password { get; set; } - public bool RememberMe { get; set; } + public string RememberMe { get; set; } } } diff --git a/src/Readarr.Http/Authentication/NoAuthenticationHandler.cs b/src/Readarr.Http/Authentication/NoAuthenticationHandler.cs new file mode 100644 index 000000000..7f342ed77 --- /dev/null +++ b/src/Readarr.Http/Authentication/NoAuthenticationHandler.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Readarr.Http.Authentication +{ + public class NoAuthenticationHandler : AuthenticationHandler + { + public NoAuthenticationHandler(IAuthenticationService authService, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new List + { + new Claim("user", "Anonymous"), + new Claim("UiAuth", "true") + }; + + var identity = new ClaimsIdentity(claims, "NoAuth", "user", "identifier"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "NoAuth"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +} diff --git a/src/Readarr.Http/ErrorManagement/ErrorHandler.cs b/src/Readarr.Http/ErrorManagement/ErrorHandler.cs deleted file mode 100644 index 8748c2711..000000000 --- a/src/Readarr.Http/ErrorManagement/ErrorHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Nancy; -using Nancy.ErrorHandling; -using Readarr.Http.Extensions; - -namespace Readarr.Http.ErrorManagement -{ - public class ErrorHandler : IStatusCodeHandler - { - public bool HandlesStatusCode(HttpStatusCode statusCode, NancyContext context) - { - return true; - } - - public void Handle(HttpStatusCode statusCode, NancyContext context) - { - if (statusCode == HttpStatusCode.SeeOther || statusCode == HttpStatusCode.OK) - { - return; - } - - if (statusCode == HttpStatusCode.Continue) - { - context.Response = new Response { StatusCode = statusCode }; - return; - } - - if (statusCode == HttpStatusCode.Unauthorized) - { - return; - } - - if (context.Response.ContentType == "text/html" || context.Response.ContentType == "text/plain") - { - context.Response = new ErrorModel - { - Message = statusCode.ToString() - }.AsResponse(context, statusCode); - } - } - } -} diff --git a/src/Readarr.Http/ErrorManagement/ErrorModel.cs b/src/Readarr.Http/ErrorManagement/ErrorModel.cs index 927abcb63..4504a12a4 100644 --- a/src/Readarr.Http/ErrorManagement/ErrorModel.cs +++ b/src/Readarr.Http/ErrorManagement/ErrorModel.cs @@ -1,4 +1,8 @@ -using Readarr.Http.Exceptions; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.Serializer; +using Readarr.Http.Exceptions; namespace Readarr.Http.ErrorManagement { @@ -17,5 +21,12 @@ namespace Readarr.Http.ErrorManagement public ErrorModel() { } + + public Task WriteToResponse(HttpResponse response, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) + { + response.StatusCode = (int)statusCode; + response.ContentType = "application/json"; + return STJson.SerializeAsync(this, response.Body); + } } } diff --git a/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs b/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs index b6d532914..ca2f92ad1 100644 --- a/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs +++ b/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs @@ -1,16 +1,14 @@ -using System; using System.Data.SQLite; -using System.IO; +using System.Net; +using System.Threading.Tasks; using FluentValidation; -using Nancy; -using Nancy.Extensions; -using Nancy.IO; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; using NLog; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Datastore; using NzbDrone.Core.Exceptions; using Readarr.Http.Exceptions; -using Readarr.Http.Extensions; -using HttpStatusCode = Nancy.HttpStatusCode; namespace Readarr.Http.ErrorManagement { @@ -23,65 +21,81 @@ namespace Readarr.Http.ErrorManagement _logger = logger; } - public Response HandleException(NancyContext context, Exception exception) + public async Task HandleException(HttpContext context) { _logger.Trace("Handling Exception"); + + var response = context.Response; + var exceptionHandlerPathFeature = context.Features.Get(); + var exception = exceptionHandlerPathFeature?.Error; + _logger.Warn(exception); + var statusCode = HttpStatusCode.InternalServerError; + var errorModel = new ErrorModel + { + Message = exception.Message, + Description = exception.ToString() + }; + if (exception is ApiException apiException) { _logger.Warn(apiException, "API Error:\n{0}", apiException.Message); - var body = RequestStream.FromStream(context.Request.Body).AsString(); - _logger.Trace("Request body:\n{0}", body); - return apiException.ToErrorResponse(context); - } + /* var body = RequestStream.FromStream(context.Request.Body).AsString(); + _logger.Trace("Request body:\n{0}", body);*/ - if (exception is ValidationException validationException) + errorModel = new ErrorModel(apiException); + statusCode = apiException.StatusCode; + } + else if (exception is ValidationException validationException) { _logger.Warn("Invalid request {0}", validationException.Message); - return validationException.Errors.AsResponse(context, HttpStatusCode.BadRequest); + response.StatusCode = (int)HttpStatusCode.BadRequest; + response.ContentType = "application/json"; + await response.WriteAsync(STJson.ToJson(validationException.Errors)); + return; } - - if (exception is NzbDroneClientException clientException) + else if (exception is NzbDroneClientException clientException) { - return new ErrorModel + errorModel = new ErrorModel { Message = exception.Message, Description = exception.ToString() - }.AsResponse(context, (HttpStatusCode)clientException.StatusCode); + }; + statusCode = clientException.StatusCode; } - - if (exception is ModelNotFoundException notFoundException) + else if (exception is ModelNotFoundException notFoundException) { - return new ErrorModel + errorModel = new ErrorModel { Message = exception.Message, Description = exception.ToString() - }.AsResponse(context, HttpStatusCode.NotFound); + }; + statusCode = HttpStatusCode.NotFound; } - - if (exception is ModelConflictException conflictException) + else if (exception is ModelConflictException conflictException) { _logger.Error(exception, "DB error"); - return new ErrorModel + errorModel = new ErrorModel { Message = exception.Message, Description = exception.ToString() - }.AsResponse(context, HttpStatusCode.Conflict); + }; + statusCode = HttpStatusCode.Conflict; } - - if (exception is SQLiteException sqLiteException) + else if (exception is SQLiteException sqLiteException) { if (context.Request.Method == "PUT" || context.Request.Method == "POST") { if (sqLiteException.Message.Contains("constraint failed")) { - return new ErrorModel + errorModel = new ErrorModel { Message = exception.Message, - }.AsResponse(context, HttpStatusCode.Conflict); + }; + statusCode = HttpStatusCode.Conflict; } } @@ -90,11 +104,7 @@ namespace Readarr.Http.ErrorManagement _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path); - return new ErrorModel - { - Message = exception.Message, - Description = exception.ToString() - }.AsResponse(context, HttpStatusCode.InternalServerError); + await errorModel.WriteToResponse(response, statusCode); } } } diff --git a/src/Readarr.Http/Exceptions/ApiException.cs b/src/Readarr.Http/Exceptions/ApiException.cs index 14e8da980..b0349a3b5 100644 --- a/src/Readarr.Http/Exceptions/ApiException.cs +++ b/src/Readarr.Http/Exceptions/ApiException.cs @@ -1,8 +1,5 @@ using System; -using Nancy; -using Nancy.Responses; -using Readarr.Http.ErrorManagement; -using Readarr.Http.Extensions; +using System.Net; namespace Readarr.Http.Exceptions { @@ -19,11 +16,6 @@ namespace Readarr.Http.Exceptions Content = content; } - public JsonResponse ToErrorResponse(NancyContext context) - { - return new ErrorModel(this).AsResponse(context, StatusCode); - } - private static string GetMessage(HttpStatusCode statusCode, object content) { var result = statusCode.ToString(); diff --git a/src/Readarr.Http/Extensions/NancyJsonSerializer.cs b/src/Readarr.Http/Extensions/NancyJsonSerializer.cs deleted file mode 100644 index af4eb67ed..000000000 --- a/src/Readarr.Http/Extensions/NancyJsonSerializer.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using Nancy; -using Nancy.Responses.Negotiation; -using NzbDrone.Common.Serializer; - -namespace Readarr.Http.Extensions -{ - public class NancyJsonSerializer : ISerializer - { - protected readonly JsonSerializerOptions _serializerSettings; - - public NancyJsonSerializer() - { - _serializerSettings = STJson.GetSerializerSettings(); - } - - public bool CanSerialize(MediaRange contentType) - { - return contentType == "application/json"; - } - - public void Serialize(MediaRange contentType, TModel model, Stream outputStream) - { - STJson.Serialize(model, outputStream, _serializerSettings); - } - - public IEnumerable Extensions { get; private set; } - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs b/src/Readarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs deleted file mode 100644 index 030d877fd..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Nancy; -using Nancy.Bootstrapper; -using Readarr.Http.Frontend; - -namespace Readarr.Http.Extensions.Pipelines -{ - public class CacheHeaderPipeline : IRegisterNancyPipeline - { - private readonly ICacheableSpecification _cacheableSpecification; - - public CacheHeaderPipeline(ICacheableSpecification cacheableSpecification) - { - _cacheableSpecification = cacheableSpecification; - } - - public int Order => 0; - - public void Register(IPipelines pipelines) - { - pipelines.AfterRequest.AddItemToStartOfPipeline(Handle); - } - - private void Handle(NancyContext context) - { - if (context.Request.Method == "OPTIONS") - { - return; - } - - if (_cacheableSpecification.IsCacheable(context)) - { - context.Response.Headers.EnableCache(); - } - else - { - context.Response.Headers.DisableCache(); - } - } - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/CorsPipeline.cs b/src/Readarr.Http/Extensions/Pipelines/CorsPipeline.cs deleted file mode 100644 index d7b5aebd5..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/CorsPipeline.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Linq; -using Nancy; -using Nancy.Bootstrapper; -using NzbDrone.Common.Extensions; - -namespace Readarr.Http.Extensions.Pipelines -{ - public class CorsPipeline : IRegisterNancyPipeline - { - public int Order => 0; - - public void Register(IPipelines pipelines) - { - pipelines.BeforeRequest.AddItemToEndOfPipeline(HandleRequest); - pipelines.AfterRequest.AddItemToEndOfPipeline(HandleResponse); - } - - private Response HandleRequest(NancyContext context) - { - if (context == null || context.Request.Method != "OPTIONS") - { - return null; - } - - var response = new Response() - .WithStatusCode(HttpStatusCode.OK) - .WithContentType(""); - ApplyResponseHeaders(response, context.Request); - return response; - } - - private void HandleResponse(NancyContext context) - { - if (context == null || context.Response.Headers.ContainsKey(AccessControlHeaders.AllowOrigin)) - { - return; - } - - ApplyResponseHeaders(context.Response, context.Request); - } - - private static void ApplyResponseHeaders(Response response, Request request) - { - if (request.IsApiRequest()) - { - // Allow Cross-Origin access to the API since it's protected with the apikey, and nothing else. - ApplyCorsResponseHeaders(response, request, "*", "GET, OPTIONS, PATCH, POST, PUT, DELETE"); - } - else if (request.IsSharedContentRequest()) - { - // Allow Cross-Origin access to specific shared content such as mediacovers and images. - ApplyCorsResponseHeaders(response, request, "*", "GET, OPTIONS"); - } - - // Disallow Cross-Origin access for any other route. - } - - private static void ApplyCorsResponseHeaders(Response response, Request request, string allowOrigin, string allowedMethods) - { - response.Headers.Add(AccessControlHeaders.AllowOrigin, allowOrigin); - - if (request.Method == "OPTIONS") - { - if (response.Headers.ContainsKey("Allow")) - { - allowedMethods = response.Headers["Allow"]; - } - - response.Headers.Add(AccessControlHeaders.AllowMethods, allowedMethods); - - if (request.Headers[AccessControlHeaders.RequestHeaders].Any()) - { - var requestedHeaders = request.Headers[AccessControlHeaders.RequestHeaders].Join(", "); - - response.Headers.Add(AccessControlHeaders.AllowHeaders, requestedHeaders); - } - } - } - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/GZipPipeline.cs b/src/Readarr.Http/Extensions/Pipelines/GZipPipeline.cs deleted file mode 100644 index 2e795dab8..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/GZipPipeline.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.IO; -using System.IO.Compression; -using System.Linq; -using Nancy; -using Nancy.Bootstrapper; -using NLog; -using NzbDrone.Common.EnvironmentInfo; - -namespace Readarr.Http.Extensions.Pipelines -{ - public class GzipCompressionPipeline : IRegisterNancyPipeline - { - private readonly Logger _logger; - - public int Order => 0; - - private readonly Action, Stream> _writeGZipStream; - - public GzipCompressionPipeline(Logger logger) - { - _logger = logger; - - // On Mono GZipStream/DeflateStream leaks memory if an exception is thrown, use an intermediate buffer in that case. - _writeGZipStream = PlatformInfo.IsMono ? WriteGZipStreamMono : (Action, Stream>)WriteGZipStream; - } - - public void Register(IPipelines pipelines) - { - pipelines.AfterRequest.AddItemToEndOfPipeline(CompressResponse); - } - - private void CompressResponse(NancyContext context) - { - var request = context.Request; - var response = context.Response; - - try - { - if ( - response.Contents != Response.NoBody - && !response.ContentType.Contains("image") - && !response.ContentType.Contains("font") - && request.Headers.AcceptEncoding.Any(x => x.Contains("gzip")) - && !AlreadyGzipEncoded(response) - && !ContentLengthIsTooSmall(response)) - { - var contents = response.Contents; - - response.Headers["Content-Encoding"] = "gzip"; - response.Contents = responseStream => _writeGZipStream(contents, responseStream); - } - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to gzip response"); - throw; - } - } - - private static void WriteGZipStreamMono(Action innerContent, Stream targetStream) - { - using (var membuffer = new MemoryStream()) - { - WriteGZipStream(innerContent, membuffer); - membuffer.Position = 0; - membuffer.CopyTo(targetStream); - } - } - - private static void WriteGZipStream(Action innerContent, Stream targetStream) - { - using (var gzip = new GZipStream(targetStream, CompressionMode.Compress, true)) - using (var buffered = new BufferedStream(gzip, 8192)) - { - innerContent.Invoke(buffered); - } - } - - private static bool ContentLengthIsTooSmall(Response response) - { - var contentLength = response.Headers.TryGetValue("Content-Length", out var value) ? value : null; - - if (contentLength != null && long.Parse(contentLength) < 1024) - { - return true; - } - - return false; - } - - private static bool AlreadyGzipEncoded(Response response) - { - var contentEncoding = response.Headers.TryGetValue("Content-Encoding", out var value) ? value : null; - - if (contentEncoding == "gzip") - { - return true; - } - - return false; - } - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs b/src/Readarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs deleted file mode 100644 index f33759807..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Nancy.Bootstrapper; - -namespace Readarr.Http.Extensions.Pipelines -{ - public interface IRegisterNancyPipeline - { - int Order { get; } - - void Register(IPipelines pipelines); - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs b/src/Readarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs deleted file mode 100644 index df8fd39ac..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Nancy; -using Nancy.Bootstrapper; -using Readarr.Http.Frontend; - -namespace Readarr.Http.Extensions.Pipelines -{ - public class IfModifiedPipeline : IRegisterNancyPipeline - { - private readonly ICacheableSpecification _cacheableSpecification; - - public IfModifiedPipeline(ICacheableSpecification cacheableSpecification) - { - _cacheableSpecification = cacheableSpecification; - } - - public int Order => 0; - - public void Register(IPipelines pipelines) - { - pipelines.BeforeRequest.AddItemToStartOfPipeline(Handle); - } - - private Response Handle(NancyContext context) - { - if (_cacheableSpecification.IsCacheable(context) && context.Request.Headers.IfModifiedSince.HasValue) - { - var response = new Response { ContentType = MimeTypes.GetMimeType(context.Request.Path), StatusCode = HttpStatusCode.NotModified }; - response.Headers.EnableCache(); - return response; - } - - return null; - } - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/ReadarrVersionPipeline.cs b/src/Readarr.Http/Extensions/Pipelines/ReadarrVersionPipeline.cs deleted file mode 100644 index 5267cd276..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/ReadarrVersionPipeline.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Nancy; -using Nancy.Bootstrapper; -using NzbDrone.Common.EnvironmentInfo; - -namespace Readarr.Http.Extensions.Pipelines -{ - public class ReadarrVersionPipeline : IRegisterNancyPipeline - { - public int Order => 0; - - public void Register(IPipelines pipelines) - { - pipelines.AfterRequest.AddItemToStartOfPipeline(Handle); - } - - private void Handle(NancyContext context) - { - if (!context.Response.Headers.ContainsKey("X-ApplicationVersion")) - { - context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString()); - } - } - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs b/src/Readarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs deleted file mode 100644 index 2d341d834..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Threading; -using Nancy; -using Nancy.Bootstrapper; -using NLog; -using NzbDrone.Common.Extensions; -using Readarr.Http.ErrorManagement; -using Readarr.Http.Extensions; -using Readarr.Http.Extensions.Pipelines; - -namespace NzbDrone.Api.Extensions.Pipelines -{ - public class RequestLoggingPipeline : IRegisterNancyPipeline - { - private static readonly Logger _loggerHttp = LogManager.GetLogger("Http"); - private static readonly Logger _loggerApi = LogManager.GetLogger("Api"); - - private static int _requestSequenceID; - - private readonly ReadarrErrorPipeline _errorPipeline; - - public RequestLoggingPipeline(ReadarrErrorPipeline errorPipeline) - { - _errorPipeline = errorPipeline; - } - - public int Order => 100; - - public void Register(IPipelines pipelines) - { - pipelines.BeforeRequest.AddItemToStartOfPipeline(LogStart); - pipelines.AfterRequest.AddItemToEndOfPipeline(LogEnd); - pipelines.OnError.AddItemToEndOfPipeline(LogError); - } - - private Response LogStart(NancyContext context) - { - var id = Interlocked.Increment(ref _requestSequenceID); - - context.Items["ApiRequestSequenceID"] = id; - context.Items["ApiRequestStartTime"] = DateTime.UtcNow; - - var reqPath = GetRequestPathAndQuery(context.Request); - - _loggerHttp.Trace("Req: {0} [{1}] {2} (from {3})", id, context.Request.Method, reqPath, GetOrigin(context)); - - return null; - } - - private void LogEnd(NancyContext context) - { - var id = (int)context.Items["ApiRequestSequenceID"]; - var startTime = (DateTime)context.Items["ApiRequestStartTime"]; - - var endTime = DateTime.UtcNow; - var duration = endTime - startTime; - - var reqPath = GetRequestPathAndQuery(context.Request); - - _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); - - if (context.Request.IsApiRequest()) - { - _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); - } - } - - private Response LogError(NancyContext context, Exception exception) - { - var response = _errorPipeline.HandleException(context, exception); - - context.Response = response; - - LogEnd(context); - - context.Response = null; - - return response; - } - - private static string GetRequestPathAndQuery(Request request) - { - if (request.Url.Query.IsNotNullOrWhiteSpace()) - { - return string.Concat(request.Url.Path, request.Url.Query); - } - else - { - return request.Url.Path; - } - } - - private static string GetOrigin(NancyContext context) - { - if (context.Request.Headers.UserAgent.IsNullOrWhiteSpace()) - { - return context.GetRemoteIP(); - } - else - { - return $"{context.GetRemoteIP()} {context.Request.Headers.UserAgent}"; - } - } - } -} diff --git a/src/Readarr.Http/Extensions/Pipelines/UrlBasePipeline.cs b/src/Readarr.Http/Extensions/Pipelines/UrlBasePipeline.cs deleted file mode 100644 index b91d497f0..000000000 --- a/src/Readarr.Http/Extensions/Pipelines/UrlBasePipeline.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Nancy; -using Nancy.Bootstrapper; -using Nancy.Responses; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; - -namespace Readarr.Http.Extensions.Pipelines -{ - public class UrlBasePipeline : IRegisterNancyPipeline - { - private readonly string _urlBase; - - public UrlBasePipeline(IConfigFileProvider configFileProvider) - { - _urlBase = configFileProvider.UrlBase; - } - - public int Order => 99; - - public void Register(IPipelines pipelines) - { - if (_urlBase.IsNotNullOrWhiteSpace()) - { - pipelines.BeforeRequest.AddItemToStartOfPipeline(Handle); - } - } - - private Response Handle(NancyContext context) - { - var basePath = context.Request.Url.BasePath; - - if (basePath.IsNullOrWhiteSpace()) - { - return new RedirectResponse($"{_urlBase}{context.Request.Path}{context.Request.Url.Query}"); - } - - if (_urlBase != basePath) - { - return new NotFoundResponse(); - } - - return null; - } - } -} diff --git a/src/Readarr.Http/Extensions/ReqResExtensions.cs b/src/Readarr.Http/Extensions/ReqResExtensions.cs deleted file mode 100644 index f0c356ea2..000000000 --- a/src/Readarr.Http/Extensions/ReqResExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Nancy; -using Nancy.Responses; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Serializer; - -namespace Readarr.Http.Extensions -{ - public static class ReqResExtensions - { - private static readonly NancyJsonSerializer NancySerializer = new NancyJsonSerializer(); - - public static readonly string LastModified = BuildInfo.BuildDateTime.ToString("r"); - - public static T FromJson(this Stream body) - where T : class, new() - { - return FromJson(body, typeof(T)); - } - - public static T FromJson(this Stream body, Type type) - { - return (T)FromJson(body, type); - } - - public static object FromJson(this Stream body, Type type) - { - body.Position = 0; - return STJson.Deserialize(body, type); - } - - public static JsonResponse AsResponse(this TModel model, NancyContext context, HttpStatusCode statusCode = HttpStatusCode.OK) - { - var response = new JsonResponse(model, NancySerializer, context.Environment) { StatusCode = statusCode }; - response.Headers.DisableCache(); - - return response; - } - - public static IDictionary DisableCache(this IDictionary headers) - { - headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"; - headers["Pragma"] = "no-cache"; - headers["Expires"] = "0"; - - return headers; - } - - public static IDictionary EnableCache(this IDictionary headers) - { - headers["Cache-Control"] = "max-age=31536000 , public"; - headers["Expires"] = "Sat, 29 Jun 2020 00:00:00 GMT"; - headers["Last-Modified"] = LastModified; - headers["Age"] = "193266"; - - return headers; - } - } -} diff --git a/src/Readarr.Http/Extensions/RequestExtensions.cs b/src/Readarr.Http/Extensions/RequestExtensions.cs index 8b2cc5a95..a6aa18379 100644 --- a/src/Readarr.Http/Extensions/RequestExtensions.cs +++ b/src/Readarr.Http/Extensions/RequestExtensions.cs @@ -1,119 +1,173 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; -using Nancy; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Exceptions; namespace Readarr.Http.Extensions { public static class RequestExtensions { - public static bool IsApiRequest(this Request request) + // See src/Readarr.Api.V1/Queue/QueueModule.cs + private static readonly HashSet VALID_SORT_KEYS = new HashSet(StringComparer.OrdinalIgnoreCase) { - return request.Path.StartsWith("/api/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsFeedRequest(this Request request) - { - return request.Path.StartsWith("/feed/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsSignalRRequest(this Request request) - { - return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsLocalRequest(this Request request) + "authors.sortname", //Workaround authors table properties not being added on isValidSortKey call + "timeleft", + "estimatedCompletionTime", + "protocol", + "indexer", + "downloadClient", + "quality", + "status", + "title", + "progress" + }; + + private static readonly HashSet EXCLUDED_KEYS = new HashSet(StringComparer.InvariantCultureIgnoreCase) { - return request.UserHostAddress.Equals("localhost") || - request.UserHostAddress.Equals("127.0.0.1") || - request.UserHostAddress.Equals("::1"); - } - - public static bool IsLoginRequest(this Request request) + "page", + "pageSize", + "sortKey", + "sortDirection", + "filterKey", + "filterValue", + }; + + public static bool IsApiRequest(this HttpRequest request) { - return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase); + return request.Path.StartsWithSegments("/api", StringComparison.InvariantCultureIgnoreCase); } - public static bool IsContentRequest(this Request request) - { - return request.Path.StartsWith("/Content/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool GetBooleanQueryParameter(this Request request, string parameter, bool defaultValue = false) + public static bool GetBooleanQueryParameter(this HttpRequest request, string parameter, bool defaultValue = false) { var parameterValue = request.Query[parameter]; - if (parameterValue.HasValue) + if (parameterValue.Any()) { - return bool.Parse(parameterValue.Value); + return bool.Parse(parameterValue.ToString()); } return defaultValue; } - public static bool IsSharedContentRequest(this Request request) + public static PagingResource ReadPagingResourceFromRequest(this HttpRequest request) { - return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) || - request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase); - } + if (!int.TryParse(request.Query["PageSize"].ToString(), out var pageSize)) + { + pageSize = 10; + } - public static int GetIntegerQueryParameter(this Request request, string parameter, int defaultValue = 0) - { - var parameterValue = request.Query[parameter]; + if (!int.TryParse(request.Query["Page"].ToString(), out var page)) + { + page = 1; + } + + var pagingResource = new PagingResource + { + PageSize = pageSize, + Page = page, + Filters = new List() + }; - if (parameterValue.HasValue) + if (request.Query["SortKey"].Any()) { - return int.Parse(parameterValue.Value); + var sortKey = request.Query["SortKey"].ToString(); + + if (!VALID_SORT_KEYS.Contains(sortKey) && + !TableMapping.Mapper.IsValidSortKey(sortKey)) + { + throw new BadRequestException($"Invalid sort key {sortKey}"); + } + + pagingResource.SortKey = sortKey; + + if (request.Query["SortDirection"].Any()) + { + pagingResource.SortDirection = request.Query["SortDirection"].ToString() + .Equals("ascending", StringComparison.InvariantCultureIgnoreCase) + ? SortDirection.Ascending + : SortDirection.Descending; + } } - return defaultValue; - } + // For backwards compatibility with v2 + if (request.Query["FilterKey"].Any()) + { + var filter = new PagingResourceFilter + { + Key = request.Query["FilterKey"].ToString() + }; - public static Guid GetGuidQueryParameter(this Request request, string parameter, Guid defaultValue = default) - { - var parameterValue = request.Query[parameter]; + if (request.Query["FilterValue"].Any()) + { + filter.Value = request.Query["FilterValue"].ToString(); + } + + pagingResource.Filters.Add(filter); + } - if (parameterValue.HasValue) + // v3 uses filters in key=value format + foreach (var pair in request.Query) { - return Guid.Parse(parameterValue.Value); + if (EXCLUDED_KEYS.Contains(pair.Key)) + { + continue; + } + + pagingResource.Filters.Add(new PagingResourceFilter + { + Key = pair.Key, + Value = pair.Value.ToString() + }); } - return defaultValue; + return pagingResource; } - public static int? GetNullableIntegerQueryParameter(this Request request, string parameter, int? defaultValue = null) + public static PagingResource ApplyToPage(this PagingSpec pagingSpec, Func, PagingSpec> function, Converter mapper) { - var parameterValue = request.Query[parameter]; + pagingSpec = function(pagingSpec); - if (parameterValue.HasValue) + return new PagingResource { - return int.Parse(parameterValue.Value); - } + Page = pagingSpec.Page, + PageSize = pagingSpec.PageSize, + SortDirection = pagingSpec.SortDirection, + SortKey = pagingSpec.SortKey, + TotalRecords = pagingSpec.TotalRecords, + Records = pagingSpec.Records.ConvertAll(mapper) + }; + } - return defaultValue; + public static string GetRemoteIP(this HttpContext context) + { + return context?.Request?.GetRemoteIP() ?? "Unknown"; } - public static string GetRemoteIP(this NancyContext context) + public static string GetRemoteIP(this HttpRequest request) { - if (context == null || context.Request == null) + if (request == null) { return "Unknown"; } - var remoteAddress = context.Request.UserHostAddress; - IPAddress remoteIP; + var remoteIP = request.HttpContext.Connection.RemoteIpAddress; + var remoteAddress = remoteIP.ToString(); // Only check if forwarded by a local network reverse proxy - if (IPAddress.TryParse(remoteAddress, out remoteIP) && remoteIP.IsLocalAddress()) + if (remoteIP.IsLocalAddress()) { - var realIPHeader = context.Request.Headers["X-Real-IP"]; + var realIPHeader = request.Headers["X-Real-IP"]; if (realIPHeader.Any()) { return realIPHeader.First().ToString(); } - var forwardedForHeader = context.Request.Headers["X-Forwarded-For"]; + var forwardedForHeader = request.Headers["X-Forwarded-For"]; if (forwardedForHeader.Any()) { // Get the first address that was forwarded by a local IP to prevent remote clients faking another proxy @@ -136,5 +190,18 @@ namespace Readarr.Http.Extensions return remoteAddress; } + + public static void DisableCache(this IHeaderDictionary headers) + { + headers["Cache-Control"] = "no-cache, no-store"; + headers["Expires"] = "-1"; + headers["Pragma"] = "no-cache"; + } + + public static void EnableCache(this IHeaderDictionary headers) + { + headers["Cache-Control"] = "max-age=31536000, public"; + headers["Last-Modified"] = BuildInfo.BuildDateTime.ToString("r"); + } } } diff --git a/src/Readarr.Http/Frontend/CacheableSpecification.cs b/src/Readarr.Http/Frontend/CacheableSpecification.cs deleted file mode 100644 index 7a264152e..000000000 --- a/src/Readarr.Http/Frontend/CacheableSpecification.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using Nancy; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; - -namespace Readarr.Http.Frontend -{ - public interface ICacheableSpecification - { - bool IsCacheable(NancyContext context); - } - - public class CacheableSpecification : ICacheableSpecification - { - public bool IsCacheable(NancyContext context) - { - if (!RuntimeInfo.IsProduction) - { - return false; - } - - if (((DynamicDictionary)context.Request.Query).ContainsKey("h")) - { - return true; - } - - if (context.Request.Path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase)) - { - if (context.Request.Path.ContainsIgnoreCase("/MediaCover")) - { - return true; - } - - return false; - } - - if (context.Request.Path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase)) - { - return false; - } - - if (context.Request.Path.EndsWith("index.js")) - { - return false; - } - - if (context.Request.Path.EndsWith("initialize.js")) - { - return false; - } - - if (context.Request.Path.StartsWith("/feed", StringComparison.CurrentCultureIgnoreCase)) - { - return false; - } - - if (context.Request.Path.StartsWith("/log", StringComparison.CurrentCultureIgnoreCase) && - context.Request.Path.EndsWith(".txt", StringComparison.CurrentCultureIgnoreCase)) - { - return false; - } - - if (context.Response != null) - { - if (context.Response.ContentType.Contains("text/html")) - { - return false; - } - } - - return true; - } - } -} diff --git a/src/Readarr.Http/Frontend/InitializeJsModule.cs b/src/Readarr.Http/Frontend/InitializeJsController.cs similarity index 72% rename from src/Readarr.Http/Frontend/InitializeJsModule.cs rename to src/Readarr.Http/Frontend/InitializeJsController.cs index e74ff8d92..d32b6da63 100644 --- a/src/Readarr.Http/Frontend/InitializeJsModule.cs +++ b/src/Readarr.Http/Frontend/InitializeJsController.cs @@ -1,7 +1,6 @@ -using System.IO; using System.Text; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Common; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Analytics; @@ -9,7 +8,9 @@ using NzbDrone.Core.Configuration; namespace Readarr.Http.Frontend { - public class InitializeJsModule : NancyModule + [Authorize(Policy = "UI")] + [ApiController] + public class InitializeJsController : Controller { private readonly IConfigFileProvider _configFileProvider; private readonly IAnalyticsService _analyticsService; @@ -18,36 +19,21 @@ namespace Readarr.Http.Frontend private static string _urlBase; private string _generatedContent; - public InitializeJsModule(IConfigFileProvider configFileProvider, - IAnalyticsService analyticsService) + public InitializeJsController(IConfigFileProvider configFileProvider, + IAnalyticsService analyticsService) { _configFileProvider = configFileProvider; _analyticsService = analyticsService; _apiKey = configFileProvider.ApiKey; _urlBase = configFileProvider.UrlBase; - - Get("/initialize.js", x => Index()); } - private Response Index() + [HttpGet("/initialize.js")] + public IActionResult Index() { // TODO: Move away from window.Readarr and prefetch the information returned here when starting the UI - return new StreamResponse(GetContentStream, "application/javascript"); - } - - private Stream GetContentStream() - { - var text = GetContent(); - - var stream = new MemoryStream(); - var writer = new StreamWriter(stream); - - writer.Write(text); - writer.Flush(); - stream.Position = 0; - - return stream; + return Content(GetContent(), "application/javascript"); } private string GetContent() diff --git a/src/Readarr.Http/Frontend/Mappers/HtmlMapperBase.cs b/src/Readarr.Http/Frontend/Mappers/HtmlMapperBase.cs index 06c0f60af..508603382 100644 --- a/src/Readarr.Http/Frontend/Mappers/HtmlMapperBase.cs +++ b/src/Readarr.Http/Frontend/Mappers/HtmlMapperBase.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Text.RegularExpressions; -using Nancy; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; @@ -40,13 +39,14 @@ namespace Readarr.Http.Frontend.Mappers return stream; } - public override Response GetResponse(string resourceUrl) + /* + public override IActionResult GetResponse(string resourceUrl) { var response = base.GetResponse(resourceUrl); response.Headers["X-UA-Compatible"] = "IE=edge"; return response; - } + }*/ protected string GetHtmlText() { diff --git a/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs b/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs index 268c6363b..180ed7d96 100644 --- a/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs +++ b/src/Readarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs @@ -1,4 +1,4 @@ -using Nancy; +using Microsoft.AspNetCore.Mvc; namespace Readarr.Http.Frontend.Mappers { @@ -6,6 +6,6 @@ namespace Readarr.Http.Frontend.Mappers { string Map(string resourceUrl); bool CanHandle(string resourceUrl); - Response GetResponse(string resourceUrl); + IActionResult GetResponse(string resourceUrl); } } diff --git a/src/Readarr.Http/Frontend/Mappers/LoginHtmlMapper.cs b/src/Readarr.Http/Frontend/Mappers/LoginHtmlMapper.cs deleted file mode 100644 index aa90d3265..000000000 --- a/src/Readarr.Http/Frontend/Mappers/LoginHtmlMapper.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.IO; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Core.Configuration; - -namespace Readarr.Http.Frontend.Mappers -{ - public class LoginHtmlMapper : HtmlMapperBase - { - public LoginHtmlMapper(IAppFolderInfo appFolderInfo, - IDiskProvider diskProvider, - Func cacheBreakProviderFactory, - IConfigFileProvider configFileProvider, - Logger logger) - : base(diskProvider, cacheBreakProviderFactory, logger) - { - HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html"); - UrlBase = configFileProvider.UrlBase; - } - - public override string Map(string resourceUrl) - { - return HtmlPath; - } - - public override bool CanHandle(string resourceUrl) - { - return resourceUrl.StartsWith("/login"); - } - } -} diff --git a/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs b/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs index 61ffc3fdd..98caef415 100644 --- a/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs +++ b/src/Readarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs @@ -1,7 +1,7 @@ using System; using System.IO; -using Nancy; -using Nancy.Responses; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; @@ -13,14 +13,14 @@ namespace Readarr.Http.Frontend.Mappers private readonly IDiskProvider _diskProvider; private readonly Logger _logger; private readonly StringComparison _caseSensitive; - - private static readonly NotFoundResponse NotFoundResponse = new NotFoundResponse(); + private readonly IContentTypeProvider _mimeTypeProvider; protected StaticResourceMapperBase(IDiskProvider diskProvider, Logger logger) { _diskProvider = diskProvider; _logger = logger; + _mimeTypeProvider = new FileExtensionContentTypeProvider(); _caseSensitive = RuntimeInfo.IsProduction ? DiskProviderBase.PathStringComparison : StringComparison.OrdinalIgnoreCase; } @@ -28,19 +28,23 @@ namespace Readarr.Http.Frontend.Mappers public abstract bool CanHandle(string resourceUrl); - public virtual Response GetResponse(string resourceUrl) + public virtual IActionResult GetResponse(string resourceUrl) { var filePath = Map(resourceUrl); if (_diskProvider.FileExists(filePath, _caseSensitive)) { - var response = new StreamResponse(() => GetContentStream(filePath), MimeTypes.GetMimeType(filePath)); - return new MaterialisingResponse(response); + if (!_mimeTypeProvider.TryGetContentType(filePath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + return new FileStreamResult(GetContentStream(filePath), contentType); } _logger.Warn("File {0} not found", filePath); - return NotFoundResponse; + return null; } protected virtual Stream GetContentStream(string filePath) diff --git a/src/Readarr.Http/Frontend/StaticResourceController.cs b/src/Readarr.Http/Frontend/StaticResourceController.cs new file mode 100644 index 000000000..d88165e85 --- /dev/null +++ b/src/Readarr.Http/Frontend/StaticResourceController.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; +using Readarr.Http.Frontend.Mappers; + +namespace Readarr.Http.Frontend +{ + [Authorize(Policy="UI")] + [ApiController] + public class StaticResourceController : Controller + { + private readonly string _urlBase; + private readonly string _loginPath; + private readonly IEnumerable _requestMappers; + private readonly Logger _logger; + + public StaticResourceController(IConfigFileProvider configFileProvider, + IAppFolderInfo appFolderInfo, + IEnumerable requestMappers, + Logger logger) + { + _urlBase = configFileProvider.UrlBase.Trim('/'); + _requestMappers = requestMappers; + _logger = logger; + + _loginPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html"); + } + + [AllowAnonymous] + [HttpGet("login")] + public IActionResult LoginPage() + { + return PhysicalFile(_loginPath, "text/html"); + } + + [EnableCors("AllowGet")] + [AllowAnonymous] + [HttpGet("/content/{**path:regex(^(?!api/).*)}")] + public IActionResult IndexContent([FromRoute] string path) + { + return MapResource("Content/" + path); + } + + [HttpGet("")] + [HttpGet("/{**path:regex(^(?!api/).*)}")] + public IActionResult Index([FromRoute] string path) + { + return MapResource(path); + } + + private IActionResult MapResource(string path) + { + path = "/" + (path ?? ""); + + var mapper = _requestMappers.SingleOrDefault(m => m.CanHandle(path)); + + if (mapper != null) + { + return mapper.GetResponse(path) ?? NotFound(); + } + + _logger.Warn("Couldn't find handler for {0}", path); + + return NotFound(); + } + } +} diff --git a/src/Readarr.Http/Frontend/StaticResourceModule.cs b/src/Readarr.Http/Frontend/StaticResourceModule.cs deleted file mode 100644 index 1626a04b1..000000000 --- a/src/Readarr.Http/Frontend/StaticResourceModule.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Nancy; -using NLog; -using Readarr.Http.Frontend.Mappers; - -namespace Readarr.Http.Frontend -{ - public class StaticResourceModule : NancyModule - { - private readonly IEnumerable _requestMappers; - private readonly Logger _logger; - - public StaticResourceModule(IEnumerable requestMappers, Logger logger) - { - _requestMappers = requestMappers; - _logger = logger; - - Get("/{resource*}", x => Index()); - Get("/", x => Index()); - } - - private Response Index() - { - var path = Request.Url.Path; - - if ( - string.IsNullOrWhiteSpace(path) || - path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase) || - path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase)) - { - return new NotFoundResponse(); - } - - var mapper = _requestMappers.SingleOrDefault(m => m.CanHandle(path)); - - if (mapper != null) - { - return mapper.GetResponse(path); - } - - _logger.Warn("Couldn't find handler for {0}", path); - - return new NotFoundResponse(); - } - } -} diff --git a/src/Readarr.Http/Middleware/CacheHeaderMiddleware.cs b/src/Readarr.Http/Middleware/CacheHeaderMiddleware.cs new file mode 100644 index 000000000..9240ce3ec --- /dev/null +++ b/src/Readarr.Http/Middleware/CacheHeaderMiddleware.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Readarr.Http.Extensions; + +namespace Readarr.Http.Middleware +{ + public class CacheHeaderMiddleware + { + private readonly RequestDelegate _next; + private readonly ICacheableSpecification _cacheableSpecification; + + public CacheHeaderMiddleware(RequestDelegate next, ICacheableSpecification cacheableSpecification) + { + _next = next; + _cacheableSpecification = cacheableSpecification; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Method != "OPTIONS") + { + if (_cacheableSpecification.IsCacheable(context)) + { + context.Response.Headers.EnableCache(); + } + else + { + context.Response.Headers.DisableCache(); + } + } + + await _next(context); + } + } +} diff --git a/src/Readarr.Http/Middleware/CacheableSpecification.cs b/src/Readarr.Http/Middleware/CacheableSpecification.cs new file mode 100644 index 000000000..a8e7749af --- /dev/null +++ b/src/Readarr.Http/Middleware/CacheableSpecification.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; + +namespace Readarr.Http.Middleware +{ + public interface ICacheableSpecification + { + bool IsCacheable(HttpContext context); + } + + public class CacheableSpecification : ICacheableSpecification + { + public bool IsCacheable(HttpContext context) + { + if (!RuntimeInfo.IsProduction) + { + return false; + } + + if (context.Request.Query.ContainsKey("h")) + { + return true; + } + + if (context.Request.Path.StartsWithSegments("/api", StringComparison.CurrentCultureIgnoreCase)) + { + if (context.Request.Path.ToString().ContainsIgnoreCase("/MediaCover")) + { + return true; + } + + return false; + } + + if (context.Request.Path.StartsWithSegments("/signalr", StringComparison.CurrentCultureIgnoreCase)) + { + return false; + } + + if (context.Request.Path.Equals("/index.js")) + { + return false; + } + + if (context.Request.Path.Equals("/initialize.js")) + { + return false; + } + + if (context.Request.Path.StartsWithSegments("/feed", StringComparison.CurrentCultureIgnoreCase)) + { + return false; + } + + if (context.Request.Path.StartsWithSegments("/log", StringComparison.CurrentCultureIgnoreCase) && + context.Request.Path.ToString().EndsWith(".txt", StringComparison.CurrentCultureIgnoreCase)) + { + return false; + } + + if (context.Response != null) + { + if (context.Response.ContentType?.Contains("text/html") ?? false || context.Response.StatusCode >= 400) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Readarr.Http/Middleware/IfModifiedMiddleware.cs b/src/Readarr.Http/Middleware/IfModifiedMiddleware.cs new file mode 100644 index 000000000..0f6225b52 --- /dev/null +++ b/src/Readarr.Http/Middleware/IfModifiedMiddleware.cs @@ -0,0 +1,43 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using Readarr.Http.Extensions; + +namespace Readarr.Http.Middleware +{ + public class IfModifiedMiddleware + { + private readonly RequestDelegate _next; + private readonly ICacheableSpecification _cacheableSpecification; + private readonly IContentTypeProvider _mimeTypeProvider; + + public IfModifiedMiddleware(RequestDelegate next, ICacheableSpecification cacheableSpecification) + { + _next = next; + _cacheableSpecification = cacheableSpecification; + + _mimeTypeProvider = new FileExtensionContentTypeProvider(); + } + + public async Task InvokeAsync(HttpContext context) + { + if (_cacheableSpecification.IsCacheable(context) && context.Request.Headers["IfModifiedSince"].Any()) + { + context.Response.StatusCode = 304; + context.Response.Headers.EnableCache(); + + if (!_mimeTypeProvider.TryGetContentType(context.Request.Path.ToString(), out var mimeType)) + { + mimeType = "application/octet-stream"; + } + + context.Response.ContentType = mimeType; + + return; + } + + await _next(context); + } + } +} diff --git a/src/Readarr.Http/Middleware/LoggingMiddleware.cs b/src/Readarr.Http/Middleware/LoggingMiddleware.cs new file mode 100644 index 000000000..0adf16af4 --- /dev/null +++ b/src/Readarr.Http/Middleware/LoggingMiddleware.cs @@ -0,0 +1,92 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NLog; +using NzbDrone.Common.Extensions; +using Readarr.Http.ErrorManagement; +using Readarr.Http.Extensions; + +namespace Readarr.Http.Middleware +{ + public class LoggingMiddleware + { + private static readonly Logger _loggerHttp = LogManager.GetLogger("Http"); + private static readonly Logger _loggerApi = LogManager.GetLogger("Api"); + private static int _requestSequenceID; + + private readonly ReadarrErrorPipeline _errorHandler; + private readonly RequestDelegate _next; + + public LoggingMiddleware(RequestDelegate next, + ReadarrErrorPipeline errorHandler) + { + _next = next; + _errorHandler = errorHandler; + } + + public async Task InvokeAsync(HttpContext context) + { + LogStart(context); + + await _next(context); + + LogEnd(context); + } + + private void LogStart(HttpContext context) + { + var id = Interlocked.Increment(ref _requestSequenceID); + + context.Items["ApiRequestSequenceID"] = id; + context.Items["ApiRequestStartTime"] = DateTime.UtcNow; + + var reqPath = GetRequestPathAndQuery(context.Request); + + _loggerHttp.Trace("Req: {0} [{1}] {2} (from {3})", id, context.Request.Method, reqPath, GetOrigin(context)); + } + + private void LogEnd(HttpContext context) + { + var id = (int)context.Items["ApiRequestSequenceID"]; + var startTime = (DateTime)context.Items["ApiRequestStartTime"]; + + var endTime = DateTime.UtcNow; + var duration = endTime - startTime; + + var reqPath = GetRequestPathAndQuery(context.Request); + + _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); + + if (context.Request.IsApiRequest()) + { + _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, context.Response.StatusCode, (HttpStatusCode)context.Response.StatusCode, (int)duration.TotalMilliseconds); + } + } + + private static string GetRequestPathAndQuery(HttpRequest request) + { + if (request.QueryString.Value.IsNotNullOrWhiteSpace() && request.QueryString.Value != "?") + { + return string.Concat(request.Path, request.QueryString); + } + else + { + return request.Path; + } + } + + private static string GetOrigin(HttpContext context) + { + if (context.Request.Headers["UserAgent"].ToString().IsNullOrWhiteSpace()) + { + return context.GetRemoteIP(); + } + else + { + return $"{context.GetRemoteIP()} {context.Request.Headers["UserAgent"]}"; + } + } + } +} diff --git a/src/Readarr.Http/Middleware/UrlBaseMiddleware.cs b/src/Readarr.Http/Middleware/UrlBaseMiddleware.cs new file mode 100644 index 000000000..ef78a7108 --- /dev/null +++ b/src/Readarr.Http/Middleware/UrlBaseMiddleware.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.Extensions; + +namespace Readarr.Http.Middleware +{ + public class UrlBaseMiddleware + { + private readonly RequestDelegate _next; + private readonly string _urlBase; + + public UrlBaseMiddleware(RequestDelegate next, string urlBase) + { + _next = next; + _urlBase = urlBase; + } + + public async Task InvokeAsync(HttpContext context) + { + if (_urlBase.IsNotNullOrWhiteSpace() && context.Request.PathBase.Value.IsNullOrWhiteSpace()) + { + context.Response.Redirect($"{_urlBase}{context.Request.Path}{context.Request.QueryString}"); + return; + } + + await _next(context); + } + } +} diff --git a/src/Readarr.Http/Middleware/VersionMiddleware.cs b/src/Readarr.Http/Middleware/VersionMiddleware.cs new file mode 100644 index 000000000..cbe9255b4 --- /dev/null +++ b/src/Readarr.Http/Middleware/VersionMiddleware.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NzbDrone.Common.EnvironmentInfo; + +namespace Readarr.Http.Middleware +{ + public class VersionMiddleware + { + private const string VERSIONHEADER = "X-ApplicationVersion"; + + private readonly RequestDelegate _next; + private readonly string _version; + + public VersionMiddleware(RequestDelegate next) + { + _next = next; + _version = BuildInfo.Version.ToString(); + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Response.Headers.ContainsKey(VERSIONHEADER)) + { + context.Response.Headers.Add(VERSIONHEADER, _version); + } + + await _next(context); + } + } +} diff --git a/src/Readarr.Http/REST/Attributes/RestDeleteByIdAttribute.cs b/src/Readarr.Http/REST/Attributes/RestDeleteByIdAttribute.cs new file mode 100644 index 000000000..296771db8 --- /dev/null +++ b/src/Readarr.Http/REST/Attributes/RestDeleteByIdAttribute.cs @@ -0,0 +1,14 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestDeleteByIdAttribute : HttpDeleteAttribute + { + public RestDeleteByIdAttribute() + : base("{id:int}") + { + } + } +} diff --git a/src/Readarr.Http/REST/Attributes/RestGetByIdAttribute.cs b/src/Readarr.Http/REST/Attributes/RestGetByIdAttribute.cs new file mode 100644 index 000000000..870b91e10 --- /dev/null +++ b/src/Readarr.Http/REST/Attributes/RestGetByIdAttribute.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestGetByIdAttribute : ActionFilterAttribute, IActionHttpMethodProvider, IRouteTemplateProvider + { + public override void OnActionExecuting(ActionExecutingContext context) + { + Console.WriteLine($"OnExecuting {context.Controller.GetType()} {context.ActionDescriptor.DisplayName}"); + } + + public IEnumerable HttpMethods => new[] { "GET" }; + public string Template => "{id:int}"; + public new int? Order => 0; + public string Name { get; } + } +} diff --git a/src/Readarr.Http/REST/Attributes/RestPostByIdAttribute.cs b/src/Readarr.Http/REST/Attributes/RestPostByIdAttribute.cs new file mode 100644 index 000000000..680f04a81 --- /dev/null +++ b/src/Readarr.Http/REST/Attributes/RestPostByIdAttribute.cs @@ -0,0 +1,10 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestPostByIdAttribute : HttpPostAttribute + { + } +} diff --git a/src/Readarr.Http/REST/Attributes/RestPutByIdAttribute.cs b/src/Readarr.Http/REST/Attributes/RestPutByIdAttribute.cs new file mode 100644 index 000000000..153e0c9d3 --- /dev/null +++ b/src/Readarr.Http/REST/Attributes/RestPutByIdAttribute.cs @@ -0,0 +1,14 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class RestPutByIdAttribute : HttpPutAttribute + { + public RestPutByIdAttribute() + : base("{id:int?}") + { + } + } +} diff --git a/src/Readarr.Http/REST/Attributes/SkipValidationAttribute.cs b/src/Readarr.Http/REST/Attributes/SkipValidationAttribute.cs new file mode 100644 index 000000000..4101a89a4 --- /dev/null +++ b/src/Readarr.Http/REST/Attributes/SkipValidationAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace NzbDrone.Http.REST.Attributes +{ + [AttributeUsage(AttributeTargets.Method)] + public class SkipValidationAttribute : Attribute + { + public SkipValidationAttribute(bool skip = true, bool skipShared = true) + { + Skip = skip; + SkipShared = skipShared; + } + + public bool Skip { get; } + public bool SkipShared { get; } + } +} diff --git a/src/Readarr.Http/REST/BadRequestException.cs b/src/Readarr.Http/REST/BadRequestException.cs index 8a2d422c4..1e8cda114 100644 --- a/src/Readarr.Http/REST/BadRequestException.cs +++ b/src/Readarr.Http/REST/BadRequestException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Readarr.Http.Exceptions; namespace Readarr.Http.REST diff --git a/src/Readarr.Http/REST/MethodNotAllowedException.cs b/src/Readarr.Http/REST/MethodNotAllowedException.cs index 14d89f13e..d9c0d15d1 100644 --- a/src/Readarr.Http/REST/MethodNotAllowedException.cs +++ b/src/Readarr.Http/REST/MethodNotAllowedException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Readarr.Http.Exceptions; namespace Readarr.Http.REST diff --git a/src/Readarr.Http/REST/NotFoundException.cs b/src/Readarr.Http/REST/NotFoundException.cs index 38ac00211..9574283ab 100644 --- a/src/Readarr.Http/REST/NotFoundException.cs +++ b/src/Readarr.Http/REST/NotFoundException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Readarr.Http.Exceptions; namespace Readarr.Http.REST diff --git a/src/Readarr.Http/REST/RestController.cs b/src/Readarr.Http/REST/RestController.cs new file mode 100644 index 000000000..0752cfe86 --- /dev/null +++ b/src/Readarr.Http/REST/RestController.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using NzbDrone.Core.Datastore; +using NzbDrone.Http.REST.Attributes; +using Readarr.Http.Validation; + +namespace Readarr.Http.REST +{ + public abstract class RestController : Controller + where TResource : RestResource, new() + { + private static readonly List VALIDATE_ID_ATTRIBUTES = new List { typeof(RestPutByIdAttribute), typeof(RestDeleteByIdAttribute) }; + + protected ResourceValidator PostValidator { get; private set; } + protected ResourceValidator PutValidator { get; private set; } + protected ResourceValidator SharedValidator { get; private set; } + + protected void ValidateId(int id) + { + if (id <= 0) + { + throw new BadRequestException(id + " is not a valid ID"); + } + } + + protected RestController() + { + PostValidator = new ResourceValidator(); + PutValidator = new ResourceValidator(); + SharedValidator = new ResourceValidator(); + + PutValidator.RuleFor(r => r.Id).ValidId(); + } + + [RestGetById] + public abstract TResource GetResourceById(int id); + + public override void OnActionExecuting(ActionExecutingContext context) + { + var descriptor = context.ActionDescriptor as ControllerActionDescriptor; + + var skipAttribute = (SkipValidationAttribute)Attribute.GetCustomAttribute(descriptor.MethodInfo, typeof(SkipValidationAttribute), true); + var skipValidate = skipAttribute?.Skip ?? false; + var skipShared = skipAttribute?.SkipShared ?? false; + + if (Request.Method == "POST" || Request.Method == "PUT") + { + var resourceArgs = context.ActionArguments.Values.Where(x => x.GetType() == typeof(TResource)) + .Select(x => x as TResource) + .ToList(); + + foreach (var resource in resourceArgs) + { + ValidateResource(resource, skipValidate, skipShared); + } + } + + var attributes = descriptor.MethodInfo.CustomAttributes; + if (attributes.Any(x => VALIDATE_ID_ATTRIBUTES.Contains(x.GetType())) && !skipValidate) + { + if (context.ActionArguments.TryGetValue("id", out var idObj)) + { + ValidateId((int)idObj); + } + } + + base.OnActionExecuting(context); + } + + public override void OnActionExecuted(ActionExecutedContext context) + { + var descriptor = context.ActionDescriptor as ControllerActionDescriptor; + + var attributes = descriptor.MethodInfo.CustomAttributes; + + if (context.Exception?.GetType() == typeof(ModelNotFoundException) && + attributes.Any(x => x.AttributeType == typeof(RestGetByIdAttribute))) + { + context.Result = new NotFoundResult(); + } + } + + protected void ValidateResource(TResource resource, bool skipValidate = false, bool skipSharedValidate = false) + { + if (resource == null) + { + throw new BadRequestException("Request body can't be empty"); + } + + var errors = new List(); + + if (!skipSharedValidate) + { + errors.AddRange(SharedValidator.Validate(resource).Errors); + } + + if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Path.ToString().EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) + { + errors.AddRange(PostValidator.Validate(resource).Errors); + } + else if (Request.Method.Equals("PUT", StringComparison.InvariantCultureIgnoreCase)) + { + errors.AddRange(PutValidator.Validate(resource).Errors); + } + + if (errors.Any()) + { + throw new ValidationException(errors); + } + } + + protected ActionResult Accepted(int id) + { + var result = GetResourceById(id); + return AcceptedAtAction(nameof(GetResourceById), new { id = id }, result); + } + + protected ActionResult Created(int id) + { + var result = GetResourceById(id); + return CreatedAtAction(nameof(GetResourceById), new { id = id }, result); + } + } +} diff --git a/src/Readarr.Http/ReadarrRestModuleWithSignalR.cs b/src/Readarr.Http/REST/RestControllerWithSignalR.cs similarity index 76% rename from src/Readarr.Http/ReadarrRestModuleWithSignalR.cs rename to src/Readarr.Http/REST/RestControllerWithSignalR.cs index d858e0fb8..0ba3ab343 100644 --- a/src/Readarr.Http/ReadarrRestModuleWithSignalR.cs +++ b/src/Readarr.Http/REST/RestControllerWithSignalR.cs @@ -1,28 +1,35 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.SignalR; -using Readarr.Http.REST; -namespace Readarr.Http +namespace Readarr.Http.REST { - public abstract class ReadarrRestModuleWithSignalR : ReadarrRestModule, IHandle> + public abstract class RestControllerWithSignalR : RestController, IHandle> where TResource : RestResource, new() where TModel : ModelBase, new() { + protected string Resource { get; } private readonly IBroadcastSignalRMessage _signalRBroadcaster; - protected ReadarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) + protected RestControllerWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) { _signalRBroadcaster = signalRBroadcaster; - } - protected ReadarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster, string resource) - : base(resource) - { - _signalRBroadcaster = signalRBroadcaster; + var apiAttribute = GetType().GetCustomAttribute(); + if (apiAttribute != null && apiAttribute.Resource != VersionedApiControllerAttribute.CONTROLLER_RESOURCE) + { + Resource = apiAttribute.Resource; + } + else + { + Resource = new TResource().ResourceName.Trim('/'); + } } + [NonAction] public void Handle(ModelEvent message) { if (!_signalRBroadcaster.IsConnected) diff --git a/src/Readarr.Http/REST/RestModule.cs b/src/Readarr.Http/REST/RestModule.cs deleted file mode 100644 index d7d154923..000000000 --- a/src/Readarr.Http/REST/RestModule.cs +++ /dev/null @@ -1,373 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using FluentValidation; -using FluentValidation.Results; -using Nancy; -using Nancy.Responses.Negotiation; -using NzbDrone.Core.Datastore; -using Readarr.Http.Extensions; - -namespace Readarr.Http.REST -{ - public abstract class RestModule : NancyModule - where TResource : RestResource, new() - { - private const string ROOT_ROUTE = "/"; - private const string ID_ROUTE = @"/(?[\d]{1,10})"; - - // See src/Readarr.Api.V1/Queue/QueueModule.cs - private static readonly HashSet VALID_SORT_KEYS = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "authors.sortname", //Workaround authors table properties not being added on isValidSortKey call - "timeleft", - "estimatedCompletionTime", - "protocol", - "indexer", - "downloadClient", - "quality", - "status", - "title", - "progress" - }; - - private readonly HashSet _excludedKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase) - { - "page", - "pageSize", - "sortKey", - "sortDirection", - "filterKey", - "filterValue", - }; - - private Action _deleteResource; - private Func _getResourceById; - private Func> _getResourceAll; - private Func, PagingResource> _getResourcePaged; - private Func _getResourceSingle; - private Func _createResource; - private Action _updateResource; - - protected ResourceValidator PostValidator { get; private set; } - protected ResourceValidator PutValidator { get; private set; } - protected ResourceValidator SharedValidator { get; private set; } - - protected void ValidateId(int id) - { - if (id <= 0) - { - throw new BadRequestException(id + " is not a valid ID"); - } - } - - protected RestModule(string modulePath) - : base(modulePath) - { - ValidateModule(); - - PostValidator = new ResourceValidator(); - PutValidator = new ResourceValidator(); - SharedValidator = new ResourceValidator(); - } - - private void ValidateModule() - { - if (GetResourceById != null) - { - return; - } - - if (CreateResource != null || UpdateResource != null) - { - throw new InvalidOperationException("GetResourceById route must be defined before defining Create/Update routes."); - } - } - - protected Action DeleteResource - { - private get - { - return _deleteResource; - } - - set - { - _deleteResource = value; - Delete(ID_ROUTE, options => - { - ValidateId(options.Id); - DeleteResource((int)options.Id); - - return new object(); - }); - } - } - - protected Func GetResourceById - { - get - { - return _getResourceById; - } - - set - { - _getResourceById = value; - Get(ID_ROUTE, options => - { - ValidateId(options.Id); - try - { - var resource = GetResourceById((int)options.Id); - - if (resource == null) - { - return new NotFoundResponse(); - } - - return resource; - } - catch (ModelNotFoundException) - { - return new NotFoundResponse(); - } - }); - } - } - - protected Func> GetResourceAll - { - private get - { - return _getResourceAll; - } - - set - { - _getResourceAll = value; - Get(ROOT_ROUTE, options => - { - var resource = GetResourceAll(); - return resource; - }); - } - } - - protected Func, PagingResource> GetResourcePaged - { - private get - { - return _getResourcePaged; - } - - set - { - _getResourcePaged = value; - Get(ROOT_ROUTE, options => - { - var resource = GetResourcePaged(ReadPagingResourceFromRequest()); - return resource; - }); - } - } - - protected Func GetResourceSingle - { - private get - { - return _getResourceSingle; - } - - set - { - _getResourceSingle = value; - Get(ROOT_ROUTE, options => - { - var resource = GetResourceSingle(); - return resource; - }); - } - } - - protected Func CreateResource - { - private get - { - return _createResource; - } - - set - { - _createResource = value; - Post(ROOT_ROUTE, options => - { - var id = CreateResource(ReadResourceFromRequest()); - return ResponseWithCode(GetResourceById(id), HttpStatusCode.Created); - }); - } - } - - protected Action UpdateResource - { - private get - { - return _updateResource; - } - - set - { - _updateResource = value; - Put(ROOT_ROUTE, options => - { - var resource = ReadResourceFromRequest(); - UpdateResource(resource); - return ResponseWithCode(GetResourceById(resource.Id), HttpStatusCode.Accepted); - }); - Put(ID_ROUTE, options => - { - var resource = ReadResourceFromRequest(); - resource.Id = options.Id; - UpdateResource(resource); - return ResponseWithCode(GetResourceById(resource.Id), HttpStatusCode.Accepted); - }); - } - } - - protected Negotiator ResponseWithCode(object model, HttpStatusCode statusCode) - { - return Negotiate.WithModel(model).WithStatusCode(statusCode); - } - - protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false) - { - var resource = new TResource(); - - try - { - resource = Request.Body.FromJson(); - } - catch (JsonException e) - { - throw new BadRequestException(e.Message); - } - - if (resource == null) - { - throw new BadRequestException("Request body can't be empty"); - } - - 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)) - { - errors.AddRange(PostValidator.Validate(resource).Errors); - } - else if (Request.Method.Equals("PUT", StringComparison.InvariantCultureIgnoreCase)) - { - errors.AddRange(PutValidator.Validate(resource).Errors); - } - - if (errors.Any()) - { - throw new ValidationException(errors); - } - - return resource; - } - - private PagingResource ReadPagingResourceFromRequest() - { - int pageSize; - int.TryParse(Request.Query.PageSize.ToString(), out pageSize); - if (pageSize == 0) - { - pageSize = 10; - } - - int page; - int.TryParse(Request.Query.Page.ToString(), out page); - if (page == 0) - { - page = 1; - } - - var pagingResource = new PagingResource - { - PageSize = pageSize, - Page = page, - Filters = new List() - }; - - if (Request.Query.SortKey != null) - { - var sortKey = Request.Query.SortKey.ToString(); - - if (!VALID_SORT_KEYS.Contains(sortKey) && - !TableMapping.Mapper.IsValidSortKey(sortKey)) - { - throw new BadRequestException($"Invalid sort key {sortKey}"); - } - - pagingResource.SortKey = sortKey; - - // For backwards compatibility with v2 - if (Request.Query.SortDir != null) - { - pagingResource.SortDirection = Request.Query.SortDir.ToString() - .Equals("Asc", StringComparison.InvariantCultureIgnoreCase) - ? SortDirection.Ascending - : SortDirection.Descending; - } - - // v3 uses SortDirection instead of SortDir to be consistent with every other use of it - if (Request.Query.SortDirection != null) - { - pagingResource.SortDirection = Request.Query.SortDirection.ToString() - .Equals("ascending", StringComparison.InvariantCultureIgnoreCase) - ? SortDirection.Ascending - : SortDirection.Descending; - } - } - - // For backwards compatibility with v2 - if (Request.Query.FilterKey != null) - { - var filter = new PagingResourceFilter - { - Key = Request.Query.FilterKey.ToString() - }; - - if (Request.Query.FilterValue != null) - { - filter.Value = Request.Query.FilterValue?.ToString(); - } - - pagingResource.Filters.Add(filter); - } - - // v3 uses filters in key=value format - foreach (var key in Request.Query) - { - if (_excludedKeys.Contains(key)) - { - continue; - } - - pagingResource.Filters.Add(new PagingResourceFilter - { - Key = key, - Value = Request.Query[key] - }); - } - - return pagingResource; - } - } -} diff --git a/src/Readarr.Http/REST/UnsupportedMediaTypeException.cs b/src/Readarr.Http/REST/UnsupportedMediaTypeException.cs index a1f5c8422..d513b22af 100644 --- a/src/Readarr.Http/REST/UnsupportedMediaTypeException.cs +++ b/src/Readarr.Http/REST/UnsupportedMediaTypeException.cs @@ -1,4 +1,4 @@ -using Nancy; +using System.Net; using Readarr.Http.Exceptions; namespace Readarr.Http.REST diff --git a/src/Readarr.Http/Readarr.Http.csproj b/src/Readarr.Http/Readarr.Http.csproj index 798f10bc6..72d0da734 100644 --- a/src/Readarr.Http/Readarr.Http.csproj +++ b/src/Readarr.Http/Readarr.Http.csproj @@ -4,9 +4,7 @@ - - - + diff --git a/src/Readarr.Http/ReadarrBootstrapper.cs b/src/Readarr.Http/ReadarrBootstrapper.cs deleted file mode 100644 index 3b92a08b8..000000000 --- a/src/Readarr.Http/ReadarrBootstrapper.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Linq; -using Nancy; -using Nancy.Bootstrapper; -using Nancy.Diagnostics; -using Nancy.Responses.Negotiation; -using NLog; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Instrumentation; -using NzbDrone.Core.Instrumentation; -using Readarr.Http.Extensions.Pipelines; -using TinyIoC; - -namespace Readarr.Http -{ - public class ReadarrBootstrapper : TinyIoCNancyBootstrapper - { - private readonly TinyIoCContainer _tinyIoCContainer; - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(ReadarrBootstrapper)); - - public ReadarrBootstrapper(TinyIoCContainer tinyIoCContainer) - { - _tinyIoCContainer = tinyIoCContainer; - } - - protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines) - { - Logger.Info("Starting Web Server"); - - if (RuntimeInfo.IsProduction) - { - DiagnosticsHook.Disable(pipelines); - } - - RegisterPipelines(pipelines); - - container.Resolve().Register(); - } - - private void RegisterPipelines(IPipelines pipelines) - { - var pipelineRegistrars = _tinyIoCContainer.ResolveAll().OrderBy(v => v.Order).ToList(); - - foreach (var registerNancyPipeline in pipelineRegistrars) - { - registerNancyPipeline.Register(pipelines); - } - } - - protected override TinyIoCContainer GetApplicationContainer() - { - return _tinyIoCContainer; - } - - protected override Func InternalConfiguration - { - get - { - // We don't support Xml Serialization atm - return NancyInternalConfiguration.WithOverrides(x => - { - x.ResponseProcessors.Remove(typeof(ViewProcessor)); - x.ResponseProcessors.Remove(typeof(XmlProcessor)); - }); - } - } - - public override void Configure(Nancy.Configuration.INancyEnvironment environment) - { - environment.Diagnostics(password: @"password"); - } - - protected override byte[] FavIcon => null; - } -} diff --git a/src/Readarr.Http/ReadarrModule.cs b/src/Readarr.Http/ReadarrModule.cs deleted file mode 100644 index f68f9b4ea..000000000 --- a/src/Readarr.Http/ReadarrModule.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Nancy; -using Nancy.Responses.Negotiation; - -namespace Readarr.Http -{ - public abstract class ReadarrModule : NancyModule - { - protected ReadarrModule(string resource) - : base(resource) - { - } - - protected Negotiator ResponseWithCode(object model, HttpStatusCode statusCode) - { - return Negotiate.WithModel(model).WithStatusCode(statusCode); - } - } -} diff --git a/src/Readarr.Http/ReadarrRestModule.cs b/src/Readarr.Http/ReadarrRestModule.cs deleted file mode 100644 index 331521443..000000000 --- a/src/Readarr.Http/ReadarrRestModule.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using NzbDrone.Core.Datastore; -using Readarr.Http.REST; -using Readarr.Http.Validation; - -namespace Readarr.Http -{ - public abstract class ReadarrRestModule : RestModule - where TResource : RestResource, new() - { - protected string Resource { get; private set; } - - private static string BaseUrl() - { - var isV1 = typeof(TResource).Namespace.Contains(".V1."); - if (isV1) - { - return "/api/v1/"; - } - - return "/api/"; - } - - private static string ResourceName() - { - return new TResource().ResourceName.Trim('/').ToLower(); - } - - protected ReadarrRestModule() - : this(ResourceName()) - { - } - - protected ReadarrRestModule(string resource) - : base(BaseUrl() + resource.Trim('/').ToLower()) - { - Resource = resource; - - // PostValidator.RuleFor(r => r.Id).IsZero(); - PutValidator.RuleFor(r => r.Id).ValidId(); - } - - protected PagingResource ApplyToPage(Func, PagingSpec> function, PagingSpec pagingSpec, Converter mapper) - { - pagingSpec = function(pagingSpec); - - return new PagingResource - { - Page = pagingSpec.Page, - PageSize = pagingSpec.PageSize, - SortDirection = pagingSpec.SortDirection, - SortKey = pagingSpec.SortKey, - TotalRecords = pagingSpec.TotalRecords, - Records = pagingSpec.Records.ConvertAll(mapper) - }; - } - } -} diff --git a/src/Readarr.Http/TinyIoCNancyBootstrapper.cs b/src/Readarr.Http/TinyIoCNancyBootstrapper.cs deleted file mode 100644 index 435ccc37c..000000000 --- a/src/Readarr.Http/TinyIoCNancyBootstrapper.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Nancy; -using Nancy.Bootstrapper; -using Nancy.Configuration; -using Nancy.Diagnostics; -using TinyIoC; - -namespace Readarr.Http -{ - /// - /// TinyIoC bootstrapper - registers default route resolver and registers itself as - /// INancyModuleCatalog for resolving modules but behaviour can be overridden if required. - /// - public class TinyIoCNancyBootstrapper : NancyBootstrapperWithRequestContainerBase - { - /// - /// Default assemblies that are ignored for autoregister - /// - public static IEnumerable> DefaultAutoRegisterIgnoredAssemblies = new Func[] - { - asm => !asm.FullName.StartsWith("Nancy.", StringComparison.InvariantCulture) - }; - - /// - /// Gets the assemblies to ignore when autoregistering the application container - /// Return true from the delegate to ignore that particular assembly, returning false - /// does not mean the assembly *will* be included, a true from another delegate will - /// take precedence. - /// - protected virtual IEnumerable> AutoRegisterIgnoredAssemblies => DefaultAutoRegisterIgnoredAssemblies; - - /// - /// Configures the container using AutoRegister followed by registration - /// of default INancyModuleCatalog and IRouteResolver. - /// - /// Container instance - protected override void ConfigureApplicationContainer(TinyIoCContainer container) - { - AutoRegister(container, AutoRegisterIgnoredAssemblies); - } - - /// - /// Resolve INancyEngine - /// - /// INancyEngine implementation - protected override sealed INancyEngine GetEngineInternal() - { - return ApplicationContainer.Resolve(); - } - - // Summary: - // Gets the Nancy.Configuration.INancyEnvironmentConfigurator used by th. - // Returns: - // An Nancy.Configuration.INancyEnvironmentConfigurator instance. - protected override INancyEnvironmentConfigurator GetEnvironmentConfigurator() - { - return ApplicationContainer.Resolve(); - } - - // Summary: - // Get the Nancy.Configuration.INancyEnvironment instance. - // Returns: - // An configured Nancy.Configuration.INancyEnvironment instance. - // Remarks: - // The boostrapper must be initialised (Nancy.Bootstrapper.INancyBootstrapper.Initialise) - // prior to calling this. - public override INancyEnvironment GetEnvironment() - { - return ApplicationContainer.Resolve(); - } - - // Summary: - // Registers an Nancy.Configuration.INancyEnvironment instance in the container. - // Parameters: - // container: - // The container to register into. - // environment: - // The Nancy.Configuration.INancyEnvironment instance to register. - protected override void RegisterNancyEnvironment(TinyIoCContainer container, INancyEnvironment environment) - { - ApplicationContainer.Register(environment); - } - - /// - /// Create a default, unconfigured, container - /// - /// Container instance - protected override TinyIoCContainer GetApplicationContainer() - { - return new TinyIoCContainer(); - } - - /// - /// Register the bootstrapper's implemented types into the container. - /// This is necessary so a user can pass in a populated container but not have - /// to take the responsibility of registering things like INancyModuleCatalog manually. - /// - /// Application container to register into - protected override sealed void RegisterBootstrapperTypes(TinyIoCContainer applicationContainer) - { - applicationContainer.Register(this); - } - - /// - /// Register the default implementations of internally used types into the container as singletons - /// - /// Container to register into - /// Type registrations to register - protected override sealed void RegisterTypes(TinyIoCContainer container, IEnumerable typeRegistrations) - { - foreach (var typeRegistration in typeRegistrations) - { - switch (typeRegistration.Lifetime) - { - case Lifetime.Transient: - container.Register(typeRegistration.RegistrationType, typeRegistration.ImplementationType).AsMultiInstance(); - break; - case Lifetime.Singleton: - container.Register(typeRegistration.RegistrationType, typeRegistration.ImplementationType).AsSingleton(); - break; - case Lifetime.PerRequest: - throw new InvalidOperationException("Unable to directly register a per request lifetime."); - default: - throw new ArgumentOutOfRangeException(); - } - } - } - - /// - /// Register the various collections into the container as singletons to later be resolved - /// by IEnumerable{Type} constructor dependencies. - /// - /// Container to register into - /// Collection type registrations to register - protected override sealed void RegisterCollectionTypes(TinyIoCContainer container, IEnumerable collectionTypeRegistrations) - { - foreach (var collectionTypeRegistration in collectionTypeRegistrations) - { - switch (collectionTypeRegistration.Lifetime) - { - case Lifetime.Transient: - container.RegisterMultiple(collectionTypeRegistration.RegistrationType, collectionTypeRegistration.ImplementationTypes).AsMultiInstance(); - break; - case Lifetime.Singleton: - container.RegisterMultiple(collectionTypeRegistration.RegistrationType, collectionTypeRegistration.ImplementationTypes).AsSingleton(); - break; - case Lifetime.PerRequest: - throw new InvalidOperationException("Unable to directly register a per request lifetime."); - default: - throw new ArgumentOutOfRangeException(); - } - } - } - - /// - /// Register the given module types into the container - /// - /// Container to register into - /// NancyModule types - protected override sealed void RegisterRequestContainerModules(TinyIoCContainer container, IEnumerable moduleRegistrationTypes) - { - foreach (var moduleRegistrationType in moduleRegistrationTypes) - { - container.Register( - typeof(INancyModule), - moduleRegistrationType.ModuleType, - moduleRegistrationType.ModuleType.FullName). - AsSingleton(); - } - } - - /// - /// Register the given instances into the container - /// - /// Container to register into - /// Instance registration types - protected override void RegisterInstances(TinyIoCContainer container, IEnumerable instanceRegistrations) - { - foreach (var instanceRegistration in instanceRegistrations) - { - container.Register( - instanceRegistration.RegistrationType, - instanceRegistration.Implementation); - } - } - - /// - /// Creates a per request child/nested container - /// - /// Current context - /// Request container instance - protected override TinyIoCContainer CreateRequestContainer(NancyContext context) - { - return ApplicationContainer.GetChildContainer(); - } - - /// - /// Gets the diagnostics for initialisation - /// - /// IDiagnostics implementation - protected override IDiagnostics GetDiagnostics() - { - return ApplicationContainer.Resolve(); - } - - /// - /// Gets all registered startup tasks - /// - /// An instance containing instances. - protected override IEnumerable GetApplicationStartupTasks() - { - return ApplicationContainer.ResolveAll(false); - } - - /// - /// Gets all registered request startup tasks - /// - /// An instance containing instances. - protected override IEnumerable RegisterAndGetRequestStartupTasks(TinyIoCContainer container, Type[] requestStartupTypes) - { - container.RegisterMultiple(typeof(IRequestStartup), requestStartupTypes); - - return container.ResolveAll(false); - } - - /// - /// Gets all registered application registration tasks - /// - /// An instance containing instances. - protected override IEnumerable GetRegistrationTasks() - { - return ApplicationContainer.ResolveAll(false); - } - - /// - /// Retrieve all module instances from the container - /// - /// Container to use - /// Collection of NancyModule instances - protected override sealed IEnumerable GetAllModules(TinyIoCContainer container) - { - var nancyModules = container.ResolveAll(false); - return nancyModules; - } - - /// - /// Retrieve a specific module instance from the container - /// - /// Container to use - /// Type of the module - /// NancyModule instance - protected override sealed INancyModule GetModule(TinyIoCContainer container, Type moduleType) - { - container.Register(typeof(INancyModule), moduleType); - - return container.Resolve(); - } - - /// - /// Executes auto registation with the given container. - /// - /// Container instance - private static void AutoRegister(TinyIoCContainer container, IEnumerable> ignoredAssemblies) - { - var assembly = typeof(NancyEngine).Assembly; - - container.AutoRegister(AppDomain.CurrentDomain.GetAssemblies().Where(a => !ignoredAssemblies.Any(ia => ia(a))), DuplicateImplementationActions.RegisterMultiple, t => t.Assembly != assembly); - } - } -} diff --git a/src/Readarr.Http/Validation/DuplicateEndpointDetector.cs b/src/Readarr.Http/Validation/DuplicateEndpointDetector.cs new file mode 100644 index 000000000..a8fcefd82 --- /dev/null +++ b/src/Readarr.Http/Validation/DuplicateEndpointDetector.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using ImpromptuInterface; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; + +namespace Readarr.Http.Validation +{ + public interface IDfaMatcherBuilder + { + void AddEndpoint(RouteEndpoint endpoint); + object BuildDfaTree(bool includeLabel = false); + } + + // https://github.com/dotnet/aspnetcore/blob/cc3d47f5501cdfae3e5b5be509ef2c0fb8cca069/src/Http/Routing/src/Matching/DfaNode.cs + public interface IDfaNode + { + public string Label { get; set; } + public List Matches { get; } + public IDictionary Literals { get; } + public object Parameters { get; } + public object CatchAll { get; } + public IDictionary PolicyEdges { get; } + } + + public class DuplicateEndpointDetector + { + private readonly IServiceProvider _services; + + public DuplicateEndpointDetector(IServiceProvider services) + { + _services = services; + } + + public Dictionary> GetDuplicateEndpoints(EndpointDataSource dataSource) + { + // get the DfaMatcherBuilder - internal, so needs reflection :( + var matcherBuilder = typeof(IEndpointSelectorPolicy).Assembly + .GetType("Microsoft.AspNetCore.Routing.Matching.DfaMatcherBuilder"); + + var rawBuilder = _services.GetRequiredService(matcherBuilder); + var builder = rawBuilder.ActLike(); + + var endpoints = dataSource.Endpoints; + foreach (var t in endpoints) + { + if (t is RouteEndpoint endpoint && (endpoint.Metadata.GetMetadata()?.SuppressMatching ?? false) == false) + { + builder.AddEndpoint(endpoint); + } + } + + // Assign each node a sequential index. + var visited = new Dictionary(); + var duplicates = new Dictionary>(); + + var rawTree = builder.BuildDfaTree(includeLabel: true); + + Visit(rawTree, LogDuplicates); + + return duplicates; + + void LogDuplicates(IDfaNode node) + { + if (!visited.TryGetValue(node, out var label)) + { + label = visited.Count; + visited.Add(node, label); + } + + // We can safely index into visited because this is a post-order traversal, + // all of the children of this node are already in the dictionary. + var filteredMatches = node?.Matches?.Where(x => !x.DisplayName.StartsWith("Readarr.Http.Frontend.StaticResourceController")).ToList(); + var matchCount = filteredMatches?.Count ?? 0; + if (matchCount > 1) + { + var duplicateEndpoints = filteredMatches.Select(x => x.DisplayName).ToList(); + duplicates[node.Label] = duplicateEndpoints; + } + } + } + + private static void Visit(object rawNode, Action visitor) + { + var node = rawNode.ActLike(); + if (node.Literals?.Values != null) + { + foreach (var dictValue in node.Literals.Values) + { + Visit(dictValue, visitor); + } + } + + // Break cycles + if (node.Parameters != null && !ReferenceEquals(rawNode, node.Parameters)) + { + Visit(node.Parameters, visitor); + } + + // Break cycles + if (node.CatchAll != null && !ReferenceEquals(rawNode, node.CatchAll)) + { + Visit(node.CatchAll, visitor); + } + + if (node.PolicyEdges?.Values != null) + { + foreach (var dictValue in node.PolicyEdges.Values) + { + Visit(dictValue, visitor); + } + } + + visitor(node); + } + } +} diff --git a/src/Readarr.Http/VersionedApiControllerAttribute.cs b/src/Readarr.Http/VersionedApiControllerAttribute.cs new file mode 100644 index 000000000..09d44855b --- /dev/null +++ b/src/Readarr.Http/VersionedApiControllerAttribute.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Readarr.Http +{ + public class VersionedApiControllerAttribute : Attribute, IRouteTemplateProvider, IEnableCorsAttribute, IApiBehaviorMetadata + { + public const string API_CORS_POLICY = "ApiCorsPolicy"; + public const string CONTROLLER_RESOURCE = "[controller]"; + + public VersionedApiControllerAttribute(int version, string resource = CONTROLLER_RESOURCE) + { + Resource = resource; + Template = $"api/v{version}/{resource}"; + PolicyName = API_CORS_POLICY; + } + + public string Resource { get; } + public string Template { get; } + public int? Order => 2; + public string Name { get; set; } + public string PolicyName { get; set; } + } + + public class V1ApiControllerAttribute : VersionedApiControllerAttribute + { + public V1ApiControllerAttribute(string resource = "[controller]") + : base(1, resource) + { + } + } +} diff --git a/src/Readarr.Http/VersionedFeedControllerAttribute.cs b/src/Readarr.Http/VersionedFeedControllerAttribute.cs new file mode 100644 index 000000000..9e5001142 --- /dev/null +++ b/src/Readarr.Http/VersionedFeedControllerAttribute.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace Readarr.Http +{ + public class VersionedFeedControllerAttribute : Attribute, IRouteTemplateProvider + { + public VersionedFeedControllerAttribute(int version, string resource = "[controller]") + { + Version = version; + Template = $"feed/v{Version}/{resource}"; + } + + public string Template { get; private set; } + public int? Order => 2; + public string Name { get; set; } + public int Version { get; private set; } + } + + public class V1FeedControllerAttribute : VersionedApiControllerAttribute + { + public V1FeedControllerAttribute(string resource = "[controller]") + : base(1, resource) + { + } + } +}