From fdf416550ada17765453613cd09d508e0d7f4962 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Wed, 8 Feb 2023 13:18:08 +0100 Subject: [PATCH] Refactor dictionary bulk action APIs (#13786) * Sanitize dictionary overview and export actions * Amend dictionary services with async and attempt pattern + isolate temporary file handling in its own service. * Update OpenAPI schema to match new dictionary bulk actions * Update src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs Co-authored-by: Mole * Update src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs Co-authored-by: Mole * Update src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs Co-authored-by: Mole * Update src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportModel.cs Co-authored-by: Mole * Refactor - split dictionary import handling into smaller methods * Rename view model due to name clash in new SwaggerGen + update OpenAPI JSON file --------- Co-authored-by: Mole --- .../Dictionary/AllDictionaryController.cs | 33 +----- .../Dictionary/ExportDictionaryController.cs | 20 ++-- .../Dictionary/ImportDictionaryController.cs | 67 ++++++------ .../Dictionary/UploadDictionaryController.cs | 48 ++++---- .../DictionaryBuilderExtensions.cs | 2 +- .../FileUploadBuilderExtensions.cs | 1 + .../Factories/DictionaryFactory.cs | 56 +++++----- .../Factories/IDictionaryFactory.cs | 2 +- .../Dictionary/DictionaryMapDefinition.cs | 3 +- .../Models/FormFileUploadResult.cs | 14 --- .../Models/UdtFileUpload.cs | 10 ++ src/Umbraco.Cms.Api.Management/OpenApi.json | 102 +++++++++-------- .../Services/DictionaryItemImportService.cs | 103 ++++++++++++++++++ .../Services/IDictionaryItemImportService.cs | 10 ++ .../Services/ILoadDictionaryItemService.cs | 8 -- .../Services/ITemporaryFileService.cs | 12 ++ .../Services/IUploadFileService.cs | 4 +- .../Services/LoadDictionaryItemService.cs | 55 ---------- .../DictionaryImportOperationStatus.cs | 9 ++ .../UdtFileUploadOperationStatus.cs | 8 ++ .../Services/TemporaryFileService.cs | 53 +++++++++ .../Services/UploadFileService.cs | 63 ++++------- .../Dictionary/DictionaryImportModel.cs | 8 ++ .../Dictionary/DictionaryImportViewModel.cs | 8 -- .../DictionaryItemsImportViewModel.cs | 4 +- .../Dictionary/DictionaryOverviewViewModel.cs | 4 +- .../Dictionary/DictionaryUploadViewModel.cs | 8 ++ 27 files changed, 395 insertions(+), 320 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/Models/FormFileUploadResult.cs create mode 100644 src/Umbraco.Cms.Api.Management/Models/UdtFileUpload.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/IDictionaryItemImportService.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Services/ILoadDictionaryItemService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/ITemporaryFileService.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Services/LoadDictionaryItemService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/OperationStatus/UdtFileUploadOperationStatus.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportModel.cs delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportViewModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryUploadViewModel.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/AllDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/AllDictionaryController.cs index a8abc11540..76af20b0b9 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/AllDictionaryController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/AllDictionaryController.cs @@ -22,42 +22,15 @@ public class AllDictionaryController : DictionaryControllerBase [HttpGet] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - // FIXME: make this action slim (move logic somewhere else) public async Task>> All(int skip = 0, int take = 100) { + // unfortunately we can't paginate here...we'll have to get all and paginate in memory IDictionaryItem[] items = (await _dictionaryItemService.GetDescendantsAsync(null)).ToArray(); - var list = new List(items.Length); - - // Build the proper tree structure, as we can have nested dictionary items - BuildTree(list, items); - var model = new PagedViewModel { - Total = list.Count, - Items = list.Skip(skip).Take(take), + Total = items.Length, + Items = _umbracoMapper.MapEnumerable(items.Skip(skip).Take(take)) }; return await Task.FromResult(model); } - - // recursive method to build a tree structure from the flat structure returned above - private void BuildTree(List list, IDictionaryItem[] items, int level = 0, Guid? parentId = null) - { - IDictionaryItem[] children = items.Where(t => t.ParentId == parentId).ToArray(); - if (children.Any() == false) - { - return; - } - - foreach (IDictionaryItem child in children.OrderBy(item => item.ItemKey)) - { - DictionaryOverviewViewModel? display = _umbracoMapper.Map(child); - if (display is not null) - { - display.Level = level; - list.Add(display); - } - - BuildTree(list, items, level + 1, child.Key); - } - } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ExportDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ExportDictionaryController.cs index 9821f923c3..1ffadcd06c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ExportDictionaryController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ExportDictionaryController.cs @@ -11,23 +11,22 @@ namespace Umbraco.Cms.Api.Management.Controllers.Dictionary; public class ExportDictionaryController : DictionaryControllerBase { - // FIXME: use IDictionaryItemService instead of ILocalizationService - private readonly ILocalizationService _localizationService; + private readonly IDictionaryItemService _dictionaryItemService; private readonly IEntityXmlSerializer _entityXmlSerializer; - public ExportDictionaryController(ILocalizationService localizationService, IEntityXmlSerializer entityXmlSerializer) + public ExportDictionaryController(IDictionaryItemService dictionaryItemService, IEntityXmlSerializer entityXmlSerializer) { - _localizationService = localizationService; + _dictionaryItemService = dictionaryItemService; _entityXmlSerializer = entityXmlSerializer; } - [HttpGet("export/{key:guid}")] + [HttpGet("{key:guid}/export")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(NotFoundResult), StatusCodes.Status404NotFound)] - public async Task ExportDictionary(Guid key, bool includeChildren = false) + public async Task Export(Guid key, bool includeChildren = false) { - IDictionaryItem? dictionaryItem = _localizationService.GetDictionaryItemById(key); + IDictionaryItem? dictionaryItem = await _dictionaryItemService.GetAsync(key); if (dictionaryItem is null) { return await Task.FromResult(NotFound()); @@ -35,11 +34,6 @@ public class ExportDictionaryController : DictionaryControllerBase XElement xml = _entityXmlSerializer.Serialize(dictionaryItem, includeChildren); - var fileName = $"{dictionaryItem.ItemKey}.udt"; - - // Set custom header so umbRequestHelper.downloadFile can save the correct filename - HttpContext.Response.Headers.Add("x-filename", fileName); - - return await Task.FromResult(File(Encoding.UTF8.GetBytes(xml.ToDataString()), MediaTypeNames.Application.Octet, fileName)); + return await Task.FromResult(File(Encoding.UTF8.GetBytes(xml.ToDataString()), MediaTypeNames.Application.Octet, $"{dictionaryItem.ItemKey}.udt")); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs index ef1cf4a24f..2c9e31a4cd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs @@ -1,52 +1,53 @@ -using System.Net.Mime; -using System.Text; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Services; -using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; +using Umbraco.Cms.Api.Management.Services.OperationStatus; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Api.Management.Controllers.Dictionary; public class ImportDictionaryController : DictionaryControllerBase { - private readonly IDictionaryService _dictionaryService; - private readonly IWebHostEnvironment _webHostEnvironment; - private readonly ILoadDictionaryItemService _loadDictionaryItemService; + private readonly IDictionaryItemImportService _dictionaryItemImportService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; public ImportDictionaryController( - IDictionaryService dictionaryService, - IWebHostEnvironment webHostEnvironment, - ILoadDictionaryItemService loadDictionaryItemService) + IDictionaryItemImportService dictionaryItemImportService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { - _dictionaryService = dictionaryService; - _webHostEnvironment = webHostEnvironment; - _loadDictionaryItemService = loadDictionaryItemService; + _dictionaryItemImportService = dictionaryItemImportService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } [HttpPost("import")] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(ContentResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(NotFoundResult), StatusCodes.Status404NotFound)] - public async Task ImportDictionary(string file, int? parentId) + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Import(ViewModels.Dictionary.DictionaryImportModel dictionaryImportModel) { - if (string.IsNullOrWhiteSpace(file)) + Attempt result = await _dictionaryItemImportService + .ImportDictionaryItemFromUdtFileAsync( + dictionaryImportModel.FileName, + dictionaryImportModel.ParentKey, + CurrentUserId(_backOfficeSecurityAccessor)); + + return result.Status switch { - return NotFound(); - } - - var filePath = Path.Combine(_webHostEnvironment.MapPathContentRoot(Constants.SystemDirectories.Data), file); - if (_webHostEnvironment.ContentRootFileProvider.GetFileInfo(filePath) is null) - { - return await Task.FromResult(NotFound()); - } - - IDictionaryItem dictionaryItem = _loadDictionaryItemService.Load(filePath, parentId); - - return await Task.FromResult(Content(_dictionaryService.CalculatePath(dictionaryItem.ParentId, dictionaryItem.Id), MediaTypeNames.Text.Plain, Encoding.UTF8)); + DictionaryImportOperationStatus.Success => CreatedAtAction(controller => nameof(controller.ByKey), result.Result!.Key), + DictionaryImportOperationStatus.ParentNotFound => NotFound("The parent dictionary item could not be found."), + DictionaryImportOperationStatus.InvalidFileType => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid file type") + .WithDetail("The dictionary import only supports UDT files.") + .Build()), + DictionaryImportOperationStatus.InvalidFileContent => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid file content") + .WithDetail("The uploaded file could not be read as a valid UDT file.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown dictionary import operation status") + }; } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UploadDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UploadDictionaryController.cs index e23a2c1566..a69228bd4a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UploadDictionaryController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UploadDictionaryController.cs @@ -1,52 +1,46 @@ -using System.Xml; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Models; using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Api.Management.Services.OperationStatus; using Umbraco.Cms.Api.Management.ViewModels.Dictionary; -using Umbraco.Extensions; -using Umbraco.New.Cms.Core.Factories; +using Umbraco.Cms.Core; namespace Umbraco.Cms.Api.Management.Controllers.Dictionary; public class UploadDictionaryController : DictionaryControllerBase { - private readonly ILocalizedTextService _localizedTextService; private readonly IUploadFileService _uploadFileService; private readonly IDictionaryFactory _dictionaryFactory; - public UploadDictionaryController(ILocalizedTextService localizedTextService, IUploadFileService uploadFileService, IDictionaryFactory dictionaryFactory) + public UploadDictionaryController(IUploadFileService uploadFileService, IDictionaryFactory dictionaryFactory) { - _localizedTextService = localizedTextService; _uploadFileService = uploadFileService; _dictionaryFactory = dictionaryFactory; } [HttpPost("upload")] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(DictionaryImportViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DictionaryUploadViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] - public async Task> Upload(IFormFile file) + public async Task Upload(IFormFile file) { - FormFileUploadResult formFileUploadResult = _uploadFileService.TryLoad(file); - if (formFileUploadResult.CouldLoad is false || formFileUploadResult.XmlDocument is null) + Attempt result = await _uploadFileService.UploadUdtFileAsync(file); + + return result.Status switch { - return await Task.FromResult(ValidationProblem( - _localizedTextService.Localize("media", "failedFileUpload"), - formFileUploadResult.ErrorMessage)); - } - - DictionaryImportViewModel model = _dictionaryFactory.CreateDictionaryImportViewModel(formFileUploadResult); - - if (!model.DictionaryItems.Any()) - { - return ValidationProblem( - _localizedTextService.Localize("media", "failedFileUpload"), - _localizedTextService.Localize("dictionary", "noItemsInFile")); - } - - return await Task.FromResult(model); + UdtFileUploadOperationStatus.Success => Ok(_dictionaryFactory.CreateDictionaryImportViewModel(result.Result)), + UdtFileUploadOperationStatus.InvalidFileType => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid file type") + .WithDetail("The dictionary import only supports UDT files.") + .Build()), + UdtFileUploadOperationStatus.InvalidFileContent => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid file content") + .WithDetail("The uploaded file could not be read as a valid UDT file.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown UDT file upload operation status") + }; } } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DictionaryBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DictionaryBuilderExtensions.cs index 059b8411c7..189b40053e 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DictionaryBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DictionaryBuilderExtensions.cs @@ -13,7 +13,7 @@ internal static class DictionaryBuilderExtensions { builder.Services .AddTransient() - .AddTransient(); + .AddTransient(); builder.WithCollectionBuilder().Add(); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/FileUploadBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/FileUploadBuilderExtensions.cs index f0a11dc6d1..e5257bd14a 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/FileUploadBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/FileUploadBuilderExtensions.cs @@ -9,6 +9,7 @@ public static class FileUploadBuilderExtensions internal static IUmbracoBuilder AddFileUpload(this IUmbracoBuilder builder) { builder.Services.AddTransient(); + builder.Services.AddTransient(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs index a1125da8d5..d6e435962b 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs @@ -1,5 +1,4 @@ -using System.Xml; -using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Models; @@ -55,37 +54,36 @@ public class DictionaryFactory : IDictionaryFactory return updated; } - public DictionaryImportViewModel CreateDictionaryImportViewModel(FormFileUploadResult formFileUploadResult) - { - if (formFileUploadResult.CouldLoad is false || formFileUploadResult.XmlDocument is null) + public DictionaryUploadViewModel CreateDictionaryImportViewModel(UdtFileUpload udtFileUpload) => + new DictionaryUploadViewModel { - throw new ArgumentNullException("The document of the FormFileUploadResult cannot be null"); - } + FileName = udtFileUpload.FileName, + DictionaryItems = udtFileUpload + .Content + .Descendants("DictionaryItem") + .Select(dictionaryItem => + { + if (Guid.TryParse(dictionaryItem.Attributes("Key").FirstOrDefault()?.Value, out Guid itemKey) == false) + { + return null; + } - var model = new DictionaryImportViewModel - { - TempFileName = formFileUploadResult.TemporaryPath, DictionaryItems = new List(), + var name = dictionaryItem.Attributes("Name").FirstOrDefault()?.Value; + if (name.IsNullOrWhiteSpace()) + { + return null; + } + + Guid? parentKey = Guid.TryParse(dictionaryItem.Parent?.Attributes("Key").FirstOrDefault()?.Value, out Guid key) + ? key + : null; + + return new DictionaryItemsImportViewModel { Name = name, Key = itemKey, ParentKey = parentKey }; + }) + .WhereNotNull() + .ToArray(), }; - var level = 1; - var currentParent = string.Empty; - foreach (XmlNode dictionaryItem in formFileUploadResult.XmlDocument.GetElementsByTagName("DictionaryItem")) - { - var name = dictionaryItem.Attributes?.GetNamedItem("Name")?.Value ?? string.Empty; - var parentKey = dictionaryItem.ParentNode?.Attributes?.GetNamedItem("Key")?.Value ?? string.Empty; - - if (parentKey != currentParent || level == 1) - { - level += 1; - currentParent = parentKey; - } - - model.DictionaryItems.Add(new DictionaryItemsImportViewModel { Level = level, Name = name }); - } - - return model; - } - private async Task MapTranslations(IDictionaryItem dictionaryItem, IEnumerable translationModels) { var languagesByIsoCode = (await _languageService.GetAllAsync()).ToDictionary(l => l.IsoCode); diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDictionaryFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDictionaryFactory.cs index 9dfb5e2775..5359c1f45a 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDictionaryFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDictionaryFactory.cs @@ -12,5 +12,5 @@ public interface IDictionaryFactory Task CreateDictionaryItemViewModelAsync(IDictionaryItem dictionaryItem); - DictionaryImportViewModel CreateDictionaryImportViewModel(FormFileUploadResult formFileUploadResult); + DictionaryUploadViewModel CreateDictionaryImportViewModel(UdtFileUpload fileUpload); } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs index 17214fe6d9..1c0aff2601 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs @@ -45,11 +45,12 @@ public class DictionaryMapDefinition : IMapDefinition target.DeleteDate = null; } - // Umbraco.Code.MapAll -Level + // Umbraco.Code.MapAll private void Map(IDictionaryItem source, DictionaryOverviewViewModel target, MapperContext context) { target.Key = source.Key; target.Name = source.ItemKey; + target.ParentKey = source.ParentId; target.TranslatedIsoCodes = source .Translations .Where(translation => translation.Value.IsNullOrWhiteSpace() == false) diff --git a/src/Umbraco.Cms.Api.Management/Models/FormFileUploadResult.cs b/src/Umbraco.Cms.Api.Management/Models/FormFileUploadResult.cs deleted file mode 100644 index e5f51311bc..0000000000 --- a/src/Umbraco.Cms.Api.Management/Models/FormFileUploadResult.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Xml; - -namespace Umbraco.Cms.Api.Management.Models; - -public class FormFileUploadResult -{ - public bool CouldLoad { get; set; } - - public XmlDocument? XmlDocument { get; set; } - - public string? ErrorMessage { get; set; } - - public string? TemporaryPath { get; set; } -} diff --git a/src/Umbraco.Cms.Api.Management/Models/UdtFileUpload.cs b/src/Umbraco.Cms.Api.Management/Models/UdtFileUpload.cs new file mode 100644 index 0000000000..4c23f8f6cf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Models/UdtFileUpload.cs @@ -0,0 +1,10 @@ +using System.Xml.Linq; + +namespace Umbraco.Cms.Api.Management.Models; + +public class UdtFileUpload +{ + public required XDocument Content { get; set; } + + public required string FileName { get; init; } = string.Empty; +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index da8719657b..9e2319c572 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -766,12 +766,12 @@ } } }, - "/umbraco/management/api/v1/dictionary/export/{key}": { + "/umbraco/management/api/v1/dictionary/{key}/export": { "get": { "tags": [ "Dictionary" ], - "operationId": "GetDictionaryExportByKey", + "operationId": "GetDictionaryByKeyExport", "parameters": [ { "name": "key", @@ -822,30 +822,25 @@ "Dictionary" ], "operationId": "PostDictionaryImport", - "parameters": [ - { - "name": "file", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "parentId", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DictionaryImportModel" + } } } - ], + }, "responses": { - "200": { - "description": "Success", + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContentResultModel" + "$ref": "#/components/schemas/ProblemDetailsModel" } } } @@ -855,7 +850,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundResultModel" + "$ref": "#/components/schemas/ProblemDetailsModel" } } } @@ -878,7 +873,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DictionaryImportModel" + "$ref": "#/components/schemas/DictionaryUploadModel" } } } @@ -5203,25 +5198,6 @@ }, "additionalProperties": false }, - "ContentResultModel": { - "type": "object", - "properties": { - "content": { - "type": "string", - "nullable": true - }, - "contentType": { - "type": "string", - "nullable": true - }, - "statusCode": { - "type": "integer", - "format": "int32", - "nullable": true - } - }, - "additionalProperties": false - }, "ContentTreeItemModel": { "type": "object", "properties": { @@ -5568,14 +5544,12 @@ "DictionaryImportModel": { "type": "object", "properties": { - "dictionaryItems": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DictionaryItemsImportModel" - } + "fileName": { + "type": "string" }, - "tempFileName": { + "parentKey": { "type": "string", + "format": "uuid", "nullable": true } }, @@ -5650,13 +5624,18 @@ "DictionaryItemsImportModel": { "type": "object", "properties": { + "key": { + "type": "string", + "format": "uuid" + }, "name": { "type": "string", "nullable": true }, - "level": { - "type": "integer", - "format": "int32" + "parentKey": { + "type": "string", + "format": "uuid", + "nullable": true } }, "additionalProperties": false @@ -5672,9 +5651,10 @@ "type": "string", "format": "uuid" }, - "level": { - "type": "integer", - "format": "int32" + "parentKey": { + "type": "string", + "format": "uuid", + "nullable": true }, "translatedIsoCodes": { "type": "array", @@ -5685,6 +5665,22 @@ }, "additionalProperties": false }, + "DictionaryUploadModel": { + "type": "object", + "properties": { + "dictionaryItems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DictionaryItemsImportModel" + } + }, + "fileName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "DirectionModel": { "enum": [ "Ascending", diff --git a/src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs b/src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs new file mode 100644 index 0000000000..ab1f290886 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs @@ -0,0 +1,103 @@ +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Api.Management.Services.OperationStatus; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Packaging; +using Umbraco.Extensions; +using File = System.IO.File; + +namespace Umbraco.Cms.Api.Management.Services; + +internal sealed class DictionaryItemImportService : IDictionaryItemImportService +{ + private readonly IDictionaryItemService _dictionaryItemService; + private readonly PackageDataInstallation _packageDataInstallation; + private readonly ILogger _logger; + private readonly ITemporaryFileService _temporaryFileService; + + public DictionaryItemImportService( + IDictionaryItemService dictionaryItemService, + PackageDataInstallation packageDataInstallation, + ILogger logger, + ITemporaryFileService temporaryFileService) + { + _dictionaryItemService = dictionaryItemService; + _packageDataInstallation = packageDataInstallation; + _logger = logger; + _temporaryFileService = temporaryFileService; + } + + public async Task> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId) + { + if (".udt".InvariantEquals(Path.GetExtension(fileName)) == false) + { + return Attempt.FailWithStatus(DictionaryImportOperationStatus.InvalidFileType, null); + } + + if (parentKey.HasValue && await _dictionaryItemService.GetAsync(parentKey.Value) == null) + { + return Attempt.FailWithStatus(DictionaryImportOperationStatus.ParentNotFound, null); + } + + // load the UDT file from disk + (XDocument Document, DictionaryImportOperationStatus Status) loadResult = await LoadUdtFileAsync(fileName); + if (loadResult.Status != DictionaryImportOperationStatus.Success) + { + return Attempt.FailWithStatus(loadResult.Status, null); + } + + // import the UDT file + (IDictionaryItem? DictionaryItem, DictionaryImportOperationStatus Status) importResult = ImportUdtFile(loadResult.Document, userId, parentKey, fileName); + + // clean up the UDT file (we don't care about success or failure at this point, we'll let the temporary file service handle those) + await _temporaryFileService.DeleteFileAsync(fileName); + + return importResult.Status == DictionaryImportOperationStatus.Success + ? Attempt.SucceedWithStatus(DictionaryImportOperationStatus.Success, importResult.DictionaryItem) + : Attempt.FailWithStatus(importResult.Status, null); + } + + private async Task<(XDocument Document, DictionaryImportOperationStatus Status)> LoadUdtFileAsync(string fileName) + { + try + { + var filePath = await _temporaryFileService.GetFilePathAsync(fileName); + + await using FileStream stream = File.OpenRead(filePath); + XDocument document = await XDocument.LoadAsync(stream, LoadOptions.None, CancellationToken.None); + + return document.Root != null + ? (document, DictionaryImportOperationStatus.Success) + : (document, DictionaryImportOperationStatus.InvalidFileContent); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading UDT file: {FileName}", fileName); + return (new XDocument(), DictionaryImportOperationStatus.InvalidFileContent); + } + } + + private (IDictionaryItem? DictionaryItem, DictionaryImportOperationStatus Status) ImportUdtFile(XDocument udtFileContent, int userId, Guid? parentKey, string fileName) + { + if (udtFileContent.Root == null) + { + return (null, DictionaryImportOperationStatus.InvalidFileContent); + } + + try + { + IEnumerable dictionaryItems = _packageDataInstallation.ImportDictionaryItem(udtFileContent.Root, userId, parentKey); + IDictionaryItem? importedDictionaryItem = dictionaryItems.FirstOrDefault(); + return importedDictionaryItem != null + ? (importedDictionaryItem, DictionaryImportOperationStatus.Success) + : (null, DictionaryImportOperationStatus.InvalidFileContent); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error importing UDT file: {FileName}", fileName); + return (null, DictionaryImportOperationStatus.InvalidFileContent); + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/Services/IDictionaryItemImportService.cs b/src/Umbraco.Cms.Api.Management/Services/IDictionaryItemImportService.cs new file mode 100644 index 0000000000..0264e3f7ba --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/IDictionaryItemImportService.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Api.Management.Services.OperationStatus; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Services; + +public interface IDictionaryItemImportService +{ + Task> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/ILoadDictionaryItemService.cs b/src/Umbraco.Cms.Api.Management/Services/ILoadDictionaryItemService.cs deleted file mode 100644 index 9cd6357ecd..0000000000 --- a/src/Umbraco.Cms.Api.Management/Services/ILoadDictionaryItemService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Umbraco.Cms.Core.Models; - -namespace Umbraco.Cms.Api.Management.Services; - -public interface ILoadDictionaryItemService -{ - IDictionaryItem Load(string filePath, int? parentId); -} diff --git a/src/Umbraco.Cms.Api.Management/Services/ITemporaryFileService.cs b/src/Umbraco.Cms.Api.Management/Services/ITemporaryFileService.cs new file mode 100644 index 0000000000..00a43cc937 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/ITemporaryFileService.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace Umbraco.Cms.Api.Management.Services; + +public interface ITemporaryFileService +{ + Task GetFilePathAsync(string fileName); + + Task SaveFileAsync(IFormFile file); + + Task DeleteFileAsync(string fileName); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/IUploadFileService.cs b/src/Umbraco.Cms.Api.Management/Services/IUploadFileService.cs index 3e632217dd..a5263fbe02 100644 --- a/src/Umbraco.Cms.Api.Management/Services/IUploadFileService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/IUploadFileService.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Http; using Umbraco.Cms.Api.Management.Models; +using Umbraco.Cms.Api.Management.Services.OperationStatus; +using Umbraco.Cms.Core; namespace Umbraco.Cms.Api.Management.Services; public interface IUploadFileService { - FormFileUploadResult TryLoad(IFormFile file); + Task> UploadUdtFileAsync(IFormFile file); } diff --git a/src/Umbraco.Cms.Api.Management/Services/LoadDictionaryItemService.cs b/src/Umbraco.Cms.Api.Management/Services/LoadDictionaryItemService.cs deleted file mode 100644 index 0fa8596c3e..0000000000 --- a/src/Umbraco.Cms.Api.Management/Services/LoadDictionaryItemService.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Xml; -using System.Xml.Linq; -using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Security; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.Packaging; - -namespace Umbraco.Cms.Api.Management.Services; - -public class LoadDictionaryItemService : ILoadDictionaryItemService -{ - private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - // FIXME: use IDictionaryItemService instead of ILocalizationService - private readonly ILocalizationService _localizationService; - private readonly PackageDataInstallation _packageDataInstallation; - private readonly ILogger _logger; - - public LoadDictionaryItemService( - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - ILocalizationService localizationService, - PackageDataInstallation packageDataInstallation, - ILogger logger) - { - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _localizationService = localizationService; - _packageDataInstallation = packageDataInstallation; - _logger = logger; - } - - // FIXME: use Guid key, not integer ID for parent identification - public IDictionaryItem Load(string filePath, int? parentId) - { - var xmlDocument = new XmlDocument { XmlResolver = null }; - xmlDocument.Load(filePath); - - var userId = _backOfficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0; - var element = XElement.Parse(xmlDocument.InnerXml); - - IDictionaryItem? parentDictionaryItem = _localizationService.GetDictionaryItemById(parentId ?? 0); - IEnumerable dictionaryItems = _packageDataInstallation.ImportDictionaryItem(element, userId, parentDictionaryItem?.Key); - - // Try to clean up the temporary file. - try - { - System.IO.File.Delete(filePath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error cleaning up temporary udt file in {File}", filePath); - } - - return dictionaryItems.First(); - } -} diff --git a/src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs b/src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs new file mode 100644 index 0000000000..ad3d3f2c7c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Api.Management.Services.OperationStatus; + +public enum DictionaryImportOperationStatus +{ + Success, + ParentNotFound, + InvalidFileContent, + InvalidFileType +} diff --git a/src/Umbraco.Cms.Api.Management/Services/OperationStatus/UdtFileUploadOperationStatus.cs b/src/Umbraco.Cms.Api.Management/Services/OperationStatus/UdtFileUploadOperationStatus.cs new file mode 100644 index 0000000000..0c7a43edb2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/OperationStatus/UdtFileUploadOperationStatus.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.Services.OperationStatus; + +public enum UdtFileUploadOperationStatus +{ + Success, + InvalidFileType, + InvalidFileContent +} diff --git a/src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs b/src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs new file mode 100644 index 0000000000..537ed9fe16 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Extensions; + +namespace Umbraco.Cms.Api.Management.Services; + +internal sealed class TemporaryFileService : ITemporaryFileService +{ + private readonly IHostEnvironment _hostEnvironment; + private readonly ILogger _logger; + + public TemporaryFileService(IHostEnvironment hostEnvironment, ILogger logger) + { + _hostEnvironment = hostEnvironment; + _logger = logger; + } + + public async Task GetFilePathAsync(string fileName) + { + var root = _hostEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); + fileName = fileName.Trim(Constants.CharArrays.DoubleQuote); + + var filePath = Path.Combine(root, fileName); + return await Task.FromResult(filePath); + } + + public async Task SaveFileAsync(IFormFile file) + { + var filePath = await GetFilePathAsync(file.FileName); + + await using FileStream fileStream = File.Create(filePath); + await file.CopyToAsync(fileStream); + + return filePath; + } + + public async Task DeleteFileAsync(string fileName) + { + var filePath = await GetFilePathAsync(fileName); + try + { + File.Delete(filePath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting temporary file: {FilePath}", filePath); + return await Task.FromResult(false); + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/Services/UploadFileService.cs b/src/Umbraco.Cms.Api.Management/Services/UploadFileService.cs index db784744f0..a1e529121a 100644 --- a/src/Umbraco.Cms.Api.Management/Services/UploadFileService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/UploadFileService.cs @@ -1,65 +1,42 @@ -using System.Xml; +using System.Xml.Linq; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Extensions; -using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Models; +using Umbraco.Cms.Api.Management.Services.OperationStatus; using Umbraco.Extensions; -using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Cms.Api.Management.Services; -public class UploadFileService : IUploadFileService +internal sealed class UploadFileService : IUploadFileService { - private readonly IHostEnvironment _hostEnvironment; - private readonly ILocalizedTextService _localizedTextService; + private readonly ITemporaryFileService _temporaryFileService; - public UploadFileService(IHostEnvironment hostEnvironment, ILocalizedTextService localizedTextService) + public UploadFileService(ITemporaryFileService temporaryFileService) => _temporaryFileService = temporaryFileService; + + public async Task> UploadUdtFileAsync(IFormFile file) { - _hostEnvironment = hostEnvironment; - _localizedTextService = localizedTextService; - } + UdtFileUpload DefaultModel() => new() { FileName = file.FileName, Content = new XDocument() }; - public FormFileUploadResult TryLoad(IFormFile file) - { - var formFileUploadResult = new FormFileUploadResult(); - var fileName = file.FileName.Trim(Constants.CharArrays.DoubleQuote); - var ext = fileName.Substring(fileName.LastIndexOf('.') + 1).ToLower(); - var root = _hostEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); - formFileUploadResult.TemporaryPath = Path.Combine(root, fileName); - - if (!Path.GetFullPath(formFileUploadResult.TemporaryPath).StartsWith(Path.GetFullPath(root))) + if (".udt".InvariantEquals(Path.GetExtension(file.FileName)) == false) { - formFileUploadResult.ErrorMessage = _localizedTextService.Localize("media", "invalidFileName"); - formFileUploadResult.CouldLoad = false; - return formFileUploadResult; + return Attempt.FailWithStatus(UdtFileUploadOperationStatus.InvalidFileType, DefaultModel()); } - if (!ext.InvariantEquals("udt")) + var filePath = await _temporaryFileService.SaveFileAsync(file); + + XDocument content; + await using (FileStream fileStream = File.OpenRead(filePath)) { - formFileUploadResult.ErrorMessage = _localizedTextService.Localize("media", "disallowedFileType"); - formFileUploadResult.CouldLoad = false; - return formFileUploadResult; + content = await XDocument.LoadAsync(fileStream, LoadOptions.None, CancellationToken.None); } - using (FileStream stream = File.Create(formFileUploadResult.TemporaryPath)) + if (content.Root == null) { - file.CopyToAsync(stream).GetAwaiter().GetResult(); + return Attempt.FailWithStatus(UdtFileUploadOperationStatus.InvalidFileContent, DefaultModel()); } - formFileUploadResult.XmlDocument = new XmlDocument {XmlResolver = null}; - formFileUploadResult.XmlDocument.Load(formFileUploadResult.TemporaryPath); - - if (formFileUploadResult.XmlDocument.DocumentElement != null) - { - return formFileUploadResult; - } - - formFileUploadResult.ErrorMessage = _localizedTextService.Localize("speechBubbles", "fileErrorNotFound"); - formFileUploadResult.CouldLoad = false; - return formFileUploadResult; - + // grab the file name from the file path in case the temporary file name is different from the uploaded one + var model = new UdtFileUpload { FileName = Path.GetFileName(filePath), Content = content }; + return Attempt.SucceedWithStatus(UdtFileUploadOperationStatus.Success, model); } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportModel.cs new file mode 100644 index 0000000000..c7b0d86cfc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary; + +public class DictionaryImportModel +{ + public required string FileName { get; set; } + + public Guid? ParentKey { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportViewModel.cs deleted file mode 100644 index d60af74a4a..0000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportViewModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary; - -public class DictionaryImportViewModel -{ - public List DictionaryItems { get; set; } = null!; - - public string? TempFileName { get; set; } -} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemsImportViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemsImportViewModel.cs index f0599a3e46..f456fd4ea4 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemsImportViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemsImportViewModel.cs @@ -2,7 +2,9 @@ public class DictionaryItemsImportViewModel { + public Guid Key { get; set; } + public string? Name { get; set; } - public int Level { get; set; } + public Guid? ParentKey { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryOverviewViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryOverviewViewModel.cs index fbb5d39664..2f9f6b6768 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryOverviewViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryOverviewViewModel.cs @@ -13,9 +13,9 @@ public class DictionaryOverviewViewModel public Guid Key { get; set; } /// - /// Gets or sets the level. + /// Gets or sets the parent key. /// - public int Level { get; set; } + public Guid? ParentKey { get; set; } /// /// Sets the translations. diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryUploadViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryUploadViewModel.cs new file mode 100644 index 0000000000..9445b5b1c8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryUploadViewModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary; + +public class DictionaryUploadViewModel +{ + public IEnumerable DictionaryItems { get; set; } = Array.Empty(); + + public string? FileName { get; set; } +}