From c2ecc8dc33d693d7dd89a3d2f0a1faa0327c323e Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Thu, 23 Feb 2023 14:36:21 +0100 Subject: [PATCH] New Backoffice: Package controller (#13578) * Adding migration to update the default GUID value of created packages * Updating the GUID if it is the default value when a package is saved * Adding PackageDefinitionViewModel for representing a package * Adding a mapping for package representation * Adding PackageControllerBase, GetAllCreated and GetEmpty endpoints * Adding GetCreatedByKey endpoint * Adding GetByKey implementation for created packages * Include MapAll comment * Adding Download package endpoint * Saving created package endpoint * Adding a factory to create a PackageDefinition from view model * Cleanup * Fix error message * Check for duplicate package name * Remove commented out DuplicateNameException * Moving created packages to /created folder/base * Implement delete endpoint * Update OpenApi.json * Fix package route * Fix OpenApi.json * Add Ok() around the result * Create PackageBuilderExtensions * Adding suppression changes * Cleanup * Use ProblemDetailsBuilder * Extract collecting installed packages from package migration plans into its own method * Use GetInstalledPackagesFromMigrationPlans to return all migration statuses * Add Status to DictionaryControllerBase ProblemDetails * Implement RunMigrationPackageController * Adding more information to the log message * Update OpenApi.json * Change param name * Fix OpenApi.json * Fix response type for Log viewer endpoint * Remove EmptyPackageController * Rename to RunPendingPackageMigrations * Introduce new PackageOperationStatus * Making methods async and introducing new Create, Update and Delete methods * Fix async calls * Fix Mapper - multiple enumeration and cleanup * Creating special action models * Fixing the factory with new models changes * Service implementation changes * Removing SaveCreatedPackageController as the functionality is split between Create and UpdateCreatedPackageController * Utilize the new DeleteCreatedPackageAsync * Refactor DownloadCreatedPackageController as some responsibility is moved to the service * Refactor PackagingService to use auditService * Refactor PackagingService to use skip/take * Refactor services to return pagedmodel * Refactor controller to use new return value * update OpenApi.json --------- Co-authored-by: Zeegaan --- .../Dictionary/DictionaryControllerBase.cs | 2 +- .../LogLevelCountLogViewerController.cs | 3 +- .../AllMigrationStatusPackageController.cs | 44 ++ .../Created/AllCreatedPackageController.cs | 42 ++ .../Created/ByKeyCreatedPackageController.cs | 41 ++ .../Created/CreateCreatedPackageController.cs | 49 ++ .../Created/CreatedPackageControllerBase.cs | 12 + .../Created/DeleteCreatedPackageController.cs | 40 ++ .../DownloadCreatedPackageController.cs | 59 ++ .../Created/UpdateCreatedPackageController.cs | 57 ++ .../Package/PackageControllerBase.cs | 40 ++ .../Package/RunMigrationPackageController.cs | 34 ++ .../PackageBuilderExtensions.cs | 19 + .../Factories/IPackageDefinitionFactory.cs | 9 + .../Factories/PackageDefinitionFactory.cs | 25 + .../ManagementApiComposer.cs | 1 + .../Package/PackageViewModelMapDefinition.cs | 83 +++ src/Umbraco.Cms.Api.Management/OpenApi.json | 519 +++++++++++++++++- .../ViewModels/Package/PackageCreateModel.cs | 5 + .../Package/PackageDefinitionViewModel.cs | 17 + .../PackageMigrationStatusViewModel.cs | 14 + .../ViewModels/Package/PackageModelBase.cs | 74 +++ .../ViewModels/Package/PackageUpdateModel.cs | 12 + .../CompatibilitySuppressions.xml | 21 + .../Packaging/IPackageDefinitionRepository.cs | 2 + .../Packaging/PackagesRepository.cs | 3 + .../Services/IPackagingService.cs | 41 ++ .../PackageMigrationOperationStatus.cs | 8 + .../OperationStatus/PackageOperationStatus.cs | 9 + .../Install/PackageMigrationRunner.cs | 23 + .../Migrations/MigrationPlanExecutor.cs | 3 +- .../Migrations/Upgrade/UmbracoPlan.cs | 1 + .../UpdateDefaultGuidsOfCreatedPackages.cs | 44 ++ .../CreatedPackageSchemaRepository.cs | 61 +- .../Services/Implement/PackagingService.cs | 173 ++++-- 35 files changed, 1539 insertions(+), 51 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/AllMigrationStatusPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/Created/AllCreatedPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/Created/ByKeyCreatedPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreateCreatedPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreatedPackageControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DeleteCreatedPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DownloadCreatedPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/Created/UpdateCreatedPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/PackageControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Package/RunMigrationPackageController.cs create mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/PackageBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/IPackageDefinitionFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/PackageDefinitionFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Mapping/Package/PackageViewModelMapDefinition.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageCreateModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageDefinitionViewModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageMigrationStatusViewModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageModelBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageUpdateModel.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/PackageMigrationOperationStatus.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/PackageOperationStatus.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/UpdateDefaultGuidsOfCreatedPackages.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs index 02e49021d2..5f417acf76 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/DictionaryControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Routing; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/LogViewer/LogLevelCountLogViewerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/LogViewer/LogLevelCountLogViewerController.cs index 145bcd1eb8..942deffbbf 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/LogViewer/LogLevelCountLogViewerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/LogViewer/LogLevelCountLogViewerController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.LogViewer; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Logging.Viewer; @@ -29,7 +30,7 @@ public class LogLevelCountLogViewerController : LogViewerControllerBase [HttpGet("level-count")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(LogLevelCountsViewModel), StatusCodes.Status200OK)] public async Task LogLevelCounts(DateTime? startDate = null, DateTime? endDate = null) { Attempt logLevelCountsAttempt = diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/AllMigrationStatusPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/AllMigrationStatusPackageController.cs new file mode 100644 index 0000000000..afb7ffda69 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/AllMigrationStatusPackageController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Services; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Controllers.Package; + +public class AllMigrationStatusPackageController : PackageControllerBase +{ + private readonly IPackagingService _packagingService; + private readonly IUmbracoMapper _umbracoMapper; + + public AllMigrationStatusPackageController(IPackagingService packagingService, IUmbracoMapper umbracoMapper) + { + _packagingService = packagingService; + _umbracoMapper = umbracoMapper; + } + + /// + /// Gets a paginated list of the migration status of each installed package. + /// + /// The amount of items to skip. + /// The amount of items to take. + /// The paged result of the installed packages migration status. + [HttpGet("migration-status")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> AllMigrationStatuses(int skip = 0, int take = 100) + { + PagedModel migrationPlans = await _packagingService.GetInstalledPackagesFromMigrationPlansAsync(skip, take); + + IEnumerable viewModels = _umbracoMapper.MapEnumerable(migrationPlans.Items); + + return Ok(new PagedViewModel() + { + Total = migrationPlans.Total, + Items = viewModels, + }); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/AllCreatedPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/AllCreatedPackageController.cs new file mode 100644 index 0000000000..2500071124 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/AllCreatedPackageController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Package.Created; + +public class AllCreatedPackageController : CreatedPackageControllerBase +{ + private readonly IPackagingService _packagingService; + private readonly IUmbracoMapper _umbracoMapper; + + public AllCreatedPackageController(IPackagingService packagingService, IUmbracoMapper umbracoMapper) + { + _packagingService = packagingService; + _umbracoMapper = umbracoMapper; + } + + /// + /// Gets a paginated list of all created packages. + /// + /// The amount of items to skip. + /// The amount of items to take. + /// The paged result of the created packages. + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task>> All(int skip = 0, int take = 100) + { + IEnumerable createdPackages = _packagingService + .GetAllCreatedPackages() + .WhereNotNull() + .Skip(skip) + .Take(take); + + return await Task.FromResult(Ok(_umbracoMapper.Map>(createdPackages))); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/ByKeyCreatedPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/ByKeyCreatedPackageController.cs new file mode 100644 index 0000000000..5d53ef3548 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/ByKeyCreatedPackageController.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Package.Created; + +public class ByKeyCreatedPackageController : CreatedPackageControllerBase +{ + private readonly IPackagingService _packagingService; + private readonly IUmbracoMapper _umbracoMapper; + + public ByKeyCreatedPackageController(IPackagingService packagingService, IUmbracoMapper umbracoMapper) + { + _packagingService = packagingService; + _umbracoMapper = umbracoMapper; + } + + /// + /// Gets a package by key. + /// + /// The key of the package. + /// The package or not found result. + [HttpGet("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(PackageDefinitionViewModel), StatusCodes.Status200OK)] + public async Task> ByKey(Guid key) + { + PackageDefinition? package = await _packagingService.GetCreatedPackageByKeyAsync(key); + + if (package is null) + { + return NotFound(); + } + + return Ok(_umbracoMapper.Map(package)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreateCreatedPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreateCreatedPackageController.cs new file mode 100644 index 0000000000..9edeb024a9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreateCreatedPackageController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Package.Created; + +public class CreateCreatedPackageController : CreatedPackageControllerBase +{ + private readonly IPackagingService _packagingService; + private readonly IPackageDefinitionFactory _packageDefinitionFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public CreateCreatedPackageController( + IPackagingService packagingService, + IPackageDefinitionFactory packageDefinitionFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _packagingService = packagingService; + _packageDefinitionFactory = packageDefinitionFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + /// + /// Creates a package. + /// + /// The model containing the data for a new package. + /// The created package. + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task Create(PackageCreateModel packageCreateModel) + { + PackageDefinition packageDefinition = _packageDefinitionFactory.CreatePackageDefinition(packageCreateModel); + + Attempt result = await _packagingService.CreateCreatedPackageAsync(packageDefinition, CurrentUserId(_backOfficeSecurityAccessor)); + + return result.Success + ? CreatedAtAction(controller => nameof(controller.ByKey), packageDefinition.PackageId) + : PackageOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreatedPackageControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreatedPackageControllerBase.cs new file mode 100644 index 0000000000..a1d9bd9cd9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/CreatedPackageControllerBase.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Routing; + +namespace Umbraco.Cms.Api.Management.Controllers.Package.Created; + +[ApiController] +[VersionedApiBackOfficeRoute("package/created")] +[ApiExplorerSettings(GroupName = "Package")] +[ApiVersion("1.0")] +public class CreatedPackageControllerBase : PackageControllerBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DeleteCreatedPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DeleteCreatedPackageController.cs new file mode 100644 index 0000000000..cbc71b7d20 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DeleteCreatedPackageController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Package.Created; + +public class DeleteCreatedPackageController : CreatedPackageControllerBase +{ + private readonly IPackagingService _packagingService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public DeleteCreatedPackageController(IPackagingService packagingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _packagingService = packagingService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + /// + /// Deletes a package with a given key. + /// + /// The key of the package. + /// The result of the deletion. + [HttpDelete("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Delete(Guid key) + { + Attempt result = + await _packagingService.DeleteCreatedPackageAsync(key, CurrentUserId(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : PackageOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DownloadCreatedPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DownloadCreatedPackageController.cs new file mode 100644 index 0000000000..1c13d0596b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/DownloadCreatedPackageController.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Mime; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Package.Created; + +public class DownloadCreatedPackageController : CreatedPackageControllerBase +{ + private readonly IPackagingService _packagingService; + + public DownloadCreatedPackageController(IPackagingService packagingService) => _packagingService = packagingService; + + /// + /// Downloads a package XML or ZIP file. + /// + /// The key of the package. + /// The XML or ZIP file of the package or not found result. + [HttpGet("{key:guid}/download")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + public async Task Download(Guid key) + { + PackageDefinition? package = await _packagingService.GetCreatedPackageByKeyAsync(key); + + if (package is null) + { + return NotFound(); + } + + Stream? fileStream = _packagingService.GetPackageFileStream(package); + if (fileStream is null) + { + return NotFound(); + } + + var fileName = Path.GetFileName(package.PackagePath); + Encoding encoding = Encoding.UTF8; + + var contentDisposition = new ContentDisposition + { + FileName = WebUtility.UrlEncode(fileName), + DispositionType = DispositionTypeNames.Attachment + }; + + Response.Headers.Add("Content-Disposition", contentDisposition.ToString()); + + var result = new FileStreamResult( + fileStream, + new MediaTypeHeaderValue(MediaTypeNames.Application.Octet) { Charset = encoding.WebName }); + + return result; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/UpdateCreatedPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/UpdateCreatedPackageController.cs new file mode 100644 index 0000000000..895d889193 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/Created/UpdateCreatedPackageController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Package.Created; + +public class UpdateCreatedPackageController : CreatedPackageControllerBase +{ + private readonly IPackagingService _packagingService; + private readonly IUmbracoMapper _umbracoMapper; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UpdateCreatedPackageController( + IPackagingService packagingService, + IUmbracoMapper umbracoMapper, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _packagingService = packagingService; + _umbracoMapper = umbracoMapper; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + /// + /// Updates a package. + /// + /// The key of the package. + /// The model containing the data for updating a package. + /// The created package. + [HttpPut("{key:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Update(Guid key, PackageUpdateModel packageUpdateModel) + { + PackageDefinition? package = await _packagingService.GetCreatedPackageByKeyAsync(key); + + if (package is null) + { + return NotFound(); + } + + // Macros are not included! + PackageDefinition packageDefinition = _umbracoMapper.Map(packageUpdateModel, package); + + Attempt result = await _packagingService.UpdateCreatedPackageAsync(packageDefinition, CurrentUserId(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : PackageOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/PackageControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/PackageControllerBase.cs new file mode 100644 index 0000000000..0ec683d7b3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/PackageControllerBase.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Package; + +[ApiController] +[VersionedApiBackOfficeRoute("package")] +[ApiExplorerSettings(GroupName = "Package")] +[ApiVersion("1.0")] +public abstract class PackageControllerBase : ManagementApiControllerBase +{ + protected IActionResult PackageOperationStatusResult(PackageOperationStatus status) => + status switch + { + PackageOperationStatus.NotFound => NotFound("The package could not be found"), + PackageOperationStatus.DuplicateItemName => Conflict(new ProblemDetailsBuilder() + .WithTitle("Duplicate package name") + .WithDetail("Another package already exists with the attempted name.") + .Build()), + PackageOperationStatus.InvalidName => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid package name") + .WithDetail("The attempted package name does not represent a valid name for a package.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown package operation status") + }; + + protected IActionResult PackageMigrationOperationStatusResult(PackageMigrationOperationStatus status) => + status switch + { + PackageMigrationOperationStatus.NotFound => NotFound("No migration plans were found for that package"), + PackageMigrationOperationStatus.CancelledByFailedMigration => Conflict(new ProblemDetailsBuilder() + .WithTitle("Package migration failed") + .WithDetail("Check log for full details about the failed migration.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown package migration operation status") + }; +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Package/RunMigrationPackageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Package/RunMigrationPackageController.cs new file mode 100644 index 0000000000..1c3fc6fd31 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Package/RunMigrationPackageController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Install; + +namespace Umbraco.Cms.Api.Management.Controllers.Package; + +public class RunMigrationPackageController : PackageControllerBase +{ + private readonly PackageMigrationRunner _packageMigrationRunner; + + public RunMigrationPackageController(PackageMigrationRunner packageMigrationRunner) + => _packageMigrationRunner = packageMigrationRunner; + + /// + /// Runs all migration plans for a package with a given name if any are pending. + /// + /// The name of the package. + /// The result of running the package migrations. + [HttpPost("{name}/run-migration")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RunMigrations(string name) + { + Attempt result = await _packageMigrationRunner.RunPendingPackageMigrations(name); + + return result.Success + ? Ok() + : PackageMigrationOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/PackageBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/PackageBuilderExtensions.cs new file mode 100644 index 0000000000..3fad1574f4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/PackageBuilderExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Mapping.Package; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class PackageBuilderExtensions +{ + internal static IUmbracoBuilder AddPackages(this IUmbracoBuilder builder) + { + builder.Services.AddTransient(); + + builder.WithCollectionBuilder().Add(); + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IPackageDefinitionFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IPackageDefinitionFactory.cs new file mode 100644 index 0000000000..e6c207004a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IPackageDefinitionFactory.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core.Packaging; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IPackageDefinitionFactory +{ + PackageDefinition CreatePackageDefinition(PackageCreateModel packageCreateModel); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/PackageDefinitionFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/PackageDefinitionFactory.cs new file mode 100644 index 0000000000..cce54c35d5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/PackageDefinitionFactory.cs @@ -0,0 +1,25 @@ +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Packaging; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal class PackageDefinitionFactory : IPackageDefinitionFactory +{ + private readonly IUmbracoMapper _umbracoMapper; + + public PackageDefinitionFactory(IUmbracoMapper umbracoMapper) => _umbracoMapper = umbracoMapper; + + public PackageDefinition CreatePackageDefinition(PackageCreateModel packageCreateModel) + { + // Macros are not included! + PackageDefinition packageDefinition = _umbracoMapper.Map(packageCreateModel)!; + + // Temp Id, PackageId and PackagePath for the newly created package + packageDefinition.Id = 0; + packageDefinition.PackageId = Guid.Empty; + packageDefinition.PackagePath = string.Empty; + + return packageDefinition; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index 1e58bac8b8..1123847943 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -39,6 +39,7 @@ public class ManagementApiComposer : IComposer .AddTemplates() .AddLogViewer() .AddUserGroups() + .AddPackages() .AddBackOfficeAuthentication() .AddApiVersioning() .AddSwaggerGen(); diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Package/PackageViewModelMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Package/PackageViewModelMapDefinition.cs new file mode 100644 index 0000000000..873848c356 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Package/PackageViewModelMapDefinition.cs @@ -0,0 +1,83 @@ +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Package; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Packaging; + +namespace Umbraco.Cms.Api.Management.Mapping.Package; + +public class PackageViewModelMapDefinition : IMapDefinition +{ + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((_, _) => new PackageDefinition(), Map); + mapper.Define( + (_, _) => new PackageDefinitionViewModel + { + Name = string.Empty, + PackagePath = string.Empty + }, + Map); + mapper.Define((_, _) => new PackageMigrationStatusViewModel { PackageName = string.Empty }, Map); + mapper.Define, PagedViewModel>((_, _) => new PagedViewModel(), Map); + } + + // Umbraco.Code.MapAll -Id -PackageId -PackagePath -Macros + private static void Map(PackageModelBase source, PackageDefinition target, MapperContext context) + { + target.Name = source.Name; + target.ContentLoadChildNodes = source.ContentLoadChildNodes; + target.ContentNodeId = source.ContentNodeId; + target.Languages = source.Languages; + target.DictionaryItems = source.DictionaryItems; + target.Templates = source.Templates; + target.PartialViews = source.PartialViews; + target.DocumentTypes = source.DocumentTypes; + target.MediaTypes = source.MediaTypes; + target.Stylesheets = source.Stylesheets; + target.Scripts = source.Scripts; + target.DataTypes = source.DataTypes; + target.MediaUdis = source.MediaKeys.Select(x => new GuidUdi(Constants.UdiEntityType.Media, x)).ToList(); + target.MediaLoadChildNodes = source.MediaLoadChildNodes; + } + + // Umbraco.Code.MapAll + private static void Map(PackageDefinition source, PackageDefinitionViewModel target, MapperContext context) + { + target.Key = source.PackageId; + target.Name = source.Name; + target.PackagePath = source.PackagePath; + target.ContentNodeId = source.ContentNodeId; + target.ContentLoadChildNodes = source.ContentLoadChildNodes; + target.MediaKeys = source.MediaUdis.Select(x => x.Guid).ToList(); + target.MediaLoadChildNodes = source.MediaLoadChildNodes; + target.DocumentTypes = source.DocumentTypes; + target.MediaTypes = source.MediaTypes; + target.DataTypes = source.DataTypes; + target.Templates = source.Templates; + target.PartialViews = source.PartialViews; + target.Stylesheets = source.Stylesheets; + target.Scripts = source.Scripts; + target.Languages = source.Languages; + target.DictionaryItems = source.DictionaryItems; + } + + // Umbraco.Code.MapAll + private static void Map(InstalledPackage source, PackageMigrationStatusViewModel target, MapperContext context) + { + if (source.PackageName is not null) + { + target.PackageName = source.PackageName; + } + + target.HasPendingMigrations = source.HasPendingMigrations; + } + + // Umbraco.Code.MapAll + private static void Map(IEnumerable source, PagedViewModel target, MapperContext context) + { + PackageDefinition[] definitions = source.ToArray(); + target.Items = context.MapEnumerable(definitions); + target.Total = definitions.Length; + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index ab53d86a16..52824318c7 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -2668,7 +2668,18 @@ } }, "200": { - "description": "Success" + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/LogLevelCountsModel" + } + ] + } + } + } } } } @@ -3662,6 +3673,305 @@ } } }, + "/umbraco/management/api/v1/package/{name}/run-migration": { + "post": { + "tags": [ + "Package" + ], + "operationId": "PostPackageByNameRunMigration", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "404": { + "description": "Not Found" + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "200": { + "description": "Success" + } + } + } + }, + "/umbraco/management/api/v1/package/created": { + "get": { + "tags": [ + "Package" + ], + "operationId": "GetPackageCreated", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedPackageDefinitionModel" + } + } + } + } + } + }, + "post": { + "tags": [ + "Package" + ], + "operationId": "PostPackageCreated", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PackageCreateModel" + } + ] + } + } + } + }, + "responses": { + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/package/created/{key}": { + "get": { + "tags": [ + "Package" + ], + "operationId": "GetPackageCreatedByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Not Found" + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PackageDefinitionModel" + } + ] + } + } + } + } + } + }, + "delete": { + "tags": [ + "Package" + ], + "operationId": "DeletePackageCreatedByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Not Found" + }, + "200": { + "description": "Success" + } + } + }, + "put": { + "tags": [ + "Package" + ], + "operationId": "PutPackageCreatedByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PackageUpdateModel" + } + ] + } + } + } + }, + "responses": { + "404": { + "description": "Not Found" + }, + "200": { + "description": "Success" + } + } + } + }, + "/umbraco/management/api/v1/package/created/{key}/download": { + "get": { + "tags": [ + "Package" + ], + "operationId": "GetPackageCreatedByKeyDownload", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "404": { + "description": "Not Found" + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/package/migration-status": { + "get": { + "tags": [ + "Package" + ], + "operationId": "GetPackageMigrationStatus", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedPackageMigrationStatusModel" + } + } + } + } + } + } + }, "/umbraco/management/api/v1/tree/partial-view/children": { "get": { "tags": [ @@ -7177,6 +7487,32 @@ ], "additionalProperties": false }, + "LogLevelCountsModel": { + "type": "object", + "properties": { + "information": { + "type": "integer", + "format": "int32" + }, + "debug": { + "type": "integer", + "format": "int32" + }, + "warning": { + "type": "integer", + "format": "int32" + }, + "error": { + "type": "integer", + "format": "int32" + }, + "fatal": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "LogLevelModel": { "enum": [ "Verbose", @@ -7345,6 +7681,139 @@ "type": "integer", "format": "int32" }, + "PackageCreateModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PackageModelBaseModel" + } + ], + "additionalProperties": false + }, + "PackageDefinitionModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PackageModelBaseModel" + } + ], + "properties": { + "key": { + "type": "string", + "format": "uuid" + }, + "packagePath": { + "type": "string" + } + }, + "additionalProperties": false + }, + "PackageMigrationStatusModel": { + "type": "object", + "properties": { + "packageName": { + "type": "string" + }, + "hasPendingMigrations": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "PackageModelBaseModel": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "contentNodeId": { + "type": "string", + "nullable": true + }, + "contentLoadChildNodes": { + "type": "boolean" + }, + "mediaKeys": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "mediaLoadChildNodes": { + "type": "boolean" + }, + "documentTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "mediaTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "templates": { + "type": "array", + "items": { + "type": "string" + } + }, + "partialViews": { + "type": "array", + "items": { + "type": "string" + } + }, + "stylesheets": { + "type": "array", + "items": { + "type": "string" + } + }, + "scripts": { + "type": "array", + "items": { + "type": "string" + } + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "dictionaryItems": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "PackageUpdateModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PackageModelBaseModel" + } + ], + "properties": { + "packagePath": { + "type": "string" + } + }, + "additionalProperties": false + }, "PagedAuditLogResponseModel": { "required": [ "items", @@ -7801,6 +8270,54 @@ }, "additionalProperties": false }, + "PagedPackageDefinitionModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/PackageDefinitionModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "PagedPackageMigrationStatusModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/PackageMigrationStatusModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedRecycleBinItemModel": { "required": [ "items", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageCreateModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageCreateModel.cs new file mode 100644 index 0000000000..e4ece8fe4f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageCreateModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Package; + +public class PackageCreateModel : PackageModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageDefinitionViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageDefinitionViewModel.cs new file mode 100644 index 0000000000..7e51bd801b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageDefinitionViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Api.Management.ViewModels.Package; + +public class PackageDefinitionViewModel : PackageModelBase +{ + /// + /// Gets or sets the key. + /// + public Guid Key { get; set; } + + /// + /// Gets or sets the full path to the package's XML file. + /// + [ReadOnly(true)] + public required string PackagePath { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageMigrationStatusViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageMigrationStatusViewModel.cs new file mode 100644 index 0000000000..5b4c7d8ece --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageMigrationStatusViewModel.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Package; + +public class PackageMigrationStatusViewModel +{ + /// + /// Gets or sets the name of the package. + /// + public required string PackageName { get; set; } + + /// + /// Gets or sets a value indicating whether the package has any pending migrations to run. + /// + public bool HasPendingMigrations { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageModelBase.cs new file mode 100644 index 0000000000..c3c1fa9460 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageModelBase.cs @@ -0,0 +1,74 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Package; + +public class PackageModelBase +{ + /// + /// Gets or sets the name. + /// + public required string Name { get; set; } + + /// + /// Gets or sets the id of the selected content node. + /// + public string? ContentNodeId { get; set; } + + /// + /// Gets or sets a value indicating whether to load all child nodes of the selected content node. + /// + public bool ContentLoadChildNodes { get; set; } + + /// + /// Gets or sets the list of media keys for the selected media items. + /// + public IList MediaKeys { get; set; } = new List(); + + /// + /// Gets or sets a value indicating whether to load all child nodes of the selected media items. + /// + public bool MediaLoadChildNodes { get; set; } + + /// + /// Gets or sets the list of ids for the selected document types. + /// + public IList DocumentTypes { get; set; } = new List(); + + /// + /// Gets or sets the list of ids for the selected media types. + /// + public IList MediaTypes { get; set; } = new List(); + + /// + /// Gets or sets the list of ids for the selected data types. + /// + public IList DataTypes { get; set; } = new List(); + + /// + /// Gets or sets the list of ids for the selected templates. + /// + public IList Templates { get; set; } = new List(); + + /// + /// Gets or sets the list of relative paths for the selected partial views. + /// + public IList PartialViews { get; set; } = new List(); + + /// + /// Gets or sets the list of names for the selected stylesheets. + /// + public IList Stylesheets { get; set; } = new List(); + + /// + /// Gets or sets the list of names for the selected scripts. + /// + public IList Scripts { get; set; } = new List(); + + /// + /// Gets or sets the list of ids for the selected languages. + /// + public IList Languages { get; set; } = new List(); + + /// + /// Gets or sets the list of ids for the selected dictionary items. + /// + public IList DictionaryItems { get; set; } = new List(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageUpdateModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageUpdateModel.cs new file mode 100644 index 0000000000..548f22fd8c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Package/PackageUpdateModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Api.Management.ViewModels.Package; + +public class PackageUpdateModel : PackageModelBase +{ + /// + /// Gets or sets the full path to the package's XML file. + /// + [ReadOnly(true)] + public required string PackagePath { get; set; } +} diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml index 213a91ab24..e4376074a2 100644 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -420,6 +420,13 @@ lib/net7.0/Umbraco.Core.dll true + + CP0006 + M:Umbraco.Cms.Core.Packaging.IPackageDefinitionRepository.GetByKey(System.Guid) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0006 M:Umbraco.Cms.Core.PropertyEditors.IConfigurationEditor.FromConfigurationEditor(System.Collections.Generic.IDictionary{System.String,System.Object}) @@ -581,6 +588,20 @@ lib/net7.0/Umbraco.Core.dll true + + CP0006 + M:Umbraco.Cms.Core.Services.IPackagingService.GetCreatedPackageByKey(System.Guid) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IPackagingService.GetInstalledPackagesFromMigrationPlans + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0006 P:Umbraco.Cms.Core.Models.IDataType.ConfigurationData diff --git a/src/Umbraco.Core/Packaging/IPackageDefinitionRepository.cs b/src/Umbraco.Core/Packaging/IPackageDefinitionRepository.cs index b66f4884af..0764152515 100644 --- a/src/Umbraco.Core/Packaging/IPackageDefinitionRepository.cs +++ b/src/Umbraco.Core/Packaging/IPackageDefinitionRepository.cs @@ -9,6 +9,8 @@ public interface IPackageDefinitionRepository PackageDefinition? GetById(int id); + PackageDefinition? GetByKey(Guid key); + void Delete(int id); /// diff --git a/src/Umbraco.Core/Packaging/PackagesRepository.cs b/src/Umbraco.Core/Packaging/PackagesRepository.cs index a5982aef7e..a130712c07 100644 --- a/src/Umbraco.Core/Packaging/PackagesRepository.cs +++ b/src/Umbraco.Core/Packaging/PackagesRepository.cs @@ -129,6 +129,9 @@ public class PackagesRepository : ICreatedPackagesRepository return packageXml == null ? null : _parser.ToPackageDefinition(packageXml); } + // Default implementation as the class is obsolete + public PackageDefinition? GetByKey(Guid key) => null; + public void Delete(int id) { XDocument packagesXml = EnsureStorage(out var packagesFile); diff --git a/src/Umbraco.Core/Services/IPackagingService.cs b/src/Umbraco.Core/Services/IPackagingService.cs index 40f39628be..4c3849a789 100644 --- a/src/Umbraco.Core/Services/IPackagingService.cs +++ b/src/Umbraco.Core/Services/IPackagingService.cs @@ -1,6 +1,8 @@ using System.Xml.Linq; using Umbraco.Cms.Core.Models.Packaging; using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.New.Cms.Core.Models; namespace Umbraco.Cms.Core.Services; @@ -28,6 +30,11 @@ public interface IPackagingService : IService /// IEnumerable GetAllInstalledPackages(); + /// + /// Returns installed packages collected from the package migration plans. + /// + Task> GetInstalledPackagesFromMigrationPlansAsync(int skip, int take); + InstalledPackage? GetInstalledPackageByName(string packageName); /// @@ -43,17 +50,51 @@ public interface IPackagingService : IService /// PackageDefinition? GetCreatedPackageById(int id); + /// + /// Returns a created package by key. + /// + /// The key of the package. + /// The package or null if the package was not found. + Task GetCreatedPackageByKeyAsync(Guid key); + + [Obsolete("Use DeleteCreatedPackageAsync instead. Scheduled for removal in Umbraco 15.")] void DeleteCreatedPackage(int id, int userId = Constants.Security.SuperUserId); + /// + /// Deletes a created package by key. + /// + /// The key of the package. + /// Optional id of the user deleting the package. + Task> DeleteCreatedPackageAsync(Guid key, int userId = Constants.Security.SuperUserId); + /// /// Persists a package definition to storage /// /// + [Obsolete("Use CreateCreatedPackageAsync or UpdateCreatedPackageAsync instead. Scheduled for removal in Umbraco 15.")] bool SaveCreatedPackage(PackageDefinition definition); + /// + /// Creates a new package. + /// + /// model for the package to create. + Task> CreateCreatedPackageAsync(PackageDefinition package, int userId); + + /// + /// Updates a created package. + /// + /// model for the package to update. + Task> UpdateCreatedPackageAsync(PackageDefinition package, int userId); + /// /// Creates the package file and returns it's physical path /// /// string ExportCreatedPackage(PackageDefinition definition); + + /// + /// Gets the package file stream. + /// + /// + Stream? GetPackageFileStream(PackageDefinition definition) => null; } diff --git a/src/Umbraco.Core/Services/OperationStatus/PackageMigrationOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/PackageMigrationOperationStatus.cs new file mode 100644 index 0000000000..10806fe378 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/PackageMigrationOperationStatus.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum PackageMigrationOperationStatus +{ + Success, + NotFound, + CancelledByFailedMigration +} diff --git a/src/Umbraco.Core/Services/OperationStatus/PackageOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/PackageOperationStatus.cs new file mode 100644 index 0000000000..6b6fcb54a6 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/PackageOperationStatus.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum PackageOperationStatus +{ + Success, + NotFound, + DuplicateItemName, + InvalidName +} diff --git a/src/Umbraco.Infrastructure/Install/PackageMigrationRunner.cs b/src/Umbraco.Infrastructure/Install/PackageMigrationRunner.cs index a6741b74fc..4f8e393b6a 100644 --- a/src/Umbraco.Infrastructure/Install/PackageMigrationRunner.cs +++ b/src/Umbraco.Infrastructure/Install/PackageMigrationRunner.cs @@ -12,6 +12,7 @@ using Umbraco.Cms.Infrastructure.Migrations; using Umbraco.Cms.Infrastructure.Migrations.Notifications; using Umbraco.Cms.Infrastructure.Migrations.Upgrade; using Umbraco.Extensions; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Infrastructure.Install; @@ -89,6 +90,28 @@ public class PackageMigrationRunner return RunPackagePlans(packagePlans); } + /// + /// Checks if all executed package migrations succeeded for a package. + /// + public async Task> RunPendingPackageMigrations(string packageName) + { + // Check if there are any migrations + if (_packageMigrationPlans.ContainsKey(packageName) == false) + { + return Attempt.FailWithStatus(PackageMigrationOperationStatus.NotFound, false); + } + + // Run the migrations + IEnumerable executedMigrationPlans = RunPackageMigrationsIfPending(packageName); + + if (executedMigrationPlans.Any(plan => plan.Successful == false)) + { + return Attempt.FailWithStatus(PackageMigrationOperationStatus.CancelledByFailedMigration, false); + } + + return Attempt.SucceedWithStatus(PackageMigrationOperationStatus.Success, true); + } + /// /// Runs the all specified package migration plans and publishes a /// if all are successful. diff --git a/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs b/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs index 8c7e1c2f9a..ebe22d518e 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs @@ -137,7 +137,8 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor } catch (Exception exception) { - _logger.LogError("Plan failed at step {TargetState}", transition.TargetState); + _logger.LogError(exception, "Plan {PlanName} failed at step {TargetState}", plan.Name, transition.TargetState); + // We have to always return something, so whatever running this has a chance to save the state we got to. return new ExecutedMigrationPlan { diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 9dd79a9f2f..b5df9be73e 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -85,5 +85,6 @@ public class UmbracoPlan : MigrationPlan To("{69E12556-D9B3-493A-8E8A-65EC89FB658D}"); To("{F2B16CD4-F181-4BEE-81C9-11CF384E6025}"); To("{A8E01644-9F2E-4988-8341-587EF5B7EA69}"); + To("{E073DBC0-9E8E-4C92-8210-9CB18364F46E}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/UpdateDefaultGuidsOfCreatedPackages.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/UpdateDefaultGuidsOfCreatedPackages.cs new file mode 100644 index 0000000000..93ec0c4b62 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/UpdateDefaultGuidsOfCreatedPackages.cs @@ -0,0 +1,44 @@ +using System.Xml.Linq; +using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class UpdateDefaultGuidsOfCreatedPackages : MigrationBase +{ + private readonly PackageDefinitionXmlParser _xmlParser; + + public UpdateDefaultGuidsOfCreatedPackages(IMigrationContext context) + : base(context) + { + _xmlParser = new PackageDefinitionXmlParser(); + } + + protected override void Migrate() + { + IEnumerable createdPackages = Database.Fetch(); + + foreach (CreatedPackageSchemaDto package in createdPackages) + { + if (package.PackageId != default) + { + continue; + } + + var guid = Guid.NewGuid(); + package.PackageId = guid; + + var packageDefinition = _xmlParser.ToPackageDefinition(XElement.Parse(package.Value)); + + if (packageDefinition is not null) + { + packageDefinition.PackageId = guid; + + // Update the package XML value with the correct GUID + package.Value = _xmlParser.ToXml(packageDefinition).ToString(); + } + + Database.Update(package); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/CreatedPackageSchemaRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/CreatedPackageSchemaRepository.cs index 9f921266ca..79a65479e2 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/CreatedPackageSchemaRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/CreatedPackageSchemaRepository.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Data; using System.Globalization; using System.IO.Compression; using System.Xml.Linq; @@ -88,12 +89,9 @@ public class CreatedPackageSchemaRepository : ICreatedPackagesRepository List xmlSchemas = _umbracoDatabase.Fetch(query); foreach (CreatedPackageSchemaDto packageSchema in xmlSchemas) { - var packageDefinition = _xmlParser.ToPackageDefinition(XElement.Parse(packageSchema.Value)); + PackageDefinition? packageDefinition = CreatePackageDefinitionFromSchema(packageSchema); if (packageDefinition is not null) { - packageDefinition.Id = packageSchema.Id; - packageDefinition.Name = packageSchema.Name; - packageDefinition.PackageId = packageSchema.PackageId; packageDefinitions.Add(packageDefinition); } } @@ -107,6 +105,7 @@ public class CreatedPackageSchemaRepository : ICreatedPackagesRepository .Select() .From() .Where(x => x.Id == id); + List schemaDtos = _umbracoDatabase.Fetch(query); if (schemaDtos.IsCollectionEmpty()) @@ -114,16 +113,24 @@ public class CreatedPackageSchemaRepository : ICreatedPackagesRepository return null; } - CreatedPackageSchemaDto packageSchema = schemaDtos.First(); - var packageDefinition = _xmlParser.ToPackageDefinition(XElement.Parse(packageSchema.Value)); - if (packageDefinition is not null) + return CreatePackageDefinitionFromSchema(schemaDtos.First()); + } + + public PackageDefinition? GetByKey(Guid key) + { + Sql query = new Sql(_umbracoDatabase!.SqlContext) + .Select() + .From() + .Where(x => x.PackageId == key); + + List schemaDtos = _umbracoDatabase.Fetch(query); + + if (schemaDtos.IsCollectionEmpty()) { - packageDefinition.Id = packageSchema.Id; - packageDefinition.Name = packageSchema.Name; - packageDefinition.PackageId = packageSchema.PackageId; + return null; } - return packageDefinition; + return CreatePackageDefinitionFromSchema(schemaDtos.First()); } public void Delete(int id) @@ -149,7 +156,7 @@ public class CreatedPackageSchemaRepository : ICreatedPackagesRepository throw new NullReferenceException("PackageDefinition cannot be null when saving"); } - if (string.IsNullOrEmpty(definition.Name) || definition.PackagePath == null) + if (string.IsNullOrEmpty(definition.Name)) { return false; } @@ -159,6 +166,17 @@ public class CreatedPackageSchemaRepository : ICreatedPackagesRepository if (definition.Id == default) { + Sql query = new Sql(_umbracoDatabase!.SqlContext) + .SelectCount() + .From() + .Where(x => x.Name == definition.Name); + var exists = _umbracoDatabase.ExecuteScalar(query); + + if (exists > 0) + { + return false; + } + // Create dto from definition var dto = new CreatedPackageSchemaDto { @@ -173,6 +191,11 @@ public class CreatedPackageSchemaRepository : ICreatedPackagesRepository definition.Id = dto.Id; } + if (definition.PackageId == default) + { + definition.PackageId = Guid.NewGuid(); + } + // Save snapshot locally, we do this to the updated packagePath ExportPackage(definition); @@ -749,4 +772,18 @@ public class CreatedPackageSchemaRepository : ICreatedPackagesRepository mediaTypes.Add(mediaType); } } + + private PackageDefinition? CreatePackageDefinitionFromSchema(CreatedPackageSchemaDto packageSchema) + { + var packageDefinition = _xmlParser.ToPackageDefinition(XElement.Parse(packageSchema.Value)); + + if (packageDefinition is not null) + { + packageDefinition.Id = packageSchema.Id; + packageDefinition.Name = packageSchema.Name; + packageDefinition.PackageId = packageSchema.PackageId; + } + + return packageDefinition; + } } diff --git a/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs b/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs index a0330d75fd..be3fe9d629 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs @@ -1,11 +1,17 @@ using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Manifest; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Packaging; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Packaging; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; +using Umbraco.New.Cms.Core.Models; using File = System.IO.File; namespace Umbraco.Cms.Core.Services.Implement; @@ -23,6 +29,7 @@ public class PackagingService : IPackagingService private readonly IManifestParser _manifestParser; private readonly IPackageInstallation _packageInstallation; private readonly PackageMigrationPlanCollection _packageMigrationPlans; + private readonly IHostEnvironment _hostEnvironment; public PackagingService( IAuditService auditService, @@ -31,7 +38,8 @@ public class PackagingService : IPackagingService IEventAggregator eventAggregator, IManifestParser manifestParser, IKeyValueService keyValueService, - PackageMigrationPlanCollection packageMigrationPlans) + PackageMigrationPlanCollection packageMigrationPlans, + IHostEnvironment hostEnvironment) { _auditService = auditService; _createdPackages = createdPackages; @@ -40,6 +48,28 @@ public class PackagingService : IPackagingService _manifestParser = manifestParser; _keyValueService = keyValueService; _packageMigrationPlans = packageMigrationPlans; + _hostEnvironment = hostEnvironment; + } + + [Obsolete("Use constructor that also takes an IHostEnvironment instead. Scheduled for removal in V15")] + public PackagingService( + IAuditService auditService, + ICreatedPackagesRepository createdPackages, + IPackageInstallation packageInstallation, + IEventAggregator eventAggregator, + IManifestParser manifestParser, + IKeyValueService keyValueService, + PackageMigrationPlanCollection packageMigrationPlans) + : this( + auditService, + createdPackages, + packageInstallation, + eventAggregator, + manifestParser, + keyValueService, + packageMigrationPlans, + StaticServiceProvider.Instance.GetRequiredService()) + { } #region Installation @@ -91,24 +121,70 @@ public class PackagingService : IPackagingService #region Created/Installed Package Repositories + [Obsolete("Use DeleteCreatedPackageAsync instead. Scheduled for removal in Umbraco 15.")] public void DeleteCreatedPackage(int id, int userId = Constants.Security.SuperUserId) { PackageDefinition? package = GetCreatedPackageById(id); + Guid key = package?.PackageId ?? Guid.Empty; + + DeleteCreatedPackageAsync(key, userId).GetAwaiter().GetResult(); + } + + /// + public async Task> DeleteCreatedPackageAsync(Guid key, int userId = Constants.Security.SuperUserId) + { + PackageDefinition? package = await GetCreatedPackageByKeyAsync(key); if (package == null) { - return; + return Attempt.FailWithStatus(PackageOperationStatus.NotFound, null); } - _auditService.Add(AuditType.PackagerUninstall, userId, -1, "Package", $"Created package '{package.Name}' deleted. Package id: {package.Id}"); - _createdPackages.Delete(id); + _auditService.Add(AuditType.Delete, userId, -1, "Package", $"Created package '{package.Name}' deleted. Package key: {key}"); + _createdPackages.Delete(package.Id); + + return Attempt.SucceedWithStatus(PackageOperationStatus.Success, package); } public IEnumerable GetAllCreatedPackages() => _createdPackages.GetAll(); public PackageDefinition? GetCreatedPackageById(int id) => _createdPackages.GetById(id); + /// + public Task GetCreatedPackageByKeyAsync(Guid key) => Task.FromResult(_createdPackages.GetByKey(key)); + + [Obsolete("Use CreateCreatedPackageAsync or UpdateCreatedPackageAsync instead. Scheduled for removal in Umbraco 15.")] public bool SaveCreatedPackage(PackageDefinition definition) => _createdPackages.SavePackage(definition); + /// + public async Task> CreateCreatedPackageAsync(PackageDefinition package, int userId) + { + if (_createdPackages.SavePackage(package) == false) + { + if (string.IsNullOrEmpty(package.Name)) + { + return Attempt.FailWithStatus(PackageOperationStatus.InvalidName, package); + } + + return Attempt.FailWithStatus(PackageOperationStatus.DuplicateItemName, package); + } + + _auditService.Add(AuditType.New, userId, -1, "Package", $"Created package '{package.Name}' created. Package key: {package.PackageId}"); + return await Task.FromResult(Attempt.SucceedWithStatus(PackageOperationStatus.Success, package)); + + } + + /// + public async Task> UpdateCreatedPackageAsync(PackageDefinition package, int userId) + { + if (_createdPackages.SavePackage(package) == false) + { + return Attempt.FailWithStatus(PackageOperationStatus.NotFound, package); + } + + _auditService.Add(AuditType.New, userId, -1, "Package", $"Created package '{package.Name}' updated. Package key: {package.PackageId}"); + return await Task.FromResult(Attempt.SucceedWithStatus(PackageOperationStatus.Success, package)); + } + public string ExportCreatedPackage(PackageDefinition definition) => _createdPackages.ExportPackage(definition); public InstalledPackage? GetInstalledPackageByName(string packageName) @@ -116,36 +192,11 @@ public class PackagingService : IPackagingService public IEnumerable GetAllInstalledPackages() { - IReadOnlyDictionary? keyValues = - _keyValueService.FindByKeyPrefix(Constants.Conventions.Migrations.KeyValuePrefix); - - var installedPackages = new Dictionary(); - - // Collect the package from the package migration plans - foreach (PackageMigrationPlan plan in _packageMigrationPlans) - { - if (!installedPackages.TryGetValue(plan.PackageName, out InstalledPackage? installedPackage)) - { - installedPackage = new InstalledPackage { PackageName = plan.PackageName }; - installedPackages.Add(plan.PackageName, installedPackage); - } - - var currentPlans = installedPackage.PackageMigrationPlans.ToList(); - if (keyValues is null || keyValues.TryGetValue( - Constants.Conventions.Migrations.KeyValuePrefix + plan.Name, - out var currentState) is false) - { - currentState = null; - } - - currentPlans.Add(new InstalledPackageMigrationPlans - { - CurrentMigrationId = currentState, - FinalMigrationId = plan.FinalState, - }); - - installedPackage.PackageMigrationPlans = currentPlans; - } + // Collect the packages from the package migration plans + var installedPackages = GetInstalledPackagesFromMigrationPlansAsync(0, int.MaxValue) + .GetAwaiter() + .GetResult() + .Items.ToDictionary(package => package.PackageName!, package => package); // PackageName cannot be null here // Collect and merge the packages from the manifests foreach (PackageManifest package in _manifestParser.GetManifests()) @@ -157,7 +208,8 @@ public class PackagingService : IPackagingService if (!installedPackages.TryGetValue(package.PackageName, out InstalledPackage? installedPackage)) { - installedPackage = new InstalledPackage { + installedPackage = new InstalledPackage + { PackageName = package.PackageName, Version = string.IsNullOrEmpty(package.Version) ? "Unknown" : package.Version, }; @@ -173,4 +225,55 @@ public class PackagingService : IPackagingService } #endregion + + /// + public async Task> GetInstalledPackagesFromMigrationPlansAsync(int skip, int take) + { + IReadOnlyDictionary? keyValues = + _keyValueService.FindByKeyPrefix(Constants.Conventions.Migrations.KeyValuePrefix); + + InstalledPackage[] installedPackages = _packageMigrationPlans + .GroupBy(plan => plan.PackageName) + .Select(group => + { + var package = new InstalledPackage + { + PackageName = group.Key, + }; + + var currentState = keyValues? + .GetValueOrDefault(Constants.Conventions.Migrations.KeyValuePrefix + package.PackageName); + + package.PackageMigrationPlans = group + .Select(plan => new InstalledPackageMigrationPlans + { + CurrentMigrationId = currentState, + FinalMigrationId = plan.FinalState, + }); + + return package; + }).ToArray(); + + return await Task.FromResult(new PagedModel + { + Total = installedPackages.Count(), + Items = installedPackages.Skip(skip).Take(take), + }); + } + + /// + public Stream? GetPackageFileStream(PackageDefinition definition) + { + // Removing the ContentRootPath from the package path as a sub path is required for GetFileInfo() + var subPath = definition.PackagePath.Replace(_hostEnvironment.ContentRootPath, string.Empty); + + IFileInfo packageFile = _hostEnvironment.ContentRootFileProvider.GetFileInfo(subPath); + + if (packageFile.Exists == false) + { + return null; + } + + return packageFile.CreateReadStream(); + } }