diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs index 42743527bb..c4dc5cf5f4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/ImportDictionaryController.cs @@ -32,7 +32,7 @@ public class ImportDictionaryController : DictionaryControllerBase { Attempt result = await _dictionaryItemImportService .ImportDictionaryItemFromUdtFileAsync( - importDictionaryRequestModel.FileName, + importDictionaryRequestModel.TemporaryFileKey, importDictionaryRequestModel.ParentKey, CurrentUserId(_backOfficeSecurityAccessor)); @@ -40,6 +40,7 @@ public class ImportDictionaryController : DictionaryControllerBase { DictionaryImportOperationStatus.Success => CreatedAtAction(controller => nameof(controller.ByKey), result.Result!.Key), DictionaryImportOperationStatus.ParentNotFound => NotFound("The parent dictionary item could not be found."), + DictionaryImportOperationStatus.TemporaryFileNotFound => NotFound("The temporary file with specified key could not be found."), DictionaryImportOperationStatus.InvalidFileType => BadRequest(new ProblemDetailsBuilder() .WithTitle("Invalid file type") .WithDetail("The dictionary import only supports UDT files.") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UploadDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UploadDictionaryController.cs deleted file mode 100644 index df711feba4..0000000000 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UploadDictionaryController.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Common.Builders; -using Umbraco.Cms.Api.Management.Factories; -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.Cms.Core; - -namespace Umbraco.Cms.Api.Management.Controllers.Dictionary; - -public class UploadDictionaryController : DictionaryControllerBase -{ - private readonly IUploadFileService _uploadFileService; - private readonly IDictionaryPresentationFactory _dictionaryPresentationFactory; - - public UploadDictionaryController(IUploadFileService uploadFileService, IDictionaryPresentationFactory dictionaryPresentationFactory) - { - _uploadFileService = uploadFileService; - _dictionaryPresentationFactory = dictionaryPresentationFactory; - } - - [HttpPost("upload")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(UploadDictionaryResponseModel), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] - public async Task Upload(IFormFile file) - { - Attempt result = await _uploadFileService.UploadUdtFileAsync(file); - - return result.Status switch - { - UdtFileUploadOperationStatus.Success => Ok(_dictionaryPresentationFactory.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/Controllers/TemporaryFile/ByKeyTemporaryFileController.cs b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/ByKeyTemporaryFileController.cs new file mode 100644 index 0000000000..ddc39fa3f8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/ByKeyTemporaryFileController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Language; +using Umbraco.Cms.Api.Management.ViewModels.TemporaryFile; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.TemporaryFile; + +public class ByKeyTemporaryFileController : TemporaryFileControllerBase +{ + private readonly ITemporaryFileService _temporaryFileService; + private readonly IUmbracoMapper _umbracoMapper; + + public ByKeyTemporaryFileController(ITemporaryFileService temporaryFileService, IUmbracoMapper umbracoMapper) + { + _temporaryFileService = temporaryFileService; + _umbracoMapper = umbracoMapper; + } + + [HttpGet($"{{{nameof(key)}}}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(TemporaryFileResponseModel), StatusCodes.Status200OK)] + public async Task ByKey(Guid key) + { + TemporaryFileModel? model = await _temporaryFileService.GetAsync(key); + if (model == null) + { + return NotFound(); + } + + return Ok(_umbracoMapper.Map(model)); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/CreateTemporaryFileController.cs b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/CreateTemporaryFileController.cs new file mode 100644 index 0000000000..0513fdd208 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/CreateTemporaryFileController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.TemporaryFile; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.TemporaryFile; + +public class CreateTemporaryFileController : TemporaryFileControllerBase +{ + private readonly ITemporaryFileService _temporaryFileService; + private readonly IUmbracoMapper _umbracoMapper; + + public CreateTemporaryFileController(ITemporaryFileService temporaryFileService, IUmbracoMapper umbracoMapper) + { + _temporaryFileService = temporaryFileService; + _umbracoMapper = umbracoMapper; + } + + [HttpPost("")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Create([FromForm] CreateTemporaryFileRequestModel model) + { + CreateTemporaryFileModel createModel = _umbracoMapper.Map(model)!; + + Attempt result = await _temporaryFileService.CreateAsync(createModel); + + return result.Success + ? CreatedAtAction(controller => nameof(controller.ByKey), new { key = result.Result!.Key }) + : TemporaryFileStatusResult(result.Status); + } +} + diff --git a/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/DeleteTemporaryFileController.cs b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/DeleteTemporaryFileController.cs new file mode 100644 index 0000000000..48283c9650 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/DeleteTemporaryFileController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.TemporaryFile; + +public class DeleteTemporaryFileController : TemporaryFileControllerBase +{ + private readonly ITemporaryFileService _temporaryFileService; + + public DeleteTemporaryFileController(ITemporaryFileService temporaryFileService) => _temporaryFileService = temporaryFileService; + + [HttpDelete($"{{{nameof(key)}}}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Delete(Guid key) + { + Attempt result = await _temporaryFileService.DeleteAsync(key); + + return result.Success + ? Ok() + : TemporaryFileStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs new file mode 100644 index 0000000000..86e4e448b7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/TemporaryFile/TemporaryFileControllerBase.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.TemporaryFile; + +[ApiController] +[VersionedApiBackOfficeRoute("temporaryfile")] +[ApiExplorerSettings(GroupName = "Temporary File")] +[ApiVersion("1.0")] +public abstract class TemporaryFileControllerBase : ManagementApiControllerBase +{ + protected IActionResult TemporaryFileStatusResult(TemporaryFileOperationStatus operationStatus) => + operationStatus switch + { + TemporaryFileOperationStatus.FileExtensionNotAllowed => BadRequest(new ProblemDetailsBuilder() + .WithTitle("File extension not allowed") + .WithDetail("The file extension is not allowed.") + .Build()), + + TemporaryFileOperationStatus.KeyAlreadyUsed => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Key already used") + .WithDetail("The specified key is already used.") + .Build()), + + TemporaryFileOperationStatus.NotFound => NotFound(), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown temporary file operation status") + }; + + + protected new IActionResult NotFound() => NotFound("The temporary file could not be found"); +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/FileUploadBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/FileUploadBuilderExtensions.cs deleted file mode 100644 index e5257bd14a..0000000000 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/FileUploadBuilderExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Api.Management.Services; -using Umbraco.Cms.Core.DependencyInjection; - -namespace Umbraco.Cms.Api.Management.DependencyInjection; - -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/DependencyInjection/TemporaryFileUploadsBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/TemporaryFileUploadsBuilderExtensions.cs new file mode 100644 index 0000000000..856cffbcf8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/TemporaryFileUploadsBuilderExtensions.cs @@ -0,0 +1,16 @@ +using Umbraco.Cms.Api.Management.Mapping.TemporaryFile; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Mapping; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class TTemporaryFileBuilderExtensions +{ + internal static IUmbracoBuilder AddTemporaryFiles(this IUmbracoBuilder builder) + { + builder.WithCollectionBuilder() + .Add(); + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index 89db62b22c..f140a45e4e 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -32,11 +32,11 @@ public class ManagementApiComposer : IComposer .AddMediaTypes() .AddLanguages() .AddDictionary() - .AddFileUpload() .AddHealthChecks() .AddModelsBuilder() .AddRedirectUrl() .AddTrackedReferences() + .AddTemporaryFiles() .AddDataTypes() .AddTemplates() .AddRelationTypes() diff --git a/src/Umbraco.Cms.Api.Management/Mapping/TemporaryFile/TemporaryFileUploadViewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/TemporaryFile/TemporaryFileUploadViewModelsMapDefinition.cs new file mode 100644 index 0000000000..b8e9ff1a29 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/TemporaryFile/TemporaryFileUploadViewModelsMapDefinition.cs @@ -0,0 +1,30 @@ +using Umbraco.Cms.Api.Management.ViewModels.TemporaryFile; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models.TemporaryFile; + +namespace Umbraco.Cms.Api.Management.Mapping.TemporaryFile; + +public class TemporaryFileViewModelsMapDefinition : IMapDefinition +{ + public void DefineMaps(IUmbracoMapper mapper) + { + mapper.Define((source, context) => new CreateTemporaryFileModel { FileName = string.Empty }, Map); + mapper.Define((source, context) => new TemporaryFileResponseModel(), Map); + } + + // Umbraco.Code.MapAll + private void Map(CreateTemporaryFileRequestModel source, CreateTemporaryFileModel target, MapperContext context) + { + target.OpenReadStream = () => source.File.OpenReadStream(); + target.FileName = source.File.FileName; + target.Key = source.Key; + } + + // Umbraco.Code.MapAll + private void Map(TemporaryFileModel source, TemporaryFileResponseModel target, MapperContext context) + { + target.Key = source.Key; + target.AvailableUntil = source.AvailableUntil; + target.FileName = source.FileName; + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 995430606c..310a5654fd 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -1166,43 +1166,6 @@ } } }, - "/umbraco/management/api/v1/dictionary/upload": { - "post": { - "tags": [ - "Dictionary" - ], - "operationId": "PostDictionaryUpload", - "requestBody": { - "content": { } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/UploadDictionaryResponseModel" - } - ] - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - } - } - } - }, "/umbraco/management/api/v1/tree/dictionary/children": { "get": { "tags": [ @@ -6272,6 +6235,163 @@ } } }, + "/umbraco/management/api/v1/temporaryfile": { + "post": { + "tags": [ + "Temporary File" + ], + "operationId": "PostTemporaryfile", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "format": "uuid" + }, + "File": { + "type": "string", + "format": "binary" + } + } + }, + "encoding": { + "Key": { + "style": "form" + }, + "File": { + "style": "form" + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/temporaryfile/{key}": { + "get": { + "tags": [ + "Temporary File" + ], + "operationId": "GetTemporaryfileByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TemporaryFileResponseModel" + } + ] + } + } + } + } + } + }, + "delete": { + "tags": [ + "Temporary File" + ], + "operationId": "DeleteTemporaryfileByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "200": { + "description": "Success" + } + } + } + }, "/umbraco/management/api/v1/tracked-reference/{key}": { "get": { "tags": [ @@ -8215,30 +8335,12 @@ }, "additionalProperties": false }, - "ImportDictionaryItemsPresentationModel": { - "type": "object", - "properties": { - "key": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "nullable": true - }, - "parentKey": { - "type": "string", - "format": "uuid", - "nullable": true - } - }, - "additionalProperties": false - }, "ImportDictionaryRequestModel": { "type": "object", "properties": { - "fileName": { - "type": "string" + "temporaryFileKey": { + "type": "string", + "format": "uuid" }, "parentKey": { "type": "string", @@ -10303,6 +10405,24 @@ }, "additionalProperties": false }, + "TemporaryFileResponseModel": { + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "uuid" + }, + "availableUntil": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "fileName": { + "type": "string" + } + }, + "additionalProperties": false + }, "TreeItemPresentationModel": { "type": "object", "properties": { @@ -10506,26 +10626,6 @@ }, "additionalProperties": false }, - "UploadDictionaryResponseModel": { - "type": "object", - "properties": { - "dictionaryItems": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/ImportDictionaryItemsPresentationModel" - } - ] - } - }, - "fileName": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, "UserGroupBaseModel": { "type": "object", "properties": { diff --git a/src/Umbraco.Cms.Api.Management/OpenApi/MimeTypeDocumentFilter.cs b/src/Umbraco.Cms.Api.Management/OpenApi/MimeTypeDocumentFilter.cs index 15a1bda815..b9d4f2e364 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi/MimeTypeDocumentFilter.cs +++ b/src/Umbraco.Cms.Api.Management/OpenApi/MimeTypeDocumentFilter.cs @@ -5,7 +5,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.OpenApi; /// -/// This filter explicitly removes all other mime types than application/json from the produced OpenAPI document +/// This filter explicitly removes all other mime types than application/json from the produced OpenAPI document when application/json is accepted. /// public class MimeTypeDocumentFilter : IDocumentFilter { @@ -15,8 +15,14 @@ public class MimeTypeDocumentFilter : IDocumentFilter .SelectMany(path => path.Value.Operations.Values) .ToArray(); - void RemoveUnwantedMimeTypes(IDictionary content) => - content.RemoveAll(r => r.Key != "application/json"); + void RemoveUnwantedMimeTypes(IDictionary content) + { + if (content.ContainsKey("application/json")) + { + content.RemoveAll(r => r.Key != "application/json"); + } + + } OpenApiRequestBody[] requestBodies = operations.Select(operation => operation.RequestBody).WhereNotNull().ToArray(); foreach (OpenApiRequestBody requestBody in requestBodies) diff --git a/src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs b/src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs index ab1f290886..d04da27e33 100644 --- a/src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs @@ -3,10 +3,13 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Api.Management.Services.OperationStatus; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Packaging; using Umbraco.Extensions; using File = System.IO.File; +using IScopeProvider = Umbraco.Cms.Infrastructure.Scoping.IScopeProvider; namespace Umbraco.Cms.Api.Management.Services; @@ -16,22 +19,37 @@ internal sealed class DictionaryItemImportService : IDictionaryItemImportService private readonly PackageDataInstallation _packageDataInstallation; private readonly ILogger _logger; private readonly ITemporaryFileService _temporaryFileService; + private readonly IScopeProvider _scopeProvider; public DictionaryItemImportService( IDictionaryItemService dictionaryItemService, PackageDataInstallation packageDataInstallation, ILogger logger, - ITemporaryFileService temporaryFileService) + ITemporaryFileService temporaryFileService, + IScopeProvider scopeProvider) { _dictionaryItemService = dictionaryItemService; _packageDataInstallation = packageDataInstallation; _logger = logger; _temporaryFileService = temporaryFileService; + _scopeProvider = scopeProvider; } - public async Task> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId) + public async Task> ImportDictionaryItemFromUdtFileAsync(Guid fileKey, Guid? parentKey, int userId = Constants.Security.SuperUserId) { - if (".udt".InvariantEquals(Path.GetExtension(fileName)) == false) + using var scope = _scopeProvider.CreateScope(); + _temporaryFileService.EnlistDeleteIfScopeCompletes(fileKey, _scopeProvider); + + TemporaryFileModel? temporaryFile = await _temporaryFileService.GetAsync(fileKey); + + if (temporaryFile is null) + { + return Attempt.FailWithStatus(DictionaryImportOperationStatus.TemporaryFileNotFound, null); + } + + + + if (".udt".InvariantEquals(Path.GetExtension(temporaryFile.FileName)) == false) { return Attempt.FailWithStatus(DictionaryImportOperationStatus.InvalidFileType, null); } @@ -42,31 +60,28 @@ internal sealed class DictionaryItemImportService : IDictionaryItemImportService } // load the UDT file from disk - (XDocument Document, DictionaryImportOperationStatus Status) loadResult = await LoadUdtFileAsync(fileName); + (XDocument Document, DictionaryImportOperationStatus Status) loadResult = await LoadUdtFileAsync(temporaryFile); 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); + (IDictionaryItem? DictionaryItem, DictionaryImportOperationStatus Status) importResult = ImportUdtFile(loadResult.Document, userId, parentKey, temporaryFile); - // 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); + scope.Complete(); 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) + private async Task<(XDocument Document, DictionaryImportOperationStatus Status)> LoadUdtFileAsync(TemporaryFileModel temporaryFileModel) { try { - var filePath = await _temporaryFileService.GetFilePathAsync(fileName); - - await using FileStream stream = File.OpenRead(filePath); - XDocument document = await XDocument.LoadAsync(stream, LoadOptions.None, CancellationToken.None); + await using var dataStream = temporaryFileModel.OpenReadStream(); + XDocument document = await XDocument.LoadAsync(dataStream, LoadOptions.None, CancellationToken.None); return document.Root != null ? (document, DictionaryImportOperationStatus.Success) @@ -74,12 +89,12 @@ internal sealed class DictionaryItemImportService : IDictionaryItemImportService } catch (Exception ex) { - _logger.LogError(ex, "Error loading UDT file: {FileName}", fileName); + _logger.LogError(ex, "Error loading UDT file: {FileName}", temporaryFileModel.FileName); return (new XDocument(), DictionaryImportOperationStatus.InvalidFileContent); } } - private (IDictionaryItem? DictionaryItem, DictionaryImportOperationStatus Status) ImportUdtFile(XDocument udtFileContent, int userId, Guid? parentKey, string fileName) + private (IDictionaryItem? DictionaryItem, DictionaryImportOperationStatus Status) ImportUdtFile(XDocument udtFileContent, int userId, Guid? parentKey, TemporaryFileModel temporaryFileModel) { if (udtFileContent.Root == null) { @@ -96,7 +111,7 @@ internal sealed class DictionaryItemImportService : IDictionaryItemImportService } catch (Exception ex) { - _logger.LogError(ex, "Error importing UDT file: {FileName}", fileName); + _logger.LogError(ex, "Error importing UDT file: {FileName}", temporaryFileModel.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 index 0264e3f7ba..b9ae55dfb4 100644 --- a/src/Umbraco.Cms.Api.Management/Services/IDictionaryItemImportService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/IDictionaryItemImportService.cs @@ -6,5 +6,5 @@ namespace Umbraco.Cms.Api.Management.Services; public interface IDictionaryItemImportService { - Task> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId); + Task> ImportDictionaryItemFromUdtFileAsync(Guid temporaryFileKey, Guid? parentKey, int userId = Constants.Security.SuperUserId); } diff --git a/src/Umbraco.Cms.Api.Management/Services/ITemporaryFileService.cs b/src/Umbraco.Cms.Api.Management/Services/ITemporaryFileService.cs deleted file mode 100644 index 00a43cc937..0000000000 --- a/src/Umbraco.Cms.Api.Management/Services/ITemporaryFileService.cs +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index a5263fbe02..0000000000 --- a/src/Umbraco.Cms.Api.Management/Services/IUploadFileService.cs +++ /dev/null @@ -1,11 +0,0 @@ -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 -{ - Task> UploadUdtFileAsync(IFormFile file); -} diff --git a/src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs b/src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs index ad3d3f2c7c..b9cb2e7d1c 100644 --- a/src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs +++ b/src/Umbraco.Cms.Api.Management/Services/OperationStatus/DictionaryImportOperationStatus.cs @@ -5,5 +5,6 @@ public enum DictionaryImportOperationStatus Success, ParentNotFound, InvalidFileContent, - InvalidFileType + InvalidFileType, + TemporaryFileNotFound, } diff --git a/src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs b/src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs deleted file mode 100644 index 537ed9fe16..0000000000 --- a/src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index a1e529121a..0000000000 --- a/src/Umbraco.Cms.Api.Management/Services/UploadFileService.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Xml.Linq; -using Microsoft.AspNetCore.Http; -using Umbraco.Cms.Core; -using Umbraco.Cms.Api.Management.Models; -using Umbraco.Cms.Api.Management.Services.OperationStatus; -using Umbraco.Extensions; - -namespace Umbraco.Cms.Api.Management.Services; - -internal sealed class UploadFileService : IUploadFileService -{ - private readonly ITemporaryFileService _temporaryFileService; - - public UploadFileService(ITemporaryFileService temporaryFileService) => _temporaryFileService = temporaryFileService; - - public async Task> UploadUdtFileAsync(IFormFile file) - { - UdtFileUpload DefaultModel() => new() { FileName = file.FileName, Content = new XDocument() }; - - if (".udt".InvariantEquals(Path.GetExtension(file.FileName)) == false) - { - return Attempt.FailWithStatus(UdtFileUploadOperationStatus.InvalidFileType, DefaultModel()); - } - - var filePath = await _temporaryFileService.SaveFileAsync(file); - - XDocument content; - await using (FileStream fileStream = File.OpenRead(filePath)) - { - content = await XDocument.LoadAsync(fileStream, LoadOptions.None, CancellationToken.None); - } - - if (content.Root == null) - { - return Attempt.FailWithStatus(UdtFileUploadOperationStatus.InvalidFileContent, DefaultModel()); - } - - // 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/ImportDictionaryRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/ImportDictionaryRequestModel.cs index 1129e98a71..3251fdb554 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/ImportDictionaryRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/ImportDictionaryRequestModel.cs @@ -2,7 +2,7 @@ public class ImportDictionaryRequestModel { - public required string FileName { get; set; } + public required Guid TemporaryFileKey { get; set; } public Guid? ParentKey { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TemporaryFile/CreateTemporaryFileRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TemporaryFile/CreateTemporaryFileRequestModel.cs new file mode 100644 index 0000000000..142f55bde3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TemporaryFile/CreateTemporaryFileRequestModel.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; + +namespace Umbraco.Cms.Api.Management.ViewModels.TemporaryFile; + +public class CreateTemporaryFileRequestModel +{ + public required Guid Key { get; set; } + + public required IFormFile File { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/TemporaryFile/TemporaryFileResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/TemporaryFile/TemporaryFileResponseModel.cs new file mode 100644 index 0000000000..b20e74b3db --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/TemporaryFile/TemporaryFileResponseModel.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.TemporaryFile; + +public class TemporaryFileResponseModel +{ + public Guid Key { get; set; } + + public DateTime? AvailableUntil { get; set; } + + public string FileName { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Core/Configuration/Models/RuntimeSettings.cs b/src/Umbraco.Core/Configuration/Models/RuntimeSettings.cs index 7f31c9319b..a461815897 100644 --- a/src/Umbraco.Core/Configuration/Models/RuntimeSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RuntimeSettings.cs @@ -11,6 +11,7 @@ namespace Umbraco.Cms.Core.Configuration.Models; [UmbracoOptions(Constants.Configuration.ConfigRuntime)] public class RuntimeSettings { + private const string StaticTemporaryFileLifeTime = "1.00:00:00"; // TimeSpan.FromDays(1); /// /// Gets or sets the runtime mode. /// @@ -26,4 +27,10 @@ public class RuntimeSettings /// Gets or sets a value for the maximum request length in kb. /// public int? MaxRequestLength { get; set; } + + /// + /// Gets or sets the timespan temporary files are kept, before they are removed by a background task. + /// + [DefaultValue(StaticTemporaryFileLifeTime)] + public TimeSpan TemporaryFileLifeTime { get; set; } = TimeSpan.Parse(StaticTemporaryFileLifeTime); } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index ab7b8b6026..80f298d2e8 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -305,6 +305,7 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Models/TemporaryFileUpload/CreateTemporaryFileModel.cs b/src/Umbraco.Core/Models/TemporaryFileUpload/CreateTemporaryFileModel.cs new file mode 100644 index 0000000000..cae26224a5 --- /dev/null +++ b/src/Umbraco.Core/Models/TemporaryFileUpload/CreateTemporaryFileModel.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.TemporaryFile; + +public class CreateTemporaryFileModel : TemporaryFileModelBase +{ +} diff --git a/src/Umbraco.Core/Models/TemporaryFileUpload/TemporaryFileModel.cs b/src/Umbraco.Core/Models/TemporaryFileUpload/TemporaryFileModel.cs new file mode 100644 index 0000000000..5315c1ce58 --- /dev/null +++ b/src/Umbraco.Core/Models/TemporaryFileUpload/TemporaryFileModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models.TemporaryFile; + +public class TemporaryFileModel : TemporaryFileModelBase +{ + public required DateTime AvailableUntil { get; set; } +} diff --git a/src/Umbraco.Core/Models/TemporaryFileUpload/TemporaryFileModelBase.cs b/src/Umbraco.Core/Models/TemporaryFileUpload/TemporaryFileModelBase.cs new file mode 100644 index 0000000000..f33866d098 --- /dev/null +++ b/src/Umbraco.Core/Models/TemporaryFileUpload/TemporaryFileModelBase.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Models.TemporaryFile; + +public abstract class TemporaryFileModelBase +{ + public required string FileName { get; set; } + + public Guid Key { get; set; } + + public Func OpenReadStream { get; set; } = () => Stream.Null; +} diff --git a/src/Umbraco.Core/Persistence/Repositories/ITemporaryFileRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ITemporaryFileRepository.cs new file mode 100644 index 0000000000..62127870be --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/ITemporaryFileRepository.cs @@ -0,0 +1,34 @@ +using Umbraco.Cms.Core.Models.TemporaryFile; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +/// +/// Persists temporary files. +/// +public interface ITemporaryFileRepository +{ + /// + /// Gets a temporary file from its key. + /// + /// The unique key of the temporary file. + /// The temporary file model if found on that specified key, otherwise null. + Task GetAsync(Guid key); + + /// + /// Creates or update a temporary file. + /// + /// The model for the temporary file + Task SaveAsync(TemporaryFileModel model); + + /// + /// Deletes a temporary file using it's unique key. + /// + /// The unique key for the temporary file. + Task DeleteAsync(Guid key); + + /// + /// Removes all temporary files that have its TempFileModel.AvailableUntil lower than a specified time. + /// + /// The keys of the delete temporary files. + Task> CleanUpOldTempFiles(DateTime dateTime); +} diff --git a/src/Umbraco.Core/Services/ITemporaryFileService.cs b/src/Umbraco.Core/Services/ITemporaryFileService.cs new file mode 100644 index 0000000000..95c5a956da --- /dev/null +++ b/src/Umbraco.Core/Services/ITemporaryFileService.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface ITemporaryFileService +{ + Task> CreateAsync(CreateTemporaryFileModel createModel); + + Task> DeleteAsync(Guid key); + + Task GetAsync(Guid key); + + Task> CleanUpOldTempFiles(); +} diff --git a/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs b/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs new file mode 100644 index 0000000000..bcb70df2fa --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/TemporaryFileUploadStatus.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum TemporaryFileOperationStatus +{ + Success = 0, + FileExtensionNotAllowed = 1, + KeyAlreadyUsed = 2, + NotFound = 3, +} diff --git a/src/Umbraco.Core/Services/TemporaryFileService.cs b/src/Umbraco.Core/Services/TemporaryFileService.cs new file mode 100644 index 0000000000..78a5407c4a --- /dev/null +++ b/src/Umbraco.Core/Services/TemporaryFileService.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class TemporaryFileService : ITemporaryFileService +{ + private readonly ITemporaryFileRepository _temporaryFileRepository; + private RuntimeSettings _runtimeSettings; + private ContentSettings _contentSettings; + + public TemporaryFileService( + ITemporaryFileRepository temporaryFileRepository, + IOptionsMonitor runtimeOptionsMonitor, + IOptionsMonitor contentOptionsMonitor) + { + _temporaryFileRepository = temporaryFileRepository; + + _runtimeSettings = runtimeOptionsMonitor.CurrentValue; + _contentSettings = contentOptionsMonitor.CurrentValue; + + runtimeOptionsMonitor.OnChange(x => _runtimeSettings = x); + contentOptionsMonitor.OnChange(x => _contentSettings = x); + } + + public async Task> CreateAsync(CreateTemporaryFileModel createModel) + { + TemporaryFileOperationStatus validationResult = Validate(createModel); + if (validationResult != TemporaryFileOperationStatus.Success) + { + return Attempt.FailWithStatus(validationResult, null); + } + + TemporaryFileModel? temporaryFileModel = await _temporaryFileRepository.GetAsync(createModel.Key); + if (temporaryFileModel is not null) + { + return Attempt.FailWithStatus(TemporaryFileOperationStatus.KeyAlreadyUsed, null); + } + + temporaryFileModel = new TemporaryFileModel + { + Key = createModel.Key, + FileName = createModel.FileName, + OpenReadStream = createModel.OpenReadStream, + AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime) + }; + + await _temporaryFileRepository.SaveAsync(temporaryFileModel); + + return Attempt.Succeed(TemporaryFileOperationStatus.Success, temporaryFileModel); + } + + private TemporaryFileOperationStatus Validate(TemporaryFileModelBase temporaryFileModel) + => IsAllowedFileExtension(temporaryFileModel) == false + ? TemporaryFileOperationStatus.FileExtensionNotAllowed + : TemporaryFileOperationStatus.Success; + + private bool IsAllowedFileExtension(TemporaryFileModelBase temporaryFileModel) + { + var extension = Path.GetExtension(temporaryFileModel.FileName)[1..]; + + return _contentSettings.IsFileAllowedForUpload(extension); + } + + public async Task> DeleteAsync(Guid key) + { + TemporaryFileModel? model = await _temporaryFileRepository.GetAsync(key); + if (model is null) + { + return Attempt.FailWithStatus(TemporaryFileOperationStatus.NotFound, null); + } + + await _temporaryFileRepository.DeleteAsync(key); + + return Attempt.Succeed(TemporaryFileOperationStatus.Success, model); + } + + public async Task GetAsync(Guid key) => await _temporaryFileRepository.GetAsync(key); + + public async Task> CleanUpOldTempFiles() => await _temporaryFileRepository.CleanUpOldTempFiles(DateTime.Now); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 3afb9fe64a..bd8e6ac9aa 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -52,6 +52,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Extensions/TemporaryFileServiceExtensions.cs b/src/Umbraco.Infrastructure/Extensions/TemporaryFileServiceExtensions.cs new file mode 100644 index 0000000000..5f8310ab01 --- /dev/null +++ b/src/Umbraco.Infrastructure/Extensions/TemporaryFileServiceExtensions.cs @@ -0,0 +1,22 @@ +using System.Runtime.CompilerServices; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Extensions; + +public static class TemporaryFileServiceExtensions +{ + public static void EnlistDeleteIfScopeCompletes(this ITemporaryFileService temporaryFileService, Guid temporaryFileKey, IScopeProvider scopeProvider, [CallerMemberName] string memberName = "") + { + scopeProvider.Context?.Enlist( + temporaryFileKey.ToString(), + () => memberName, + (completed, svc) => + { + if (completed) + { + temporaryFileService.DeleteAsync(temporaryFileKey); + } + }); + } +} diff --git a/src/Umbraco.Infrastructure/HostedServices/TemporaryFileCleanup.cs b/src/Umbraco.Infrastructure/HostedServices/TemporaryFileCleanup.cs new file mode 100644 index 0000000000..bacc72609e --- /dev/null +++ b/src/Umbraco.Infrastructure/HostedServices/TemporaryFileCleanup.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Infrastructure.HostedServices; + +/// +/// Recurring hosted service that executes the temporary file cleanup. +/// +public class TemporaryFileCleanup : RecurringHostedServiceBase +{ + private readonly ILogger _logger; + private readonly IMainDom _mainDom; + private readonly IRuntimeState _runtimeState; + private readonly IServerRoleAccessor _serverRoleAccessor; + private readonly ITemporaryFileService _service; + + /// + /// Initializes a new instance of the class. + /// + public TemporaryFileCleanup( + IRuntimeState runtimeState, + ILogger logger, + ITemporaryFileService temporaryFileService, + IMainDom mainDom, + IServerRoleAccessor serverRoleAccessor) + : base(logger, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)) + { + _runtimeState = runtimeState; + _logger = logger; + _service = temporaryFileService; + _mainDom = mainDom; + _serverRoleAccessor = serverRoleAccessor; + } + + /// + public override Task PerformExecuteAsync(object? state) + { + if (_runtimeState.Level != RuntimeLevel.Run) + { + return Task.FromResult(true); // repeat... + } + + // Don't run on replicas nor unknown role servers + switch (_serverRoleAccessor.CurrentServerRole) + { + case ServerRole.Subscriber: + _logger.LogDebug("Does not run on subscriber servers"); + return Task.CompletedTask; + case ServerRole.Unknown: + _logger.LogDebug("Does not run on servers with unknown role"); + return Task.CompletedTask; + case ServerRole.Single: + case ServerRole.SchedulingPublisher: + default: + break; + } + + // Ensure we do not run if not main domain, but do NOT lock it + if (!_mainDom.IsMainDom) + { + _logger.LogDebug("Does not run if not MainDom"); + return Task.FromResult(false); // do NOT repeat, going down + } + + var count = _service.CleanUpOldTempFiles().GetAwaiter().GetResult().Count(); + + if (count > 0) + { + _logger.LogDebug("Deleted {Count} temporary file(s)", count); + } + else + { + _logger.LogDebug("Task complete, no items were deleted"); + } + + return Task.FromResult(true); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LocalFileSystemTemporaryFileRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LocalFileSystemTemporaryFileRepository.cs new file mode 100644 index 0000000000..7562e89689 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LocalFileSystemTemporaryFileRepository.cs @@ -0,0 +1,170 @@ +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +internal sealed class LocalFileSystemTemporaryFileRepository : ITemporaryFileRepository +{ + private const string MetaDataFileName = ".metadata"; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly ILogger _logger; + private readonly IJsonSerializer _jsonSerializer; + + public LocalFileSystemTemporaryFileRepository( + IHostingEnvironment hostingEnvironment, + ILogger logger, + IJsonSerializer jsonSerializer) + { + _hostingEnvironment = hostingEnvironment; + _logger = logger; + _jsonSerializer = jsonSerializer; + } + + private DirectoryInfo GetRootDirectory() + { + var path = Path.Combine(_hostingEnvironment.LocalTempPath, "TemporaryFile"); + + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + + return new DirectoryInfo(path); + } + + public async Task GetAsync(Guid key) + { + + DirectoryInfo rootDirectory = GetRootDirectory(); + + DirectoryInfo? fileDirectory = rootDirectory.GetDirectories(key.ToString()).FirstOrDefault(); + if (fileDirectory is null) + { + return null; + } + + FileInfo[] files = fileDirectory.GetFiles(); + + if (files.Length != 2) + { + _logger.LogError("Unexpected number of files in folder {FolderPath}", fileDirectory.FullName); + return null; + } + + var (actualFile, metadataFile) = GetFilesByType(files); + + FileMetaData metaData = await GetMetaDataAsync(metadataFile); + + return new TemporaryFileModel() + { + FileName = actualFile.Name, + Key = key, + OpenReadStream = () => actualFile.CreateReadStream(), + AvailableUntil = metaData.AvailableUntil + }; + } + + public async Task SaveAsync(TemporaryFileModel model) + { + ArgumentNullException.ThrowIfNull(nameof(model)); + + // Ensure folder does not exist + await DeleteAsync(model.Key); + + DirectoryInfo rootDirectory = GetRootDirectory(); + + DirectoryInfo fileDirectory = rootDirectory.CreateSubdirectory(model.Key.ToString()); + + var fullFileName = Path.Combine(fileDirectory.FullName, model.FileName); + var metadataFileName = Path.Combine(fileDirectory.FullName, MetaDataFileName); + + await Task.WhenAll( + CreateActualFile(model, fullFileName), + CreateMetadataFile(metadataFileName, new FileMetaData() + { + AvailableUntil = model.AvailableUntil + })); + } + + public Task DeleteAsync(Guid key) + { + DirectoryInfo rootDirectory = GetRootDirectory(); + + DirectoryInfo? fileDirectory = rootDirectory.GetDirectories(key.ToString()).FirstOrDefault(); + + if (fileDirectory is not null) + { + fileDirectory.Delete(true); + } + + return Task.CompletedTask; + } + + public async Task> CleanUpOldTempFiles(DateTime now) + { + DirectoryInfo rootDirectory = GetRootDirectory(); + + var keysToDelete = new List(); + + foreach (DirectoryInfo fileDirectory in rootDirectory.EnumerateDirectories()) + { + var metadataFileName = Path.Combine(fileDirectory.FullName, MetaDataFileName); + FileMetaData metaData = await GetMetaDataAsync(new PhysicalFileInfo(new FileInfo(metadataFileName))); + + if (metaData.AvailableUntil < now) + { + keysToDelete.Add(Guid.Parse(fileDirectory.Name)); + } + } + + await Task.WhenAll(keysToDelete.Select(DeleteAsync).ToArray()); + + return keysToDelete; + } + + private async Task CreateMetadataFile(string fullFilePath, FileMetaData metaData) + { + var metadataContent = _jsonSerializer.Serialize(metaData); + + await File.WriteAllTextAsync(fullFilePath, metadataContent); + } + + private static async Task CreateActualFile(TemporaryFileModel model, string fullFilePath) + { + FileStream fileStream = File.Create(fullFilePath); + await using var dataStream = model.OpenReadStream(); + dataStream.Seek(0, SeekOrigin.Begin); + await dataStream.CopyToAsync(fileStream); + fileStream.Close(); + } + + private async Task GetMetaDataAsync(IFileInfo metadataFile) + { + using var reader = new StreamReader(metadataFile.CreateReadStream()); + var fileContent = await reader.ReadToEndAsync(); + FileMetaData? result = _jsonSerializer.Deserialize(fileContent); + + if (result is not null) + { + return result; + } + + _logger.LogError("Unexpected metadata {FilePath}\n{FileContent}", metadataFile.PhysicalPath, fileContent); + throw new InvalidOperationException("Unexpected content"); + } + + private class FileMetaData + { + public DateTime AvailableUntil { get; init; } + } + + private (IFileInfo actualFile, IFileInfo metadataFile) GetFilesByType(FileInfo[] files) => + files[0].Name == MetaDataFileName + ? (new PhysicalFileInfo(files[1]), new PhysicalFileInfo(files[0])) + : (new PhysicalFileInfo(files[0]), new PhysicalFileInfo(files[1])); +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 27c098e54f..ec98ad4a22 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -185,6 +185,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService();