Temporary files endpoints for management api (#13944)

* Added temporary file uploads including a repository implementation using local temp folder.

* missing files

* Fixed copy paste error

* Updated OpenApi.json

* Updated OpenApi.json

* Added file extension check

* Clean up. Removed old TemporaryFileService and UploadFileService and updated dictionary items to use this new items

* Clean up

* Get rid of stream directly on TemporaryFileModel, and use delegate to open stream instead.

* Review changes

* Moved models to their own files

* Reverted launch settings

* Update open API JSON file

---------

Co-authored-by: kjac <kja@umbraco.dk>
This commit is contained in:
Bjarke Berg
2023-03-16 08:25:15 +01:00
committed by GitHub
parent 1e4c17a5f9
commit 835388ced0
36 changed files with 876 additions and 281 deletions

View File

@@ -32,7 +32,7 @@ public class ImportDictionaryController : DictionaryControllerBase
{
Attempt<IDictionaryItem?, DictionaryImportOperationStatus> result = await _dictionaryItemImportService
.ImportDictionaryItemFromUdtFileAsync(
importDictionaryRequestModel.FileName,
importDictionaryRequestModel.TemporaryFileKey,
importDictionaryRequestModel.ParentKey,
CurrentUserId(_backOfficeSecurityAccessor));
@@ -40,6 +40,7 @@ public class ImportDictionaryController : DictionaryControllerBase
{
DictionaryImportOperationStatus.Success => CreatedAtAction<ByKeyDictionaryController>(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.")

View File

@@ -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<IActionResult> Upload(IFormFile file)
{
Attempt<UdtFileUpload, UdtFileUploadOperationStatus> 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")
};
}
}

View File

@@ -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<IActionResult> ByKey(Guid key)
{
TemporaryFileModel? model = await _temporaryFileService.GetAsync(key);
if (model == null)
{
return NotFound();
}
return Ok(_umbracoMapper.Map<TemporaryFileModel, TemporaryFileResponseModel>(model));
}
}

View File

@@ -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<IActionResult> Create([FromForm] CreateTemporaryFileRequestModel model)
{
CreateTemporaryFileModel createModel = _umbracoMapper.Map<CreateTemporaryFileRequestModel, CreateTemporaryFileModel>(model)!;
Attempt<TemporaryFileModel?, TemporaryFileOperationStatus> result = await _temporaryFileService.CreateAsync(createModel);
return result.Success
? CreatedAtAction<ByKeyTemporaryFileController>(controller => nameof(controller.ByKey), new { key = result.Result!.Key })
: TemporaryFileStatusResult(result.Status);
}
}

View File

@@ -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<IActionResult> Delete(Guid key)
{
Attempt<TemporaryFileModel?, TemporaryFileOperationStatus> result = await _temporaryFileService.DeleteAsync(key);
return result.Success
? Ok()
: TemporaryFileStatusResult(result.Status);
}
}

View File

@@ -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");
}

View File

@@ -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<IUploadFileService, UploadFileService>();
builder.Services.AddTransient<ITemporaryFileService, TemporaryFileService>();
return builder;
}
}

View File

@@ -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<MapDefinitionCollectionBuilder>()
.Add<TemporaryFileViewModelsMapDefinition>();
return builder;
}
}

View File

@@ -32,11 +32,11 @@ public class ManagementApiComposer : IComposer
.AddMediaTypes()
.AddLanguages()
.AddDictionary()
.AddFileUpload()
.AddHealthChecks()
.AddModelsBuilder()
.AddRedirectUrl()
.AddTrackedReferences()
.AddTemporaryFiles()
.AddDataTypes()
.AddTemplates()
.AddRelationTypes()

View File

@@ -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<CreateTemporaryFileRequestModel, CreateTemporaryFileModel>((source, context) => new CreateTemporaryFileModel { FileName = string.Empty }, Map);
mapper.Define<TemporaryFileModel, TemporaryFileResponseModel>((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;
}
}

View File

@@ -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": {

View File

@@ -5,7 +5,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.OpenApi;
/// <summary>
/// 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.
/// </summary>
public class MimeTypeDocumentFilter : IDocumentFilter
{
@@ -15,8 +15,14 @@ public class MimeTypeDocumentFilter : IDocumentFilter
.SelectMany(path => path.Value.Operations.Values)
.ToArray();
void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType> content) =>
content.RemoveAll(r => r.Key != "application/json");
void RemoveUnwantedMimeTypes(IDictionary<string, OpenApiMediaType> 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)

View File

@@ -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<DictionaryItemImportService> _logger;
private readonly ITemporaryFileService _temporaryFileService;
private readonly IScopeProvider _scopeProvider;
public DictionaryItemImportService(
IDictionaryItemService dictionaryItemService,
PackageDataInstallation packageDataInstallation,
ILogger<DictionaryItemImportService> logger,
ITemporaryFileService temporaryFileService)
ITemporaryFileService temporaryFileService,
IScopeProvider scopeProvider)
{
_dictionaryItemService = dictionaryItemService;
_packageDataInstallation = packageDataInstallation;
_logger = logger;
_temporaryFileService = temporaryFileService;
_scopeProvider = scopeProvider;
}
public async Task<Attempt<IDictionaryItem?, DictionaryImportOperationStatus>> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId)
public async Task<Attempt<IDictionaryItem?, DictionaryImportOperationStatus>> 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<IDictionaryItem?, DictionaryImportOperationStatus>(DictionaryImportOperationStatus.TemporaryFileNotFound, null);
}
if (".udt".InvariantEquals(Path.GetExtension(temporaryFile.FileName)) == false)
{
return Attempt.FailWithStatus<IDictionaryItem?, DictionaryImportOperationStatus>(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<IDictionaryItem?, DictionaryImportOperationStatus>(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<IDictionaryItem?, DictionaryImportOperationStatus>(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);
}
}

View File

@@ -6,5 +6,5 @@ namespace Umbraco.Cms.Api.Management.Services;
public interface IDictionaryItemImportService
{
Task<Attempt<IDictionaryItem?, DictionaryImportOperationStatus>> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId);
Task<Attempt<IDictionaryItem?, DictionaryImportOperationStatus>> ImportDictionaryItemFromUdtFileAsync(Guid temporaryFileKey, Guid? parentKey, int userId = Constants.Security.SuperUserId);
}

View File

@@ -1,12 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace Umbraco.Cms.Api.Management.Services;
public interface ITemporaryFileService
{
Task<string> GetFilePathAsync(string fileName);
Task<string> SaveFileAsync(IFormFile file);
Task<bool> DeleteFileAsync(string fileName);
}

View File

@@ -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<Attempt<UdtFileUpload, UdtFileUploadOperationStatus>> UploadUdtFileAsync(IFormFile file);
}

View File

@@ -5,5 +5,6 @@ public enum DictionaryImportOperationStatus
Success,
ParentNotFound,
InvalidFileContent,
InvalidFileType
InvalidFileType,
TemporaryFileNotFound,
}

View File

@@ -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<TemporaryFileService> _logger;
public TemporaryFileService(IHostEnvironment hostEnvironment, ILogger<TemporaryFileService> logger)
{
_hostEnvironment = hostEnvironment;
_logger = logger;
}
public async Task<string> 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<string> 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<bool> 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);
}
}
}

View File

@@ -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<Attempt<UdtFileUpload, UdtFileUploadOperationStatus>> 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);
}
}

View File

@@ -2,7 +2,7 @@
public class ImportDictionaryRequestModel
{
public required string FileName { get; set; }
public required Guid TemporaryFileKey { get; set; }
public Guid? ParentKey { get; set; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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);
/// <summary>
/// Gets or sets the runtime mode.
/// </summary>
@@ -26,4 +27,10 @@ public class RuntimeSettings
/// Gets or sets a value for the maximum request length in kb.
/// </summary>
public int? MaxRequestLength { get; set; }
/// <summary>
/// Gets or sets the timespan temporary files are kept, before they are removed by a background task.
/// </summary>
[DefaultValue(StaticTemporaryFileLifeTime)]
public TimeSpan TemporaryFileLifeTime { get; set; } = TimeSpan.Parse(StaticTemporaryFileLifeTime);
}

View File

@@ -305,6 +305,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IMediaTypeService, MediaTypeService>();
Services.AddUnique<IFileService, FileService>();
Services.AddUnique<ITemplateService, TemplateService>();
Services.AddUnique<ITemporaryFileService, TemporaryFileService>();
Services.AddUnique<ITemplateContentParserService, TemplateContentParserService>();
Services.AddUnique<IEntityService, EntityService>();
Services.AddUnique<IRelationService, RelationService>();

View File

@@ -0,0 +1,5 @@
namespace Umbraco.Cms.Core.Models.TemporaryFile;
public class CreateTemporaryFileModel : TemporaryFileModelBase
{
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Core.Models.TemporaryFile;
public class TemporaryFileModel : TemporaryFileModelBase
{
public required DateTime AvailableUntil { get; set; }
}

View File

@@ -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<Stream> OpenReadStream { get; set; } = () => Stream.Null;
}

View File

@@ -0,0 +1,34 @@
using Umbraco.Cms.Core.Models.TemporaryFile;
namespace Umbraco.Cms.Core.Persistence.Repositories;
/// <summary>
/// Persists temporary files.
/// </summary>
public interface ITemporaryFileRepository
{
/// <summary>
/// Gets a temporary file from its key.
/// </summary>
/// <param name="key">The unique key of the temporary file.</param>
/// <returns>The temporary file model if found on that specified key, otherwise null.</returns>
Task<TemporaryFileModel?> GetAsync(Guid key);
/// <summary>
/// Creates or update a temporary file.
/// </summary>
/// <param name="model">The model for the temporary file</param>
Task SaveAsync(TemporaryFileModel model);
/// <summary>
/// Deletes a temporary file using it's unique key.
/// </summary>
/// <param name="key">The unique key for the temporary file.</param>
Task DeleteAsync(Guid key);
/// <summary>
/// Removes all temporary files that have its TempFileModel.AvailableUntil lower than a specified time.
/// </summary>
/// <returns>The keys of the delete temporary files.</returns>
Task<IEnumerable<Guid>> CleanUpOldTempFiles(DateTime dateTime);
}

View File

@@ -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<Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>> CreateAsync(CreateTemporaryFileModel createModel);
Task<Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>> DeleteAsync(Guid key);
Task<TemporaryFileModel?> GetAsync(Guid key);
Task<IEnumerable<Guid>> CleanUpOldTempFiles();
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
public enum TemporaryFileOperationStatus
{
Success = 0,
FileExtensionNotAllowed = 1,
KeyAlreadyUsed = 2,
NotFound = 3,
}

View File

@@ -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<RuntimeSettings> runtimeOptionsMonitor,
IOptionsMonitor<ContentSettings> contentOptionsMonitor)
{
_temporaryFileRepository = temporaryFileRepository;
_runtimeSettings = runtimeOptionsMonitor.CurrentValue;
_contentSettings = contentOptionsMonitor.CurrentValue;
runtimeOptionsMonitor.OnChange(x => _runtimeSettings = x);
contentOptionsMonitor.OnChange(x => _contentSettings = x);
}
public async Task<Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>> CreateAsync(CreateTemporaryFileModel createModel)
{
TemporaryFileOperationStatus validationResult = Validate(createModel);
if (validationResult != TemporaryFileOperationStatus.Success)
{
return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(validationResult, null);
}
TemporaryFileModel? temporaryFileModel = await _temporaryFileRepository.GetAsync(createModel.Key);
if (temporaryFileModel is not null)
{
return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(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<TemporaryFileModel?, TemporaryFileOperationStatus>.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<Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>> DeleteAsync(Guid key)
{
TemporaryFileModel? model = await _temporaryFileRepository.GetAsync(key);
if (model is null)
{
return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(TemporaryFileOperationStatus.NotFound, null);
}
await _temporaryFileRepository.DeleteAsync(key);
return Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>.Succeed(TemporaryFileOperationStatus.Success, model);
}
public async Task<TemporaryFileModel?> GetAsync(Guid key) => await _temporaryFileRepository.GetAsync(key);
public async Task<IEnumerable<Guid>> CleanUpOldTempFiles() => await _temporaryFileRepository.CleanUpOldTempFiles(DateTime.Now);
}

View File

@@ -52,6 +52,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<IServerRegistrationRepository, ServerRegistrationRepository>();
builder.Services.AddUnique<ITagRepository, TagRepository>();
builder.Services.AddUnique<ITemplateRepository, TemplateRepository>();
builder.Services.AddUnique<ITemporaryFileRepository, LocalFileSystemTemporaryFileRepository>();
builder.Services.AddUnique<IUserGroupRepository, UserGroupRepository>();
builder.Services.AddUnique<IUserRepository, UserRepository>();
builder.Services.AddUnique<IConsentRepository, ConsentRepository>();

View File

@@ -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);
}
});
}
}

View File

@@ -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;
/// <summary>
/// Recurring hosted service that executes the temporary file cleanup.
/// </summary>
public class TemporaryFileCleanup : RecurringHostedServiceBase
{
private readonly ILogger<TemporaryFileCleanup> _logger;
private readonly IMainDom _mainDom;
private readonly IRuntimeState _runtimeState;
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly ITemporaryFileService _service;
/// <summary>
/// Initializes a new instance of the <see cref="TemporaryFileCleanup" /> class.
/// </summary>
public TemporaryFileCleanup(
IRuntimeState runtimeState,
ILogger<TemporaryFileCleanup> 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;
}
/// <inheritdoc />
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);
}
}

View File

@@ -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<LocalFileSystemTemporaryFileRepository> _logger;
private readonly IJsonSerializer _jsonSerializer;
public LocalFileSystemTemporaryFileRepository(
IHostingEnvironment hostingEnvironment,
ILogger<LocalFileSystemTemporaryFileRepository> 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<TemporaryFileModel?> 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<IEnumerable<Guid>> CleanUpOldTempFiles(DateTime now)
{
DirectoryInfo rootDirectory = GetRootDirectory();
var keysToDelete = new List<Guid>();
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<FileMetaData> GetMetaDataAsync(IFileInfo metadataFile)
{
using var reader = new StreamReader(metadataFile.CreateReadStream());
var fileContent = await reader.ReadToEndAsync();
FileMetaData? result = _jsonSerializer.Deserialize<FileMetaData>(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]));
}

View File

@@ -185,6 +185,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddHostedService<KeepAlive>();
builder.Services.AddHostedService<LogScrubber>();
builder.Services.AddHostedService<ContentVersionCleanup>();
builder.Services.AddHostedService<TemporaryFileCleanup>();
builder.Services.AddHostedService<ScheduledPublishing>();
builder.Services.AddHostedService<TempFileCleanup>();
builder.Services.AddHostedService<InstructionProcessTask>();