diff --git a/Directory.Build.props b/Directory.Build.props index 91faf8d..4834e4f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ - 3.400.0 + 3.800.0 $(VersionSuffix)-$(BuildNumber) diff --git a/VirtoCommerce.FileExperienceApi.sln.DotSettings b/VirtoCommerce.FileExperienceApi.sln.DotSettings index 5182197..a73f203 100644 --- a/VirtoCommerce.FileExperienceApi.sln.DotSettings +++ b/VirtoCommerce.FileExperienceApi.sln.DotSettings @@ -1,4 +1,5 @@ - + + <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> True diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Authorization/IFileAuthorizationRequirementFactory.cs b/src/VirtoCommerce.FileExperienceApi.Core/Authorization/IFileAuthorizationRequirementFactory.cs new file mode 100644 index 0000000..e36b598 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Authorization/IFileAuthorizationRequirementFactory.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Models; + +namespace VirtoCommerce.FileExperienceApi.Core.Authorization +{ + public interface IFileAuthorizationRequirementFactory + { + public string Scope { get; } + IAuthorizationRequirement Create(File file, string permission); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Authorization/IFileAuthorizationService.cs b/src/VirtoCommerce.FileExperienceApi.Core/Authorization/IFileAuthorizationService.cs new file mode 100644 index 0000000..2ca969b --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Authorization/IFileAuthorizationService.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Models; + +namespace VirtoCommerce.FileExperienceApi.Core.Authorization; + +public interface IFileAuthorizationService +{ + Task AuthorizeAsync(ClaimsPrincipal user, File file, string permission); +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Models/File.cs b/src/VirtoCommerce.FileExperienceApi.Core/Models/File.cs new file mode 100644 index 0000000..dd49c8b --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Models/File.cs @@ -0,0 +1,14 @@ +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.FileExperienceApi.Core.Models; + +public class File : Entity +{ + public string Scope { get; set; } + public string Name { get; set; } + public string ContentType { get; set; } + public long Size { get; set; } + public string Url { get; set; } + public string OwnerEntityId { get; set; } + public string OwnerEntityType { get; set; } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadError.cs b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadError.cs new file mode 100644 index 0000000..d09cc9c --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadError.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace VirtoCommerce.FileExperienceApi.Core.Models; + +public static class FileUploadError +{ + public static FileUploadResult InvalidContentType(string contentType) + { + return FileUploadResult.Fail("INVALID_CONTENT_TYPE", $"Expected a multipart request, but got '{contentType}'", contentType); + } + + public static FileUploadResult InvalidContent() + { + return FileUploadResult.Fail("INVALID_CONTENT", "Cannot read file"); + } + + public static FileUploadResult Exception(Exception ex, string fileName = null) + { + return FileUploadResult.Fail("EXCEPTION", ex.Message, parameter: null, fileName); + } + + public static FileUploadResult InvalidScope(string scope, string fileName) + { + return FileUploadResult.Fail("INVALID_SCOPE", $"Unknown scope '{scope}'", scope, fileName); + } + + public static FileUploadResult InvalidExtension(IList allowedExtensions, string fileName) + { + var joinedExtensions = string.Join(", ", allowedExtensions); + return FileUploadResult.Fail("INVALID_EXTENSION", $"Allowed file extensions: {joinedExtensions}", allowedExtensions, fileName); + } + + public static FileUploadResult InvalidSize(long maxSize, string fileName) + { + return FileUploadResult.Fail("INVALID_SIZE", $"Maximum allowed file size: {maxSize}", maxSize, fileName); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadOptions.cs b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadOptions.cs new file mode 100644 index 0000000..1941fde --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadOptions.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace VirtoCommerce.FileExperienceApi.Core.Models; + +public class FileUploadOptions +{ + [Required] + public string RootPath { get; set; } = "upload"; + + [Required] + public IList Scopes { get; set; } = new List(); +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadRequest.cs b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadRequest.cs new file mode 100644 index 0000000..522f387 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadRequest.cs @@ -0,0 +1,11 @@ +using System.IO; + +namespace VirtoCommerce.FileExperienceApi.Core.Models; + +public class FileUploadRequest +{ + public string Scope { get; set; } + public string UserId { get; set; } + public string FileName { get; set; } + public Stream Stream { get; set; } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadResult.cs b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadResult.cs new file mode 100644 index 0000000..3a73ba4 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadResult.cs @@ -0,0 +1,37 @@ +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.FileExperienceApi.Core.Models; + +public class FileUploadResult : File +{ + public bool Succeeded => string.IsNullOrEmpty(ErrorMessage); + public string ErrorCode { get; set; } + public string ErrorMessage { get; set; } + public object ErrorParameter { get; set; } + + public static FileUploadResult Success(File file) + { + var result = AbstractTypeFactory.TryCreateInstance(); + + result.Scope = file.Scope; + result.Id = file.Id; + result.Name = file.Name; + result.ContentType = file.ContentType; + result.Size = file.Size; + result.Url = file.Url; + + return result; + } + + public static FileUploadResult Fail(string code, string message, object parameter = null, string fileName = null) + { + var result = AbstractTypeFactory.TryCreateInstance(); + + result.ErrorCode = code; + result.ErrorMessage = message; + result.ErrorParameter = parameter; + result.Name = fileName; + + return result; + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadScopeOptions.cs b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadScopeOptions.cs new file mode 100644 index 0000000..4076c26 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadScopeOptions.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace VirtoCommerce.FileExperienceApi.Core.Models +{ + public class FileUploadScopeOptions + { + [Required] + public string Scope { get; set; } + + [Required] + public long MaxFileSize { get; set; } + + [Required] + public IList AllowedExtensions { get; set; } = new List(); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/ModuleConstants.cs b/src/VirtoCommerce.FileExperienceApi.Core/ModuleConstants.cs index e0c1cfa..0c9d476 100644 --- a/src/VirtoCommerce.FileExperienceApi.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.FileExperienceApi.Core/ModuleConstants.cs @@ -6,7 +6,7 @@ public static class Security { public static class Permissions { - public const string Access = "Files:access"; + public const string Access = "FileExperienceApi:access"; public const string Create = "FileExperienceApi:create"; public const string Read = "FileExperienceApi:read"; public const string Update = "FileExperienceApi:update"; diff --git a/src/VirtoCommerce.FileExperienceApi.Core/Services/IFileUploadService.cs b/src/VirtoCommerce.FileExperienceApi.Core/Services/IFileUploadService.cs new file mode 100644 index 0000000..eaaa973 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Core/Services/IFileUploadService.cs @@ -0,0 +1,15 @@ +using System.IO; +using System.Threading.Tasks; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.Platform.Core.GenericCrud; +using File = VirtoCommerce.FileExperienceApi.Core.Models.File; + +namespace VirtoCommerce.FileExperienceApi.Core.Services +{ + public interface IFileUploadService : ICrudService + { + Task GetOptionsAsync(string scope); + Task UploadFileAsync(FileUploadRequest request); + Task OpenReadAsync(string id); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Core/VirtoCommerce.FileExperienceApi.Core.csproj b/src/VirtoCommerce.FileExperienceApi.Core/VirtoCommerce.FileExperienceApi.Core.csproj index 16083eb..c62632c 100644 --- a/src/VirtoCommerce.FileExperienceApi.Core/VirtoCommerce.FileExperienceApi.Core.csproj +++ b/src/VirtoCommerce.FileExperienceApi.Core/VirtoCommerce.FileExperienceApi.Core.csproj @@ -1,12 +1,14 @@ - net6.0 + net8.0 false - + + + diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Authorization/FileAuthorizationRequirement.cs b/src/VirtoCommerce.FileExperienceApi.Data/Authorization/FileAuthorizationRequirement.cs new file mode 100644 index 0000000..237b358 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Authorization/FileAuthorizationRequirement.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.Platform.Core; +using VirtoCommerce.Platform.Security.Authorization; + +namespace VirtoCommerce.FileExperienceApi.Data.Authorization; + +public class FileAuthorizationRequirement : PermissionAuthorizationRequirement +{ + public FileAuthorizationRequirement(string permission) + : base(permission) + { + } +} + +public class FileAuthorizationHandler : PermissionAuthorizationHandlerBase +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FileAuthorizationRequirement requirement) + { + var authorized = context.User.IsInRole(PlatformConstants.Security.SystemRoles.Administrator); + + if (!authorized && context.Resource is File file) + { + // Authorize only if the file is not attached to any entity. + // Attached files should be processed by a different handler. + authorized = string.IsNullOrEmpty(file.OwnerEntityId); + } + + if (authorized) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Authorization/FileAuthorizationService.cs b/src/VirtoCommerce.FileExperienceApi.Data/Authorization/FileAuthorizationService.cs new file mode 100644 index 0000000..a4cabef --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Authorization/FileAuthorizationService.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.FileExperienceApi.Data.Authorization; + +public class FileAuthorizationService : IFileAuthorizationService +{ + private readonly IAuthorizationService _authorizationService; + private readonly IEnumerable _requirementFactories; + + public FileAuthorizationService( + IAuthorizationService authorizationService, + IEnumerable requirementFactories) + { + _authorizationService = authorizationService; + _requirementFactories = requirementFactories; + } + + public Task AuthorizeAsync(ClaimsPrincipal user, File file, string permission) + { + var requirementFactory = _requirementFactories.FirstOrDefault(x => x.Scope.EqualsInvariant(file.Scope)); + var requirement = requirementFactory?.Create(file, permission) ?? new FileAuthorizationRequirement(permission); + + return _authorizationService.AuthorizeAsync(user, file, requirement); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommand.cs b/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommand.cs new file mode 100644 index 0000000..8834544 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommand.cs @@ -0,0 +1,17 @@ +using GraphQL.Types; +using VirtoCommerce.ExperienceApiModule.Core.Infrastructure; + +namespace VirtoCommerce.FileExperienceApi.Data.Commands; + +public class DeleteFileCommand : ICommand +{ + public string Id { get; set; } +} + +public class DeleteFileCommandType : InputObjectGraphType +{ + public DeleteFileCommandType() + { + Field(x => x.Id); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommandBuilder.cs b/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommandBuilder.cs new file mode 100644 index 0000000..4cf1da9 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommandBuilder.cs @@ -0,0 +1,53 @@ +using System.Threading.Tasks; +using GraphQL; +using GraphQL.Types; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using VirtoCommerce.ExperienceApiModule.Core.BaseQueries; +using VirtoCommerce.ExperienceApiModule.Core.Extensions; +using VirtoCommerce.ExperienceApiModule.Core.Infrastructure.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Services; +using VirtoCommerce.Platform.Core.Common; +using FilePermissions = VirtoCommerce.FileExperienceApi.Core.ModuleConstants.Security.Permissions; + +namespace VirtoCommerce.FileExperienceApi.Data.Commands; + +public class DeleteFileCommandBuilder : CommandBuilder +{ + protected override string Name => "DeleteFile"; + + private readonly IFileUploadService _fileUploadService; + private readonly IFileAuthorizationService _fileAuthorizationService; + + public DeleteFileCommandBuilder( + IMediator mediator, + IAuthorizationService authorizationService, + IFileUploadService fileUploadService, + IFileAuthorizationService fileAuthorizationService) + : base(mediator, authorizationService) + { + _fileUploadService = fileUploadService; + _fileAuthorizationService = fileAuthorizationService; + } + + protected override async Task BeforeMediatorSend(IResolveFieldContext context, DeleteFileCommand request) + { + await base.BeforeMediatorSend(context, request); + + var file = await _fileUploadService.GetNoCloneAsync(request.Id); + if (file is null) + { + return; + } + + var authorizationResult = await _fileAuthorizationService.AuthorizeAsync(context.GetCurrentPrincipal(), file, FilePermissions.Delete); + + if (!authorizationResult.Succeeded) + { + throw context.IsAuthenticated() + ? AuthorizationError.Forbidden() + : AuthorizationError.AnonymousAccessDenied(); + } + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommandHandler.cs b/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommandHandler.cs new file mode 100644 index 0000000..0d0175b --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Commands/DeleteFileCommandHandler.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using VirtoCommerce.FileExperienceApi.Core.Services; + +namespace VirtoCommerce.FileExperienceApi.Data.Commands; + +public class DeleteFileCommandHandler : IRequestHandler +{ + private readonly IFileUploadService _fileUploadService; + + public DeleteFileCommandHandler(IFileUploadService fileUploadService) + { + _fileUploadService = fileUploadService; + } + + public async Task Handle(DeleteFileCommand request, CancellationToken cancellationToken) + { + await _fileUploadService.DeleteAsync(new[] { request.Id }); + + return true; + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQuery.cs b/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQuery.cs new file mode 100644 index 0000000..7b1f583 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQuery.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using GraphQL; +using GraphQL.Types; +using VirtoCommerce.ExperienceApiModule.Core.BaseQueries; +using VirtoCommerce.FileExperienceApi.Core.Models; + +namespace VirtoCommerce.FileExperienceApi.Data.Queries; + +public class OptionsQuery : Query +{ + public string Scope { get; set; } + + + public override IEnumerable GetArguments() + { + yield return Argument(nameof(Scope)); + } + + public override void Map(IResolveFieldContext context) + { + Scope = context.GetArgument(nameof(Scope)); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQueryBuilder.cs b/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQueryBuilder.cs new file mode 100644 index 0000000..53443f4 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQueryBuilder.cs @@ -0,0 +1,17 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using VirtoCommerce.ExperienceApiModule.Core.BaseQueries; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.FileExperienceApi.Data.Schemas; + +namespace VirtoCommerce.FileExperienceApi.Data.Queries; + +public class OptionsQueryBuilder : QueryBuilder +{ + protected override string Name => "FileUploadOptions"; + + public OptionsQueryBuilder(IMediator mediator, IAuthorizationService authorizationService) + : base(mediator, authorizationService) + { + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQueryHandler.cs b/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQueryHandler.cs new file mode 100644 index 0000000..c979831 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Queries/OptionsQueryHandler.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using VirtoCommerce.ExperienceApiModule.Core.Infrastructure; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.FileExperienceApi.Core.Services; + +namespace VirtoCommerce.FileExperienceApi.Data.Queries; + +public class OptionsQueryHandler : IQueryHandler +{ + private readonly IFileUploadService _fileUploadService; + + public OptionsQueryHandler(IFileUploadService fileUploadService) + { + _fileUploadService = fileUploadService; + } + + public Task Handle(OptionsQuery request, CancellationToken cancellationToken) + { + return _fileUploadService.GetOptionsAsync(request.Scope); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Schemas/FileUploadScopeOptionsType.cs b/src/VirtoCommerce.FileExperienceApi.Data/Schemas/FileUploadScopeOptionsType.cs new file mode 100644 index 0000000..3d05421 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Schemas/FileUploadScopeOptionsType.cs @@ -0,0 +1,14 @@ +using VirtoCommerce.ExperienceApiModule.Core.Schemas; +using VirtoCommerce.FileExperienceApi.Core.Models; + +namespace VirtoCommerce.FileExperienceApi.Data.Schemas; + +public class FileUploadScopeOptionsType : ExtendableGraphType +{ + public FileUploadScopeOptionsType() + { + Field(x => x.Scope); + Field(x => x.MaxFileSize); + Field(x => x.AllowedExtensions); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/Services/FileUploadService.cs b/src/VirtoCommerce.FileExperienceApi.Data/Services/FileUploadService.cs new file mode 100644 index 0000000..05560c8 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Data/Services/FileUploadService.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using VirtoCommerce.AssetsModule.Core.Assets; +using VirtoCommerce.AssetsModule.Core.Services; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.FileExperienceApi.Core.Services; +using VirtoCommerce.Platform.Core.Common; +using File = VirtoCommerce.FileExperienceApi.Core.Models.File; +using UrlHelpers = VirtoCommerce.Platform.Core.Extensions.UrlHelperExtensions; + +namespace VirtoCommerce.FileExperienceApi.Data.Services; + +public class FileUploadService : IFileUploadService +{ + private readonly StringComparer _ignoreCase = StringComparer.OrdinalIgnoreCase; + + private readonly FileUploadOptions _options; + private readonly IFileExtensionService _fileExtensionService; + private readonly IAssetEntryService _assetEntryService; + private readonly IBlobStorageProvider _blobProvider; + + public FileUploadService( + IFileExtensionService fileExtensionService, + IOptions options, + IAssetEntryService assetEntryService, + IBlobStorageProvider blobProvider) + { + _options = options.Value; + _fileExtensionService = fileExtensionService; + _assetEntryService = assetEntryService; + _blobProvider = blobProvider; + } + + public virtual async Task GetOptionsAsync(string scope) + { + var options = GetOptions(scope); + if (options == null) + { + return null; + } + + return new FileUploadScopeOptions + { + Scope = options.Scope, + MaxFileSize = options.MaxFileSize, + AllowedExtensions = await GetEffectiveAllowedExtensionsAsync(options.AllowedExtensions), + }; + } + + public virtual async Task UploadFileAsync(FileUploadRequest request) + { + var options = GetOptions(request.Scope); + if (options is null) + { + return FileUploadError.InvalidScope(request.Scope, request.FileName); + } + + var fileExtension = Path.GetExtension(request.FileName); + if (!await IsExtensionAllowedAsync(fileExtension, options.AllowedExtensions)) + { + return FileUploadError.InvalidExtension(await GetEffectiveAllowedExtensionsAsync(options.AllowedExtensions), request.FileName); + } + + var blobInfo = AbstractTypeFactory.TryCreateInstance(); + blobInfo.Name = Path.GetFileName(request.FileName); + blobInfo.ContentType = MimeTypeResolver.ResolveContentType(blobInfo.Name); + + // Internal URL: rootPath/scope/userId/newGuid.ext + var internalFileName = Path.ChangeExtension(NewGuid(), fileExtension); + blobInfo.RelativeUrl = BuildFileUrl(_options.RootPath, options.Scope, request.UserId, internalFileName); + + await using (var targetStream = await _blobProvider.OpenWriteAsync(blobInfo.RelativeUrl)) + { + await request.Stream.CopyToAsync(targetStream); + blobInfo.Size = targetStream.Position; + } + + if (blobInfo.Size > options.MaxFileSize) + { + await _blobProvider.RemoveAsync(new[] { blobInfo.RelativeUrl }); + return FileUploadError.InvalidSize(options.MaxFileSize, request.FileName); + } + + var asset = AbstractTypeFactory.TryCreateInstance(); + asset.Id = NewGuid(); + asset.Group = options.Scope; + asset.BlobInfo = blobInfo; + + await _assetEntryService.SaveChangesAsync(new[] { asset }); + + var file = ConvertToFile(asset); + return FileUploadResult.Success(file); + } + + public virtual async Task OpenReadAsync(string id) + { + var asset = await _assetEntryService.GetNoCloneAsync(id); + + return asset is null + ? null + : await _blobProvider.OpenReadAsync(asset.BlobInfo.RelativeUrl); + } + + public async Task> GetAsync(IList ids, string responseGroup = null, bool clone = true) + { + var assets = await _assetEntryService.GetNoCloneAsync(ids); + + var files = assets.Select(ConvertToFile).ToList(); + return files; + } + + public virtual async Task DeleteAsync(IList ids, bool softDelete = false) + { + var assets = await _assetEntryService.GetNoCloneAsync(ids); + + if (assets.Any()) + { + var existingIds = assets.Select(x => x.Id).ToArray(); + await _assetEntryService.DeleteAsync(existingIds); + + var existingUrls = assets.Select(x => x.BlobInfo.RelativeUrl).ToArray(); + await _blobProvider.RemoveAsync(existingUrls); + } + } + + public virtual Task SaveChangesAsync(IList models) + { + var assets = models.Select(ConvertToAsset).ToList(); + return _assetEntryService.SaveChangesAsync(assets); + } + + + protected virtual FileUploadScopeOptions GetOptions(string scope) + { + return _options.Scopes.FirstOrDefault(x => x.Scope.EqualsInvariant(scope)); + } + + protected virtual async Task IsExtensionAllowedAsync(string extension, IList allowedExtensions) + { + if (allowedExtensions.IsNullOrEmpty()) + { + return await _fileExtensionService.IsExtensionAllowedAsync(extension); + } + + return allowedExtensions.Contains(extension, _ignoreCase) && + await _fileExtensionService.IsExtensionAllowedAsync(extension); + } + + protected virtual async Task> GetEffectiveAllowedExtensionsAsync(IList allowedExtensions) + { + IList result; + + var whiteList = await _fileExtensionService.GetWhiteListAsync(); + + if (allowedExtensions.IsNullOrEmpty()) + { + result = whiteList.IsNullOrEmpty() + ? Array.Empty() + : whiteList; + } + else + { + result = whiteList.IsNullOrEmpty() + ? allowedExtensions.Except(await _fileExtensionService.GetBlackListAsync(), _ignoreCase).ToArray() + : allowedExtensions.Intersect(whiteList, _ignoreCase).ToArray(); + } + + return result; + } + + protected virtual string BuildFileUrl(params string[] parts) + { + string result = null; + + foreach (var (part, i) in parts.Select((x, i) => (x, i))) + { + result = i == 0 + ? part + : UrlHelpers.Combine(result, part); + } + + return result; + } + + protected virtual File ConvertToFile(AssetEntry asset) + { + var result = AbstractTypeFactory.TryCreateInstance(); + + result.Id = asset.Id; + result.Scope = asset.Group; + + if (asset.BlobInfo != null) + { + result.Name = asset.BlobInfo.Name; + result.ContentType = asset.BlobInfo.ContentType; + result.Size = asset.BlobInfo.Size; + result.Url = asset.BlobInfo.RelativeUrl; + } + + if (asset.Tenant != null) + { + result.OwnerEntityId = asset.Tenant.Id; + result.OwnerEntityType = asset.Tenant.Type; + } + + return result; + } + + protected virtual AssetEntry ConvertToAsset(File file) + { + var result = AbstractTypeFactory.TryCreateInstance(); + + result.Id = file.Id; + result.Group = file.Scope; + + result.BlobInfo = AbstractTypeFactory.TryCreateInstance(); + result.BlobInfo.Name = file.Name; + result.BlobInfo.ContentType = file.ContentType; + result.BlobInfo.Size = file.Size; + result.BlobInfo.RelativeUrl = file.Url; + + if (!string.IsNullOrEmpty(file.OwnerEntityId) || !string.IsNullOrEmpty(file.OwnerEntityType)) + { + result.Tenant = new TenantIdentity(file.OwnerEntityId, file.OwnerEntityType); + } + + return result; + } + + protected virtual string NewGuid() + { + return Guid.NewGuid().ToString("N"); + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Data/VirtoCommerce.FileExperienceApi.Data.csproj b/src/VirtoCommerce.FileExperienceApi.Data/VirtoCommerce.FileExperienceApi.Data.csproj index c43513f..f5c11f6 100644 --- a/src/VirtoCommerce.FileExperienceApi.Data/VirtoCommerce.FileExperienceApi.Data.csproj +++ b/src/VirtoCommerce.FileExperienceApi.Data/VirtoCommerce.FileExperienceApi.Data.csproj @@ -1,14 +1,15 @@ - net6.0 + net8.0 false - - + + + diff --git a/src/VirtoCommerce.FileExperienceApi.Web/Controllers/FileUploadController.cs b/src/VirtoCommerce.FileExperienceApi.Web/Controllers/FileUploadController.cs new file mode 100644 index 0000000..df16833 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Web/Controllers/FileUploadController.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using VirtoCommerce.AssetsModule.Core.Swagger; +using VirtoCommerce.ExperienceApiModule.Core; +using VirtoCommerce.FileExperienceApi.Core.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.FileExperienceApi.Core.Services; +using VirtoCommerce.FileExperienceApi.Web.Filters; +using VirtoCommerce.Platform.Core; +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Platform.Core.Security; +using VirtoCommerce.Platform.Data.Helpers; +using FilePermissions = VirtoCommerce.FileExperienceApi.Core.ModuleConstants.Security.Permissions; + +namespace VirtoCommerce.FileExperienceApi.Web.Controllers; + +[Route("api/files")] +[Authorize] +public class FileUploadController : Controller +{ + private readonly SignInManager _signInManager; + private readonly IFileUploadService _fileUploadService; + private readonly IFileAuthorizationService _fileAuthorizationService; + private static readonly FormOptions _defaultFormOptions = new(); + + public FileUploadController( + SignInManager signInManager, + IFileUploadService fileUploadService, + IFileAuthorizationService fileAuthorizationService) + { + _signInManager = signInManager; + _fileUploadService = fileUploadService; + _fileAuthorizationService = fileAuthorizationService; + } + + [HttpPost("{scope}")] + [UploadFile] + [DisableFormValueModelBinding] + public async Task>> UploadFiles([FromRoute] string scope) + { + var user = await GetCurrentUser(); + if (user is null) + { + return Forbid(); + } + + // https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-6.0 + if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType)) + { + return new[] { FileUploadError.InvalidContentType(Request.ContentType) }; + } + + var results = new List(); + + try + { + var boundary = MultipartRequestHelper.GetBoundary(MediaTypeHeaderValue.Parse(Request.ContentType), _defaultFormOptions.MultipartBoundaryLengthLimit); + var reader = new MultipartReader(boundary, Request.Body); + var section = await reader.ReadNextSectionAsync(); + + while (section != null) + { + if (ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition) && + MultipartRequestHelper.HasFileContentDisposition(contentDisposition)) + { + var result = await SaveFile(contentDisposition.FileName.Value, section.Body); + results.Add(result); + } + else + { + results.Add(FileUploadError.InvalidContent()); + } + + section = await reader.ReadNextSectionAsync(); + } + } + catch (Exception ex) + { + results.Add(FileUploadError.Exception(ex)); + } + + return results; + + async Task SaveFile(string fileName, Stream stream) + { + try + { + var request = AbstractTypeFactory.TryCreateInstance(); + request.Scope = scope; + request.UserId = GetUserId(user); + request.FileName = fileName; + request.Stream = stream; + + var result = await _fileUploadService.UploadFileAsync(request); + + if (result.Url != null) + { + // Hide real URL + result.Url = Url.Action(nameof(DownloadFile), new { result.Id }); + } + + return result; + } + catch (Exception ex) + { + return FileUploadError.Exception(ex, fileName); + } + } + } + + [HttpGet("{id}")] + public async Task DownloadFile([FromRoute] string id) + { + var user = await GetCurrentUser(); + if (user is null) + { + return Forbid(); + } + + var file = await _fileUploadService.GetNoCloneAsync(id); + if (file is null) + { + return NotFound(); + } + + var authorizationResult = await _fileAuthorizationService.AuthorizeAsync(user, file, FilePermissions.Read); + if (!authorizationResult.Succeeded) + { + return Forbid(); + } + + Stream stream; + + try + { + stream = await _fileUploadService.OpenReadAsync(id); + } + catch + { + stream = null; + } + + return stream is null + ? NotFound() + : File(stream, file.ContentType, file.Name); + } + + + // Temporary workaround for requests from the storefront. Delete after getting rid of the storefront. + private async Task GetCurrentUser() + { + var principal = User; + + if (Request.Headers.TryGetValue("VirtoCommerce-User-Name", out var userNameFromHeader) && + principal.IsInRole(PlatformConstants.Security.SystemRoles.Administrator)) + { + principal = null; + + if (userNameFromHeader != AnonymousUser.UserName) + { + var user = await _signInManager.UserManager.FindByNameAsync(userNameFromHeader); + if (user != null) + { + principal = await _signInManager.CreateUserPrincipalAsync(user); + } + } + } + + return principal; + } + + private static string GetUserId(ClaimsPrincipal user) + { + return + user.FindFirstValue(ClaimTypes.NameIdentifier) ?? + user.FindFirstValue("name") ?? + AnonymousUser.UserName; + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Web/Filters/DisableFormValueModelBindingAttribute.cs b/src/VirtoCommerce.FileExperienceApi.Web/Filters/DisableFormValueModelBindingAttribute.cs new file mode 100644 index 0000000..74791b1 --- /dev/null +++ b/src/VirtoCommerce.FileExperienceApi.Web/Filters/DisableFormValueModelBindingAttribute.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace VirtoCommerce.FileExperienceApi.Web.Filters; + +/// +/// This attribute is used to disable model binding for the upload action +/// More information https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-6.0 +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter +{ + public void OnResourceExecuting(ResourceExecutingContext context) + { + var factories = context.ValueProviderFactories; + factories.RemoveType(); + factories.RemoveType(); + factories.RemoveType(); + } + + public void OnResourceExecuted(ResourceExecutedContext context) + { + // This method intentionally left empty + } +} diff --git a/src/VirtoCommerce.FileExperienceApi.Web/Module.cs b/src/VirtoCommerce.FileExperienceApi.Web/Module.cs index 466f643..9df3742 100644 --- a/src/VirtoCommerce.FileExperienceApi.Web/Module.cs +++ b/src/VirtoCommerce.FileExperienceApi.Web/Module.cs @@ -1,12 +1,18 @@ using GraphQL.Server; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using VirtoCommerce.ExperienceApiModule.Core.Extensions; using VirtoCommerce.ExperienceApiModule.Core.Infrastructure; using VirtoCommerce.FileExperienceApi.Core; +using VirtoCommerce.FileExperienceApi.Core.Authorization; +using VirtoCommerce.FileExperienceApi.Core.Models; +using VirtoCommerce.FileExperienceApi.Core.Services; using VirtoCommerce.FileExperienceApi.Data; +using VirtoCommerce.FileExperienceApi.Data.Authorization; +using VirtoCommerce.FileExperienceApi.Data.Services; using VirtoCommerce.Platform.Core.Modularity; using VirtoCommerce.Platform.Core.Security; @@ -25,6 +31,11 @@ public void Initialize(IServiceCollection serviceCollection) serviceCollection.AddMediatR(assemblyMarker); serviceCollection.AddAutoMapper(assemblyMarker); serviceCollection.AddSchemaBuilders(assemblyMarker); + + serviceCollection.AddOptions().Bind(Configuration.GetSection("FileUpload")).ValidateDataAnnotations(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); } public void PostInitialize(IApplicationBuilder appBuilder) diff --git a/src/VirtoCommerce.FileExperienceApi.Web/VirtoCommerce.FileExperienceApi.Web.csproj b/src/VirtoCommerce.FileExperienceApi.Web/VirtoCommerce.FileExperienceApi.Web.csproj index 9108bb6..68a1d1d 100644 --- a/src/VirtoCommerce.FileExperienceApi.Web/VirtoCommerce.FileExperienceApi.Web.csproj +++ b/src/VirtoCommerce.FileExperienceApi.Web/VirtoCommerce.FileExperienceApi.Web.csproj @@ -1,6 +1,6 @@ - net6.0 + net8.0 Library diff --git a/src/VirtoCommerce.FileExperienceApi.Web/module.manifest b/src/VirtoCommerce.FileExperienceApi.Web/module.manifest index 680e352..25913d0 100644 --- a/src/VirtoCommerce.FileExperienceApi.Web/module.manifest +++ b/src/VirtoCommerce.FileExperienceApi.Web/module.manifest @@ -1,12 +1,13 @@ VirtoCommerce.FileExperienceApi - 3.400.0 + 3.800.0 - 3.414.19 + 3.800.0 - + + Virto Commerce FileExperienceApi module diff --git a/tests/VirtoCommerce.FileExperienceApi.Tests/VirtoCommerce.FileExperienceApi.Tests.csproj b/tests/VirtoCommerce.FileExperienceApi.Tests/VirtoCommerce.FileExperienceApi.Tests.csproj index 899a65c..205ad15 100644 --- a/tests/VirtoCommerce.FileExperienceApi.Tests/VirtoCommerce.FileExperienceApi.Tests.csproj +++ b/tests/VirtoCommerce.FileExperienceApi.Tests/VirtoCommerce.FileExperienceApi.Tests.csproj @@ -1,6 +1,6 @@ - net6.0 + net8.0 false @@ -12,9 +12,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + +