Skip to content

Commit

Permalink
VCST-121: Add file upload (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
artem-dudarev committed Feb 5, 2024
1 parent dbe3187 commit 8dab545
Show file tree
Hide file tree
Showing 30 changed files with 893 additions and 16 deletions.
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<!-- These properties will be shared for all projects -->
<PropertyGroup>
<VersionPrefix>3.400.0</VersionPrefix>
<VersionPrefix>3.800.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
<VersionSuffix Condition=" '$(VersionSuffix)' != '' AND '$(BuildNumber)' != '' ">$(VersionSuffix)-$(BuildNumber)</VersionSuffix>
</PropertyGroup>
Expand Down
3 changes: 2 additions & 1 deletion VirtoCommerce.FileExperienceApi.sln.DotSettings
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateConstants/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticReadonly/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /&gt;</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=virto/@EntryIndexedValue">True</s:Boolean>
</wpf:ResourceDictionary>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, File file, string permission);
}
14 changes: 14 additions & 0 deletions src/VirtoCommerce.FileExperienceApi.Core/Models/File.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
38 changes: 38 additions & 0 deletions src/VirtoCommerce.FileExperienceApi.Core/Models/FileUploadError.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<FileUploadScopeOptions> Scopes { get; set; } = new List<FileUploadScopeOptions>();
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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<FileUploadResult>.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<FileUploadResult>.TryCreateInstance();

result.ErrorCode = code;
result.ErrorMessage = message;
result.ErrorParameter = parameter;
result.Name = fileName;

return result;
}
}
Original file line number Diff line number Diff line change
@@ -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<string> AllowedExtensions { get; set; } = new List<string>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File>
{
Task<FileUploadScopeOptions> GetOptionsAsync(string scope);
Task<FileUploadResult> UploadFileAsync(FileUploadRequest request);
Task<Stream> OpenReadAsync(string id);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<!-- Project is not a test project -->
<SonarQubeTestProject>false</SonarQubeTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
<PackageReference Include="VirtoCommerce.Platform.Core" Version="3.800.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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<FileAuthorizationRequirement>
{
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<IFileAuthorizationRequirementFactory> _requirementFactories;

public FileAuthorizationService(
IAuthorizationService authorizationService,
IEnumerable<IFileAuthorizationRequirementFactory> requirementFactories)
{
_authorizationService = authorizationService;
_requirementFactories = requirementFactories;
}

public Task<AuthorizationResult> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using GraphQL.Types;
using VirtoCommerce.ExperienceApiModule.Core.Infrastructure;

namespace VirtoCommerce.FileExperienceApi.Data.Commands;

public class DeleteFileCommand : ICommand<bool>
{
public string Id { get; set; }
}

public class DeleteFileCommandType : InputObjectGraphType<DeleteFileCommand>
{
public DeleteFileCommandType()
{
Field(x => x.Id);
}
}
Original file line number Diff line number Diff line change
@@ -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<DeleteFileCommand, bool, DeleteFileCommandType, BooleanGraphType>
{
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<object> 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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<DeleteFileCommand, bool>
{
private readonly IFileUploadService _fileUploadService;

public DeleteFileCommandHandler(IFileUploadService fileUploadService)
{
_fileUploadService = fileUploadService;
}

public async Task<bool> Handle(DeleteFileCommand request, CancellationToken cancellationToken)
{
await _fileUploadService.DeleteAsync(new[] { request.Id });

return true;
}
}

0 comments on commit 8dab545

Please sign in to comment.