I know that you have uploaded a file once or twice in your life. But did you get it right? I know I messed up a couple of times. I mean, there was always a question in my head: how do you test if file is a virus? And the little devil on my shoulder would always answer: don’t worry about it.
Honestly, I though it is really complicated. Guess what? It’s not.
How would you test if file is a virus? Yes, you are right, you would check it with an antivirus. And guess what we are going to do? Yup, we are going to run antivirus – ClamAv with docker, and send file we want to check to that container using nClam library.
Additionally, we are going to implement a set of endpoints that I think are enough to cover most file requirements API needs (uploading an avatar :P)
Long story short, when I design a File REST endpoint I like to have three “types” of methods
- For upload purposes
- These methods are used only for an upload. (In this post, I’m going to have just one method for single file upload)
- For download purposes
- These methods are used for an actual download. (Also, just gonna have one fore single file download)
- For metadata purposes
- Method that serves as meta data of files. They get enough information to the UI so that UI can make a download request. (they can ping our download endpoints using file id).
Introduction
When it comes to a file upload, ASP.NET Core supports
- Buffering
- The entire file is read into IFormFile which is C# interface used to process/save the file
- Used for small files
- Streaming
- the file is received from multipart request and directly processed/saved by the app
- Used for large files
When it comes down to storage, you can store them in
- Database
- Physical storage
- Cloud data storage service (eg. Azure Blob storage)
When it comes down to security
- Allow only approved file extensions
- Check file signature where possible
- File signature is a unique identification number located at the beginning of a file.
- Check the size of uploaded file
- Never trust filename provided by a client.
- Use a safe file name determined by your application
- Check if file is a virus
Implementation
We are going to create ASP.NET Core Web API that has 3 endpoints
- POST api/v1/files
- Uploads a single file, you can easily extend this to support multiple files.
- GET api/v1/files
- Gets the list of file metadata.
- GET api/v1/files/{id}
- Gets a single file metadata by id
- GET api/v1/files/{id}/download
- Downloads a file
Company.Shorts Solution
- Lets add docker compose file which will
- create container for Postgres
- create container for NClam
version: "3.3"
services:
company-shorts-antivirus:
image: "clamav/clamav"
container_name: company-shorts-antivirus
restart: unless-stopped
ports:
- "3310:3310"
company-shorts-postgres:
image: "postgres:15.1-alpine"
container_name: company-shorts-postgres
volumes:
- /var/lib/postgresql/data
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
restart: unless-stopped
ports:
- "5432:5432"
Company.Shorts
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<UserSecretsId>cce98938-947d-45b7-86fe-161ef15f8c52</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Company.Shorts.Application\Company.Shorts.Application.csproj" />
<ProjectReference Include="..\Blocks\Company.Shorts.Blocks.Bootstrap\Company.Shorts.Blocks.Bootstrap.csproj" />
<ProjectReference Include="..\Blocks\Company.Shorts.Blocks.Common.Mapping.Configuration\Company.Shorts.Blocks.Common.Mapping.Configuration.csproj" />
<ProjectReference Include="..\Infrastructure\Company.Shorts.Infrastructure.Antivirus\Company.Shorts.Infrastructure.Antivirus.csproj" />
<ProjectReference Include="..\Infrastructure\Company.Shorts.Infrastructure.Db.Postgres\Company.Shorts.Infrastructure.Db.Postgres.csproj" />
<ProjectReference Include="..\Presentation\Company.Shorts.Presentation.Api\Company.Shorts.Presentation.Api.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Company.Shorts.Integrations.Tests" />
</ItemGroup>
</Project>
Folder structure
- Create Program.cs
- Create Startup.cs
namespace Company.Shorts
{
using Company.Shorts.Blocks.Bootstrap;
using System.Threading.Tasks;
public class Program
{
public static async Task Main(string[] args) => await ApplicationLauncher.RunAsync<Startup>(args);
}
}
namespace Company.Shorts
{
using Company.Shorts.Application;
using Company.Shorts.Blocks.Common.Mapping.Configuration;
using Company.Shorts.Blocks.Common.Serilog.Configuration;
using Company.Shorts.Blocks.Common.Swagger.Configuration;
using Company.Shorts.Blocks.Presentation.Api.Configuration;
using Company.Shorts.Infrastructure.Antivirus;
using Company.Shorts.Infrastructure.Db.Postgres;
using Company.Shorts.Presentation.Api;
using Hellang.Middleware.ProblemDetails;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
public sealed class Startup
{
public Startup(
IConfiguration configuration,
IWebHostEnvironment environment)
{
Configuration = configuration;
Environment = environment;
}
public IConfiguration Configuration { get; }
public IWebHostEnvironment Environment { get; }
public PostgresAdapterSettings? PostgresAdapterSettings =>
Configuration
.GetSection(PostgresAdapterSettings.Key)
.Get<PostgresAdapterSettings>();
public AntivirusAdapterSettings? AntivirusAdapterSettings =>
Configuration
.GetSection(AntivirusAdapterSettings.Key)
.Get<AntivirusAdapterSettings>();
public void ConfigureServices(IServiceCollection services)
{
services.AddCors();
services.AddHealthChecks();
services.AddAntivirusLayer(this.AntivirusAdapterSettings ?? throw new ArgumentException(nameof(this.AntivirusAdapterSettings)));
services.AddPostgresDatabaseLayer(this.PostgresAdapterSettings ?? throw new ArgumentException(nameof(this.PostgresAdapterSettings)));
services.AddApplicationLayer();
services.AddPresentationLayer(Environment);
services.AddAutoMapperConfiguration(AppDomain.CurrentDomain);
}
public void Configure(IApplicationBuilder app)
{
app.UseProblemDetails();
if (!Environment.IsDevelopment())
{
app.UseHsts();
}
app.MigratePostgresDb();
app.UseSwaggerConfiguration();
app.UseHttpsRedirection();
app.UseCors(options => options
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
app.UseSerilogConfiguration();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
endpoints.MapDefaultHealthCheckRoute();
});
}
}
}
- Let’s add our secrets.json
{
"PostgresAdapterSettings:Url": "Server=localhost;Port=5432;Database=ShortsUserDb;User Id=postgres;Password=postgres",
"Kestrel:Certificates:Development:Password": "11f1358b-8f90-4c2f-804c-2ccc856566ea",
"ExternalApiSettings:Url": "http://localhost:1080",
"ExampleAdapterSettings:Url": "",
"AntivirusAdapterSettings:Server": "localhost",
"AntivirusAdapterSettings:Port": 3310
}
Company.Shorts.Domain
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Folder structure
- Seedwork
- Enumeration.cs file
- File.cs
- FileExtensions.cs
namespace Company.Shorts.Domain.Seedwork
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
public abstract class Enumeration : IComparable
{
public string Name { get; private set; }
public int Id { get; private set; }
protected Enumeration(int id, string name) => (Id, Name) = (id, name);
public override string ToString() => Name;
public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
typeof(T).GetFields(BindingFlags.NonPublic |
BindingFlags.Static |
BindingFlags.DeclaredOnly)
.Select(f => f.GetValue(null))
.Cast<T>();
public override bool Equals(object? obj)
{
if (obj is not Enumeration otherValue)
{
return false;
}
var typeMatches = GetType().Equals(obj.GetType());
var valueMatches = Id.Equals(otherValue.Id);
return typeMatches && valueMatches;
}
public override int GetHashCode() => Id.GetHashCode();
public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
{
var absoluteDifference = Math.Abs(firstValue.Id - secondValue.Id);
return absoluteDifference;
}
public static T FromValue<T>(int value) where T : Enumeration
{
var matchingItem = Parse<T, int>(value, "value", item => item.Id == value);
return matchingItem;
}
public static T FromDisplayName<T>(string displayName) where T : Enumeration
{
var matchingItem = Parse<T, string>(displayName, "display name", item => item.Name == displayName);
return matchingItem;
}
private static T Parse<T, K>(K value, string description, Func<T, bool> predicate) where T : Enumeration
{
var matchingItem = GetAll<T>().FirstOrDefault(predicate);
if (matchingItem == null)
{
throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}");
}
return matchingItem;
}
public int CompareTo(object? other) => Id.CompareTo(((Enumeration?)other)?.Id);
}
}
namespace Company.Shorts.Domain
{
using System;
public class File
{
public File(Guid id, string name, byte[] data, string extension, string contentType, DateTimeOffset createdAt)
{
Id = id;
Name = name;
Data = data;
Extension = extension;
ContentType = contentType;
CreatedAt = createdAt;
}
public Guid Id { get; }
public string Name { get; }
public byte[] Data { get; }
public string Extension { get; }
public string ContentType { get; }
public DateTimeOffset CreatedAt { get; }
}
}
namespace Company.Shorts.Domain
{
using Company.Shorts.Domain.Seedwork;
public class FileExtension : Enumeration
{
public FileExtension(int id, string name, List<byte[]> signatures) : base(id, name)
{
this.Signatures = signatures;
}
public List<byte[]> Signatures { get; }
private static readonly FileExtension gif = new(
1,
".gif",
new List<byte[]> { new byte[] { 0x47, 0x49, 0x46, 0x38 } }
);
private static readonly FileExtension png = new(
2,
",png",
new List<byte[]> { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } }
);
private static readonly FileExtension jpeg = new(
3,
".jpeg",
new List<byte[]>
{
new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 },
}
);
private static readonly FileExtension jpg = new(
4,
".jpg",
new List<byte[]>
{
new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE8 },
}
);
private static readonly FileExtension txt = new(
5,
".txt",
new List<byte[]> { }
);
public static FileExtension Gif { get; } = gif;
public static FileExtension Png { get; } = png;
public static FileExtension Jpeg { get; } = jpeg;
public static FileExtension Jpg { get; } = jpg;
public static FileExtension Txt { get; } = txt;
}
}
Company.Shorts.Application.Contracts
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.5.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.5.2" />
<PackageReference Include="MediatR" Version="12.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Application.Contracts\Company.Shorts.Blocks.Application.Contracts.csproj" />
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Application.Core\Company.Shorts.Blocks.Application.Core.csproj" />
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Common.Mapping.Core\Company.Shorts.Blocks.Common.Mapping.Core.csproj" />
<ProjectReference Include="..\Company.Shorts.Application.Contracts\Company.Shorts.Application.Contracts.csproj" />
</ItemGroup>
</Project>
Folder structure
- Db
- IFileRepository.cs
- IUnitOfWork.cs
- Security
- IAntivirusService.cs
namespace Company.Shorts.Application.Contracts.Db
{
using Company.Shorts.Domain;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
public interface IFileRepository
{
void Add(File file);
Task<File> GetbyIdSafeAsync(Guid id, CancellationToken cancellation);
Task<File?> GetByIdAsync(Guid id, CancellationToken cancellation);
Task<List<File>> GetAsync(int skip, int take, CancellationToken cancellation);
}
}
namespace Company.Shorts.Application.Contracts.Db
{
using System.Threading;
using System.Threading.Tasks;
public interface IUnitOfWork
{
Task SaveAsync(CancellationToken cancellationToken);
public IFileRepository Files { get; }
}
}
namespace Company.Shorts.Application.Contracts.Security
{
using Company.Shorts.Domain;
using System.Threading.Tasks;
public interface IAntivirusService
{
Task<bool> IsVirusAsync(byte[] data);
}
}
Company.Shorts.Infrastructure.Db.Postgres
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Features" Version="7.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.4" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Application\Company.Shorts.Application.Contracts\Company.Shorts.Application.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>
Folder structure
- Internal
- Configuration
- FileEntityTypeConfiguration.cs – used for configuration of our PostgresDbContext
- Repositories
- FileRepository.cs -repository for our files
- PostgresDbContext.cs – our DbContext
- PostgresDbContextFactory.cs – used for running migration
- UnitOfWork.cs – unit of work wrapper around PostgresDbContext
- Configuration
- DependenyInjection.cs -used for registration of our layer
namespace Company.Shorts.Infrastructure.Db.Postgres.Internal.Configuration
{
using Company.Shorts.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
internal sealed class FileEntityTypeConfiguration : IEntityTypeConfiguration<File>
{
public void Configure(EntityTypeBuilder<File> builder)
{
builder.ToTable("files");
builder.HasKey(k => k.Id);
builder.Property(p => p.Name).IsRequired();
builder.Property(p => p.Data).IsRequired();
builder.Property(p => p.Extension).IsRequired();
builder.Property(p => p.ContentType).IsRequired();
builder.Property(p => p.CreatedAt).IsRequired();
}
}
}
namespace Company.Shorts.Infrastructure.Db.Postgres.Internal.Repositories
{
using Company.Shorts.Application.Contracts.Db;
using Company.Shorts.Domain;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
internal sealed class FileRepository : IFileRepository
{
private readonly DbSet<File> files;
public FileRepository(PostgresDbContext dbContext)
{
files = dbContext.Set<File>();
}
public void Add(File file)
{
files.Add(file);
}
public async Task<List<File>> GetAsync(int skip, int take, CancellationToken cancellation)
{
return await this.files.Skip(skip).Take(take).OrderBy(ob => ob.CreatedAt).ToListAsync(cancellation);
}
public Task<File?> GetByIdAsync(Guid id, CancellationToken cancellation)
{
return files.FirstOrDefaultAsync(s => s.Id == id, cancellation);
}
public async Task<File> GetbyIdSafeAsync(Guid id, CancellationToken cancellation)
{
return await this.GetByIdAsync(id, cancellation) ?? throw new ApplicationException("Unable to find file.");
}
}
}
namespace Company.Shorts.Infrastructure.Db.Postgres.Internal
{
using Company.Shorts.Domain;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
internal sealed class PostgresDbContext : DbContext
{
public PostgresDbContext(DbContextOptions options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}
}
namespace Company.Shorts.Infrastructure.Db.Postgres.Internal
{
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using System;
internal sealed class PostgresDbContextFactory : IDesignTimeDbContextFactory<PostgresDbContext>
{
public PostgresDbContext CreateDbContext(string[] args)
{
IConfigurationRoot configuration = BuildConfiguration(args);
var optionsBuilder = new DbContextOptionsBuilder<PostgresDbContext>();
var connectionString = configuration.GetSection(args[0]).Value;
optionsBuilder.UseNpgsql(connectionString);
PostgresDbContext instance = new(optionsBuilder.Options);
return instance is null
? throw new InvalidOperationException($"Unable to initialize {nameof(PostgresDbContext)} instance.")
: instance;
}
private static IConfigurationRoot BuildConfiguration(string[] args)
{
return new ConfigurationBuilder()
.SetBasePath(args[2])
.AddJsonFile(
path: "appsettings.json",
optional: false,
reloadOnChange: true)
.AddEnvironmentVariables()
.AddUserSecrets(args[1])
.AddCommandLine(args)
.Build();
}
}
}
namespace Company.Shorts.Infrastructure.Db.Postgres.Internal
{
using Company.Shorts.Application.Contracts.Db;
using System.Threading;
using System.Threading.Tasks;
internal sealed class UnitOfWork : IUnitOfWork
{
private readonly PostgresDbContext dbContext;
public UnitOfWork(PostgresDbContext dbContext, IFileRepository fileRepository)
{
this.dbContext = dbContext;
Files = fileRepository;
}
public IFileRepository Files { get; }
public async Task SaveAsync(CancellationToken cancellationToken)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
}
}
namespace Company.Shorts.Infrastructure.Db.Postgres
{
using Company.Shorts.Application.Contracts.Db;
using Company.Shorts.Infrastructure.Db.Postgres.Internal;
using Company.Shorts.Infrastructure.Db.Postgres.Internal.Repositories;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
public static class DependencyInjection
{
public static IServiceCollection AddPostgresDatabaseLayer(this IServiceCollection services, PostgresAdapterSettings settings)
{
services.AddDbContext<PostgresDbContext>(options =>
{
options.UseNpgsql(settings.Url);
});
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IFileRepository, FileRepository>();
return services;
}
public static IApplicationBuilder MigratePostgresDb(this IApplicationBuilder builder)
{
using var scope = builder.ApplicationServices.CreateScope();
using var dbContext = scope.ServiceProvider.GetService<PostgresDbContext>();
if (dbContext is null)
{
throw new ApplicationException(nameof(dbContext));
}
if (dbContext.Database.GetPendingMigrations().Any())
{
dbContext.Database.Migrate();
}
return builder;
}
}
public class PostgresAdapterSettings
{
public const string Key = nameof(PostgresAdapterSettings);
public string Url { get; set; } = default!;
}
}
Company.Shorts.Infrastructure.Antivirus
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="nClam" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Application\Company.Shorts.Application.Contracts\Company.Shorts.Application.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
In order to figure out if our file is virus or not, we need some kind of anti-virus program. Luckily, there is open source antivirus called ClamAv. How can we used it in our application? Easy, we are going to run it inside a container and send file that we wish to scan.
Folder structure
- Internal
- AntivirusService.cs – we are going to use nClam library to use ClamClient for communicating with our antivirus
- DependencyInjection.cs
namespace Company.Shorts.Infrastructure.Antivirus.Internal
{
using Company.Shorts.Application.Contracts.Security;
using Company.Shorts.Domain;
using nClam;
using System.Threading.Tasks;
internal sealed class AntivirusService : IAntivirusService
{
private readonly ClamClient client;
public AntivirusService(ClamClient client)
{
this.client = client;
}
public async Task<bool> IsVirusAsync(byte[] data)
{
var scanResult = await client.SendAndScanFileAsync(data);
var result = scanResult.Result switch
{
ClamScanResults.VirusDetected => true,
_ => false
};
return result;
}
}
}
namespace Company.Shorts.Infrastructure.Antivirus
{
using Company.Shorts.Application.Contracts.Security;
using Company.Shorts.Infrastructure.Antivirus.Internal;
using Microsoft.Extensions.DependencyInjection;
using nClam;
public static class DependencyInjection
{
public static IServiceCollection AddAntivirusLayer(this IServiceCollection services, AntivirusAdapterSettings settings)
{
services.AddScoped<ClamClient>(provider =>
{
var clamClient = new ClamClient(settings.Server, settings.Port);
return clamClient;
});
services.AddScoped<IAntivirusService, AntivirusService>();
return services;
}
}
public class AntivirusAdapterSettings
{
public const string Key = nameof(AntivirusAdapterSettings);
public string Server { get; set; } = default!;
public int Port { get; set; }
}
}
If you wish to test with a file that is a virus (or at least a test virus), create a text file and paste this in:
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
Now you can use that file when you are testing uploads.
Company.Shorts.Presentation
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Application\Company.Shorts.Application\Company.Shorts.Application.csproj" />
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Common.Serilog.Configuration\Company.Shorts.Blocks.Common.Serilog.Configuration.csproj" />
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Common.Swagger.Configuration\Company.Shorts.Blocks.Common.Swagger.Configuration.csproj" />
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Presentation.Api.Configuration\Company.Shorts.Blocks.Presentation.Api.Configuration.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Folder Include="Internal\Configuration\" />
<Folder Include="Internal\Examples\V1\" />
</ItemGroup>
</Project>
Folder structure
- Controllers
- V1
- FileController.cs
- ApiControllerBase.cs
- V1
- Internal
- Constants
- ApiHeader.cs
- ApiTags.cs
- ApiVersions.cs
- Extensions
- FormFileExtensions.cs
- Mappings
- PresentationMappingProfile.cs
- DependencyInjection.cs
- Constants
If you take a look at the Post method, you are going to notice that we are accepting IFormFile – this is the representation of file in c#.
Also, if you look at DownloadById method, you are going to notice that we are returning FileContentResult, which is IActionResult.
namespace Company.Shorts.Presentation.Api.Controllers.V1
{
using Company.Shorts.Application.Files;
using Company.Shorts.Application.Files.Models;
using Company.Shorts.Presentation.Api.Internal.Constants;
using Company.Shorts.Presentation.Api.Internal.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
[ApiVersion(ApiVersions.V1)]
public class FileController : ApiControllerBase
{
public FileController(IMediator mediator) : base(mediator)
{
}
[HttpGet]
[SwaggerOperation(OperationId = nameof(Get), Tags = new[] { ApiTags.Files })]
[ProducesResponseType(typeof(List<FileResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Get([FromQuery] GetFilesQuery query, CancellationToken cancellationToken)
{
return await this.ProcessAsync<GetFilesQuery, List<FileResponse>>(query, cancellationToken);
}
[HttpPost]
[SwaggerOperation(OperationId = nameof(Post), Tags = new[] { ApiTags.Files })]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Post(IFormFile formFile, CancellationToken cancellationToken)
{
var command = await formFile.AsCreateFileCommandAsync(cancellationToken);
return await this.ProcessAsync<CreateFileCommand, FileResponse>(command, cancellationToken);
}
[HttpGet("{id}")]
[SwaggerOperation(OperationId = nameof(GetById), Tags = new[] { ApiTags.Files })]
[ProducesResponseType(typeof(FileResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetById(Guid id, CancellationToken cancellationToken)
{
return await this.ProcessAsync<GetFileByIdQuery, FileResponse>(new GetFileByIdQuery(id), cancellationToken);
}
[HttpGet("{id}/download")]
[SwaggerOperation(OperationId = nameof(DownloadById), Tags = new[] { ApiTags.Files })]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<FileContentResult> DownloadById(Guid id, CancellationToken cancellationToken)
{
FileDownloadResponse result = await this.Mediator.Send(new DownloadFileByIdQuery(id), cancellationToken);
return new FileContentResult(result.Data, result.ContentType)
{
FileDownloadName = result.Name
};
}
}
}
namespace Company.Shorts.Presentation.Api.Internal.Extensions
{
using Company.Shorts.Application.Files;
using Microsoft.AspNetCore.Http;
using System;
using System.Threading.Tasks;
internal static class FormFileExtensions
{
public static async Task<CreateFileCommand> AsCreateFileCommandAsync(this IFormFile file, CancellationToken cancellationToken)
{
if (file is null)
{
throw new ApplicationException("Unable to resolve file");
}
string untrustedFileName = Path.GetFileNameWithoutExtension(file.FileName).ToLowerInvariant();
string extension = Path.GetExtension(file.FileName).ToLowerInvariant();
using var stream = new MemoryStream();
await file.CopyToAsync(stream, cancellationToken);
var bytes = stream.ToArray();
var result = new CreateFileCommand(untrustedFileName, bytes, extension, file.ContentDisposition, file.ContentType);
return result;
}
}
}
namespace Company.Shorts.Presentation.Api.Controllers
{
using MediatR;
using Microsoft.AspNetCore.Mvc;
using System.Net.Mime;
using System.Threading;
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Route("api/v{version:apiVersion}/[controller]")]
public abstract class ApiControllerBase : ControllerBase
{
protected ApiControllerBase(
IMediator mediator)
{
this.Mediator = mediator;
}
public IMediator Mediator { get; }
protected async Task<IActionResult> ProcessAsync<TCommand, TResponse>(
TCommand command, CancellationToken cancellationToken)
where TCommand : IRequest<TResponse>
{
TResponse result = await Mediator.Send(command, cancellationToken);
if (result is null)
{
return NotFound();
}
return Ok(result);
}
protected async Task<IActionResult> ProcessAsync<TCommand>(
TCommand command,
CancellationToken cancellationToken)
where TCommand : IRequest
{
await Mediator.Send(command, cancellationToken);
return NoContent();
}
}
}
namespace Company.Shorts.Presentation.Api
{
using Company.Shorts.Blocks.Common.Swagger.Configuration;
using Company.Shorts.Blocks.Presentation.Api.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Swashbuckle.AspNetCore.Filters;
using System.Reflection;
public static class DependecyInjection
{
public static IServiceCollection AddPresentationLayer(this IServiceCollection services, IHostEnvironment environment)
{
services
.AddSwaggerConfiguration();
services
.AddRestApiConfiguration(environment)
.AddSwaggerExamplesFromAssemblies(Assembly.GetExecutingAssembly());
return services;
}
}
}
namespace Company.Shorts.Presentation.Api.Internal.Constants
{
internal static class ApiHeaders
{
public const string RoleId = "x-role-id";
}
}
namespace Company.Shorts.Presentation.Api.Internal.Constants
{
internal static class ApiTags
{
public const string Files = nameof(Files);
}
}
namespace Company.Shorts.Presentation.Api.Internal.Constants
{
internal static class ApiVersions
{
public const string V1 = "1.0";
}
}
Company.Shorts.Application
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.5.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.5.2" />
<PackageReference Include="MediatR" Version="12.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Application.Contracts\Company.Shorts.Blocks.Application.Contracts.csproj" />
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Application.Core\Company.Shorts.Blocks.Application.Core.csproj" />
<ProjectReference Include="..\..\Blocks\Company.Shorts.Blocks.Common.Mapping.Core\Company.Shorts.Blocks.Common.Mapping.Core.csproj" />
<ProjectReference Include="..\Company.Shorts.Application.Contracts\Company.Shorts.Application.Contracts.csproj" />
</ItemGroup>
</Project>
Folder structure
- Files
- Models
- FileDownloadResponse.cs
- FileResponse.cs
- CreateFileCommand.cs
- You can split validator, handler and command in 3 separate files if you like. I prefer having it this way since all logic for that feature is at one place.
- CreateFileCommand.cs
- Simple DTO
- CreateFileCommandValidator.cs
- Checks if name is not empty
- Checks if ContentDisposition is of “form-data”
- Checks if extension is allowed and if file signature of extension is correct
- Checks if file is correct size and if it is a virus.
- CreateFileCommandHandler.cs
- Creates a file and saves it to the database
- DownloadFileById.cs
- Gets file with data by id.
- GetFileByIdQuery.cs
- Gets file metadata by id.
- GetFilesQuery.cs
- Gets files metadata.
- Models
- Internal
- Mappings
- ApplicationMappingProfile.cs
- Mapping definitions
- ApplicationMappingProfile.cs
- Mappings
namespace Company.Shorts.Application.Files.Models
{
using System;
public class FileDownloadResponse
{
public Guid Id { get; set; }
public string Name { get; set; } = default!;
public byte[] Data { get; set; } = default!;
public string Extension { get; set; } = default!;
public DateTimeOffset CreatedAt { get; set; }
public string ContentType { get; set; } = default!;
public string ContentDisposition { get; set; } = default!;
}
}
namespace Company.Shorts.Application.Files.Models
{
using System;
using Company.Shorts.Domain;
public class FileResponse
{
public Guid Id { get; set; }
public string Name { get; set; } = default!;
public string Extension { get; set; } = default!;
public DateTimeOffset CreatedAt { get; set; }
public string ContentType { get; set; } = default!;
}
}
namespace Company.Shorts.Application.Files
{
using AutoMapper;
using Company.Shorts.Application.Contracts.Security;
using Company.Shorts.Application.Files.Models;
using Company.Shorts.Blocks.Application.Contracts;
using Company.Shorts.Domain;
using Company.Shorts.Domain.Seedwork;
using FluentValidation;
using MediatR;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using IUnitOfWork = Contracts.Db.IUnitOfWork;
public class CreateFileCommand : IRequest<FileResponse>
{
public CreateFileCommand(string name, byte[] data, string extension, string contentDisposition, string contentType)
{
Name = name;
Data = data;
Extension = extension;
ContentDisposition = contentDisposition;
ContentType = contentType;
}
public string Name { get; }
public byte[] Data { get; }
public string Extension { get; }
public string ContentDisposition { get; }
public string ContentType { get; }
}
internal sealed class CreateFileCommandValidator : AbstractValidator<CreateFileCommand>
{
private const int AllowedFileSize = 1048576 * 3;
private const string FormData = "form-data";
private readonly IAntivirusService antivirusService;
public CreateFileCommandValidator(IAntivirusService antivirusService)
{
this.antivirusService = antivirusService;
this.RuleFor(r => r.Name)
.NotEmpty();
this.RuleFor(r => r.ContentDisposition)
.NotEmpty()
.Must(IsContentDispositionFormData)
.WithMessage(command => $"Content-Disposition: '{command.ContentDisposition}' is invalid.");
this.RuleFor(r => r.Extension)
.NotEmpty()
.Must((command, _) => IsValidFileExtension(command))
.WithMessage(command => $"Extension: '{command.Extension}' is not supported.");
this.RuleFor(r => r.Data)
.NotEmpty()
.Must(IsValidFileLenght)
.WithMessage("File is to large for upload.")
.MustAsync(async (s, a) => !(await IsVirus(s)))
.WithMessage("Potentally harmful file detected.");
}
/// <summary>
/// Checks if content disposition contains form-data.
/// </summary>
/// <param name="contentDisposition">Content disposition</param>
/// <returns>True if correct content disposition is provided.</returns>
private bool IsContentDispositionFormData(string contentDisposition)
{
if (string.IsNullOrWhiteSpace(contentDisposition))
{
return false;
}
return contentDisposition.Contains(FormData);
}
/// <summary>
/// Checks if file is a virus.
/// </summary>
/// <param name="data">File</param>
/// <returns>True if file is a virus.</returns>
private async Task<bool> IsVirus(byte[] data) => await this.antivirusService.IsVirusAsync(data);
/// <summary>
/// Checks file size
/// </summary>
/// <param name="data">File data.</param>
/// <returns>True if file is not larger than 3 MB</returns>
private static bool IsValidFileLenght(byte[] data) => data.Length > 0 && data.Length < AllowedFileSize;
/// <summary>
/// Checks files extension and file signature.
/// </summary>
/// <param name="command">Command for file upload.</param>
/// <returns>True when file extension is allowed and file signature is correct.</returns>
private static bool IsValidFileExtension(CreateFileCommand command)
{
if (string.IsNullOrEmpty(command.Extension))
{
return false;
}
var currentExtension = Enumeration.FromDisplayName<FileExtension>(command.Extension);
if (currentExtension is null)
{
return false;
}
using var stream = new MemoryStream(command.Data);
using var reader = new BinaryReader(stream);
var signatures = currentExtension.Signatures;
if (signatures.Count == 0)
{
return true;
}
var headerBytes = reader.ReadBytes(signatures.Max(m => m.Length));
return signatures.Any(signature =>
headerBytes.Take(signature.Length).SequenceEqual(signature));
}
}
internal sealed class CreateFileCommandHandler : IRequestHandler<CreateFileCommand, FileResponse>
{
private readonly IUnitOfWork unitOfWork;
private readonly IMapper mapper;
public CreateFileCommandHandler(IUnitOfWork unitOfWork, IMapper mapper)
{
this.unitOfWork = unitOfWork;
this.mapper = mapper;
}
public async Task<FileResponse> Handle(CreateFileCommand request, CancellationToken cancellationToken)
{
var file = new File(SystemGuid.NewGuid, request.Name, request.Data, request.Extension, request.ContentType, SystemClock.UtcNow);
this.unitOfWork.Files.Add(file);
await this.unitOfWork.SaveAsync(cancellationToken);
return this.mapper.Map<FileResponse>(file);
}
}
}
namespace Company.Shorts.Application.Files
{
using AutoMapper;
using Company.Shorts.Application.Contracts.Db;
using Company.Shorts.Application.Files.Models;
using MediatR;
using System.Threading;
using System.Threading.Tasks;
public record DownloadFileByIdQuery(Guid Id) : IRequest<FileDownloadResponse>;
internal sealed class DownloadFileByIdQueryHandler : IRequestHandler<DownloadFileByIdQuery, FileDownloadResponse>
{
private readonly IUnitOfWork unitOfWork;
private readonly IMapper mapper;
public DownloadFileByIdQueryHandler(IUnitOfWork unitOfWork, IMapper mapper)
{
this.unitOfWork = unitOfWork;
this.mapper = mapper;
}
public async Task<FileDownloadResponse> Handle(DownloadFileByIdQuery request, CancellationToken cancellationToken)
{
var result = await this.unitOfWork.Files.GetbyIdSafeAsync(request.Id, cancellationToken);
return this.mapper.Map<FileDownloadResponse>(result);
}
}
}
namespace Company.Shorts.Application.Files
{
using AutoMapper;
using Company.Shorts.Application.Contracts.Db;
using Company.Shorts.Application.Files.Models;
using Company.Shorts.Domain;
using MediatR;
using System.Threading;
using System.Threading.Tasks;
public record GetFileByIdQuery(Guid Id) : IRequest<FileResponse>;
internal sealed class GetFileByIdQueryHandler : IRequestHandler<GetFileByIdQuery, FileResponse>
{
private readonly IMapper mapper;
private readonly IUnitOfWork unitOfWork;
public GetFileByIdQueryHandler(IMapper mapper, IUnitOfWork unitOfWork)
{
this.mapper = mapper;
this.unitOfWork = unitOfWork;
}
public async Task<FileResponse> Handle(GetFileByIdQuery request, CancellationToken cancellationToken)
{
File file = await this.unitOfWork.Files.GetbyIdSafeAsync(request.Id, cancellationToken);
return this.mapper.Map<FileResponse>(file);
}
}
}
namespace Company.Shorts.Application.Files
{
using AutoMapper;
using Company.Shorts.Application.Contracts.Db;
using Company.Shorts.Application.Files.Models;
using MediatR;
using Microsoft.AspNetCore.Http;
using System.Threading;
using System.Threading.Tasks;
public record GetFilesQuery(int Skip = 0, int Take = 20) : IRequest<List<FileResponse>>;
internal sealed class GetFilesQueryHandler : IRequestHandler<GetFilesQuery, List<FileResponse>>
{
private readonly IUnitOfWork unitOfWork;
private readonly IMapper mapper;
public GetFilesQueryHandler(IUnitOfWork unitOfWork, IMapper mapper)
{
this.unitOfWork = unitOfWork;
this.mapper = mapper;
}
public async Task<List<FileResponse>> Handle(GetFilesQuery request, CancellationToken cancellationToken)
{
var files = await this.unitOfWork.Files.GetAsync(request.Skip, request.Take, cancellationToken);
return this.mapper.Map<List<FileResponse>>(files);
}
}
}
namespace Company.Shorts.Application.Internal.Mappings
{
using Company.Shorts.Application.Files.Models;
using Company.Shorts.Blocks.Common.Mapping.Core;
using Company.Shorts.Domain;
internal sealed class ApplicationMappingProfile : MappingProfileBase
{
public ApplicationMappingProfile()
{
this.CreateMap<File, FileResponse>();
this.CreateMap<File, FileDownloadResponse>();
}
}
}
namespace Company.Shorts.Application
{
using Company.Shorts.Blocks.Application.Core.Behaviors;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
public static class DependecyInjection
{
public static IServiceCollection AddApplicationLayer(this IServiceCollection services)
{
services.AddApplicationConfiguration(Assembly.GetExecutingAssembly());
return services;
}
private static IServiceCollection AddApplicationConfiguration(
this IServiceCollection services,
params Assembly[] assemblies)
{
services.AddMediatR(c =>
{
c.RegisterServicesFromAssemblies(assemblies);
});
services.AddValidatorsFromAssemblies(assemblies, includeInternalTypes: true);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddHttpContextAccessor();
return services;
}
}
}
Branch: steps/file-upload