.NET Steps- File upload

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
  • 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
  • Internal
    • Constants
      • ApiHeader.cs
      • ApiTags.cs
      • ApiVersions.cs
    • Extensions
      • FormFileExtensions.cs
    • Mappings
      • PresentationMappingProfile.cs
    • DependencyInjection.cs

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.
  • Internal
    • Mappings
      • ApplicationMappingProfile.cs
        • Mapping definitions
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;
        }
    }
}

Github

Branch: steps/file-upload