Building a File Storage Server in .NET 7 C# Tutorial

In this tutorial, we will be building an application that allows us to create directories and upload files to those directories.

Here is a short demo of what the finished application will look like:

So that all our files do not have to live in the same directory, we will create a directory structure that we can navigate up and down. We will also be able to download any files from our application.

Our file upload will be validated to only allow permitted extensions and to only allow a file up to a set maximum file upload size.

We will also encode the supplied file name; this will be used for display purposes only. The file will be stored under a randomly generated file name and extension.

However, the file will retain its original name and extension when downloaded from the application.

In this tutorial, we will be building an ASP.NET Core application in .NET 7 using Razor Pages. However, this tutorial may also apply to other UI models.

Jump to section:

Building the directory tree

Creating a new directory

Uploading a file

Downloading a file

Displaying files in the directory tree

Building the directory tree

Although our directories will be created in Windows Explorer, to save having to read them, we will instead create database records that represent our directory tree. Our DirectoryRecord entity will store the ID of its parent directory. If a directory has no parent and therefore is the root directory, this will be 0:

public class DirectoryRecord
    {
        public int Id { get; set; }

        public string Name { get; set; }

        /// <summary>
        /// 0 denotes root directory.
        /// </summary>
        public int ParentDirectoryId { get; set; }
    }

Let’s create our first page, the home page where we’ll be able to navigate up and down our directory structure:

public class IndexModel : PageModel
    {
        private readonly ApplicationDbContext _context;
        private readonly IDirectoryService _directoryService;

        public IndexModel(ApplicationDbContext context,
            IDirectoryService directoryService)
        {
            _context = context;
            _directoryService = directoryService;
        }

        [FromQuery(Name = "directory")]
        public int CurrentDirectoryId { get; set; }

        public DirectoryRecordViewModel[] DirectoryRecords { get; set; } = default!;
        public string CurrentDirectoryFullPath { get; set; } = default!;
        public int? ParentDirectoryIdOfCurrentDirectory { get; set; } = null;

        public async Task OnGet()
        {
            DirectoryRecords = await GetDirectoryRecords();
            CurrentDirectoryFullPath = await _directoryService.GetFullDirectoryPathAsync(CurrentDirectoryId);
            if (CurrentDirectoryId > 0)
                ParentDirectoryIdOfCurrentDirectory = await _directoryService.GetParentDirectoryId(CurrentDirectoryId);
        }

        private async Task<DirectoryRecordViewModel[]> GetDirectoryRecords()
        {
            return await _context
                .DirectoryRecords
                .AsNoTracking()
                .Where(d => d.ParentDirectoryId == CurrentDirectoryId)
                .Select(d => new DirectoryRecordViewModel
                {
                    Id = d.Id,
                    Name = d.Name
                })
                .ToArrayAsync();
        }
    }

    public class DirectoryRecordViewModel
    {
        public int Id { get; set; }

        public string? Name { get; set; }
    }

Our page accepts a query parameter of directory which represents the directory that we are currently in. If no parameter is specified, this means that we’re in the root directory of our directory tree. Our GetDirectoryRecords fetch the directories based on this parameter.

In our model we also have two calls to a DirectoryService:

public interface IDirectoryService
    {
        Task<string> GetFullDirectoryPathAsync(int directoryRecordId);
        Task<int?> GetParentDirectoryId(int currentDirectoryId);
    }

    public class DirectoryService : IDirectoryService
    {
        private readonly ApplicationDbContext _context;
        private readonly string _fileStoreRootDirectory;

        public DirectoryService(ApplicationDbContext context,
            IConfiguration configuration)
        {
            _context = context;
            _fileStoreRootDirectory = configuration["FileStoreRootDirectory"]!;
        }

        public async Task<string> GetFullDirectoryPathAsync(int directoryRecordId)
        {
            if (directoryRecordId == 0)
                return _fileStoreRootDirectory;

            return await GetFullPath(directoryRecordId);
        }

        private async Task<string> GetFullPath(int directoryRecordId)
        {
            DirectoryRecord currentDirectoryRecord = default!;
            List<string> pathElements = new();
            bool firstIteration = true;

            do
            {
                if (firstIteration)
                {
                    currentDirectoryRecord = await _context
                    .DirectoryRecords
                    .AsNoTracking()
                    .Where(d => d.Id == directoryRecordId)
                    .FirstAsync();
                }
                else if (currentDirectoryRecord.ParentDirectoryId != 0)
                {
                    currentDirectoryRecord = await _context
                    .DirectoryRecords
                    .AsNoTracking()
                    .Where(d => d.Id == currentDirectoryRecord.ParentDirectoryId)
                    .FirstAsync();
                }

                pathElements.Add(currentDirectoryRecord!.Name);

                firstIteration = false;

            } while (currentDirectoryRecord.ParentDirectoryId != 0);

            pathElements.Reverse();
            pathElements.Insert(0, _fileStoreRootDirectory);
            return Path.Combine(pathElements.ToArray());
        }

        public async Task<int?> GetParentDirectoryId(int directoryRecordId)
        {
            var directoryRecord = await _context
                .DirectoryRecords
                .AsNoTracking()
                .Where(d => d.Id == directoryRecordId)
                .FirstAsync();

            return directoryRecord!.ParentDirectoryId;
        }
    }

GetFullDirectoryPathAsync fetches the full path of the current directory, including parent directories, to display to the user where in the directory hierarchy they are currently in. GetParentDirectoryId fetches the ID of the parent directory, so this can be used to navigate back up the directory tree.

Our application also requires us to specify the root folder of our file storage server in appsettings.json:

"FileStoreRootDirectory": "C:\\File Server Store"

Now let’s take a look at our home page:

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<p>
    @Model.CurrentDirectoryFullPath
</p>

<p>
    <a href="newdirectory?parent-directory=@Model.CurrentDirectoryId" class="me-3">New Directory</a>
    <a href="upload?directory=@Model.CurrentDirectoryId">Upload here</a>
</p>

@if (!Model.DirectoryRecords.Any() && Model.ParentDirectoryIdOfCurrentDirectory == null)
{
    <p>Nothing to display.</p>
}
else
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @if (Model.CurrentDirectoryId > 0)
            {
                <tr>
                    <td>
                        <a href="/?directory=@Model.ParentDirectoryIdOfCurrentDirectory">Up</a>
                    </td>
                </tr>
            }
            @foreach (var directory in Model.DirectoryRecords)
            {
                <tr>
                    <td>
                        <a href="/?directory=@directory.Id">@directory.Name</a>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

We will implement New Directory shortly, but the thing to note here is that the ID of the current directory is being passed to this page as this is the parent directory of any directory created here.

Our Up link enables us to navigate up the directory tree should our current directory have a parent ID and we’re not in the root directory.

We then have a list of directories, each of which we can navigate to.

Creating a new directory

Now that we’ve created our directory tree, let’s now allow the user to create directories within our directory structure:

public class NewDirectoryModel : PageModel
    {
        private readonly ApplicationDbContext _context;
        private readonly IDirectoryService _directoryService;

        public NewDirectoryModel(ApplicationDbContext context, IDirectoryService directoryService)
        {
            _context = context;
            _directoryService = directoryService;
        }

        [FromQuery(Name = "parent-directory")]
        public int ParentDirectoryId { get; set; }

        [BindProperty]
        public NewDirectoryDTO? NewDirectoryDTO { get; set; } = default!;

        /// <summary>
        /// Full path of where the new directory will be created.
        /// </summary>
        public string DirectoryPath { get; set; } = default!;

        public async Task OnGet() =>
            DirectoryPath = await _directoryService.GetFullDirectoryPathAsync(ParentDirectoryId);

        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid)
                return Page();

            await CreateDirectory();
            await CreateDirectoryRecord();
            return RedirectToPage("./Index");
        }

        private async Task CreateDirectory()
        {
            var newDirectoryPath = await _directoryService.GetNewDirectoryPathAsync(NewDirectoryDTO!.DirectoryName, ParentDirectoryId);
            Directory.CreateDirectory(newDirectoryPath);
        }

        private async Task CreateDirectoryRecord()
        {
            var newDirectoryRecord = new DirectoryRecord
            {
                Name = NewDirectoryDTO!.DirectoryName,
                ParentDirectoryId = ParentDirectoryId
            };
            await _context.AddAsync(newDirectoryRecord);
            await _context.SaveChangesAsync();
        }
    }

    public class NewDirectoryDTO
    {
        [Required]
        [StringLength(100)]
        public string DirectoryName { get; set; } = default!;
    }

Our page accepts a parameter of parent-directory. If 0, the new directory will be located in the root folder.

To reiterate, we’re not going to rely on reading the directories from Windows and we’re instead creating database records to represent our directory tree.

On creation of a new directory, we make a call to GetNewDirectoryPathAsync. This requires the following method and interface to be added to our DirectoryService:

        Task<string> GetNewDirectoryPathAsync(string newDirectoryName, int parentDirectoryId);

        /// <summary>
        /// Returns the full directory path based on the specified name and parent.
        /// </summary>
        /// <param name="newDirectoryName"></param>
        /// <param name="parentDirectoryId">0 denotes root directory.</param>
        /// <returns></returns>
        public async Task<string> GetNewDirectoryPathAsync(string newDirectoryName, int parentDirectoryId)
        {
            var path = _fileStoreRootDirectory;
            if (parentDirectoryId > 0)
                path = await GetFullPath(parentDirectoryId);

            return Path.Combine(path, newDirectoryName);
        }

Now we can add the view for our NewDirectoryModel:

@page
@model FileServer.Pages.NewDirectoryModel
@{
    ViewData["Title"] = "New Directory";
}

<p>Creating directory in @Model.DirectoryPath</p>

<form method="post" enctype="multipart/form-data">
    <div asp-validation-summary="All" class="text-danger"></div>

    <div class="mb-3">
        <input asp-for="NewDirectoryDTO!.DirectoryName" class="form-control">
    </div>

    <button type="submit" class="btn btn-primary">Submit</button>
</form>

Uploading a file

Now that we can create a directory anywhere in our directory tree, we can build the capability to upload to any directory. Let’s first start with our FileRecord entity. As well as the file name, we can store information such as when the file was uploaded, when it was last downloaded and the file size:

public class FileRecord
    {
        public int Id { get; set; }

        /// <summary>
        /// 0 denotes root directory.
        /// </summary>
        public int DirectoryRecordId { get; set; }

        public DateTime Uploaded { get; set; }

        public string DisplayName { get; set; }

        public string FileName { get; set; }

        /// <summary>
        /// Length of file in bytes.
        /// </summary>
        public long FileLength { get; set; }

        public DateTime? LastDownloaded { get; set; }
    }

Now we can add our razor page:

public class UploadModel : PageModel
    {
        private readonly ApplicationDbContext _context;
        private readonly IDirectoryService _directoryService;
        private readonly string[] _permittedFileExtensions;
        private readonly int _maxFileSize;

        public UploadModel(ApplicationDbContext context, IDirectoryService directoryService)
        {
            _context = context;
            _directoryService = directoryService;
            _permittedFileExtensions = new string[] { ".txt", ".pdf", ".png" };
            _maxFileSize = 10485760; // 10MB.
        }

        [FromQuery(Name = "directory")]
        public int DirectoryId { get; set; } = 0;

        [BindProperty]
        public UploadDTO? UploadDTO { get; set; } = default!;

        /// <summary>
        /// Full path of directory where the file will be uploaded.
        /// </summary>
        public string DirectoryPath { get; set; } = default!;

        public async Task OnGet() =>
            DirectoryPath = await _directoryService.GetFullDirectoryPathAsync(DirectoryId);

        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid) // Is model valid?
                return Page();

            if (UploadDTO!.File!.Length == 0) // Is there file data?
            {
                ModelState.AddModelError("File", "No file data detected.");
                return Page();
            }
            else if (UploadDTO.File.Length > _maxFileSize) // Does file exceed max file size?
            {
                ModelState.AddModelError("File", "File must not exceed 10MB.");
                return Page();
            }
            else
            {
                var fileExtension = Path.GetExtension(UploadDTO.File.FileName).ToLowerInvariant();

                // Does file have permitted file extension?
                if (string.IsNullOrEmpty(fileExtension) || !_permittedFileExtensions.Contains(fileExtension))
                {
                    ModelState.AddModelError("File", "Invalid file extension");
                    return Page();
                }

                _directoryService.CreateFileStoreDirectory();
                await SaveFile();
            }

            return RedirectToPage("./Index");
        }

        private async Task SaveFile()
        {
            var generatedFileName = Path.GetRandomFileName();
            var directoryPath = await _directoryService.GetFullDirectoryPathAsync(DirectoryId);
            var fullFilePath = Path.Combine(directoryPath, generatedFileName);
            using (var stream = System.IO.File.Create(fullFilePath))
                await UploadDTO!.File!.CopyToAsync(stream);
            await SaveFileRecord(generatedFileName, UploadDTO.File.Length);
        }

        private async Task SaveFileRecord(string fileName, long fileLength)
        {
            var fileDisplayName = HttpUtility.HtmlEncode(UploadDTO!.File!.FileName);
            _context.Add(new FileRecord
            {
                DirectoryRecordId = DirectoryId,
                Uploaded = DateTime.Now,
                DisplayName = fileDisplayName,
                FileName = fileName,
                FileLength = fileLength
            });
            await _context.SaveChangesAsync();
        }
    }

    public class UploadDTO
    {
        [Required]
        public IFormFile? File { get; set; }
    }

We specify our permitted file extensions and maximum file size in our constructor. Our model accepts a parameter of directory so that we can save along with the file the directory it belongs inside. We also get the full path of the selected directory so that we can display to the user the full path of where the file will be uploaded to.

On submission of the form, in our OnPostAsync method, we first ensure that the model is valid and that the file has data. We then check to see that it is one of the permitted file types and does not exceed the maximum file size. We also have another call to our DirectoryService to ensure that the root directory exists (this is in case no directories have been created prior to file upload):

void CreateFileStoreDirectory();

public void CreateFileStoreDirectory()
        {
            if (!Directory.Exists(_fileStoreRootDirectory))
                Directory.CreateDirectory(_fileStoreRootDirectory);
        }

As per the security considerations found in Microsoft’s documentation regarding file uploads, our file upload directory is separate from that of the application. So that if the application is compromised, the integrity of the uploaded files was to remain.

We are not trusting the file name provided by the user and we are instead generating a completely new file name and extension in which the file will be saved as.

We still retain the file name provided by the user for display purposes, but it is encoded before it is saved to the database.

Now for the file upload form:

@page
@model FileServer.Pages.UploadModel
@{
    ViewData["Title"] = "Upload File";
}

<p>Uploading to @Model.DirectoryPath</p>

<form method="post" enctype="multipart/form-data">
    <div asp-validation-summary="All" class="text-danger"></div>

    <div class="mb-3">
        <input asp-for="UploadDTO!.File" class="form-control">
    </div>

    <button type="submit" class="btn btn-primary">Submit</button>
</form>

Finally, we have to update our home page Index.cshtml to include a link to the upload form, including the parameter for the current directory which will be our upload location:

<p>
    @Model.CurrentDirectoryFullPath
</p>

<p>
    <a href="newdirectory?parent-directory=@Model.CurrentDirectoryId" class="me-3">New Directory</a>
    <a href="upload?directory=@Model.CurrentDirectoryId">Upload here</a>
</p>

Downloading a file

Next, let’s update our IndexModel to handle file downloads. We will implement the download link for each file in the following section:

public async Task<ActionResult> OnGetDownload(int fileId)
        {
            var fileRecord = await _context.FileRecords.FindAsync(fileId);
            await MarkLastDownloaded(fileRecord!);
            var directoryPath = await _directoryService.GetFullDirectoryPathAsync(fileRecord!.DirectoryRecordId);
            var fullPath = Path.Combine(directoryPath, fileRecord!.FileName);
            byte[] bytes = System.IO.File.ReadAllBytes(fullPath);
            return File(bytes, "application/octet-stream", fileRecord.DisplayName);
        }

        private async Task MarkLastDownloaded(FileRecord fileRecord)
        {
            fileRecord.LastDownloaded = DateTime.Now;
            _context.Update(fileRecord);
            await _context.SaveChangesAsync();
        }

Displaying files in the directory tree

Now that we can upload files anywhere in our directory tree, next is to display them on our home page, along with our directories.

Updating the IndexModel, first add the following property and viewmodel:

public FileRecordViewModel[] FileRecords { get; set; } = default!;

public class FileRecordViewModel
    {
        public int Id { get; set; }

        public string? Name { get; set; }

        public string? Size { get; set; }

        public DateTime Uploaded { get; set; }

        public DateTime? LastDownloaded { get; set; }
    }

Next, add the following method:

private async Task<FileRecordViewModel[]> GetFileRecords()
        {
            return await _context
                .FileRecords
                .AsNoTracking()
                .Where(f => f.DirectoryRecordId == CurrentDirectoryId)
                .OrderBy(f => f.Uploaded)
                .Select(f => new FileRecordViewModel
                {
                    Id = f.Id,
                    Name = f.DisplayName,
                    Size = f.FileLength.ToBytesDisplay(),
                    Uploaded = f.Uploaded,
                    LastDownloaded = f.LastDownloaded
                })
                .ToArrayAsync();
        }

We will need to add the following extension class in order to display the file size in a user-friendly manner:

public static class FileExtensions
    {
        public static string ToBytesDisplay(this long byteCount)
        {
            string[] suf = { "B", "KB", "MB", "GB", "TB", "PB", "EB" }; //Longs run out around EB.
            if (byteCount == 0)
                return "0" + suf[0];
            long bytes = Math.Abs(byteCount);
            int place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
            double num = Math.Round(bytes / Math.Pow(1024, place), 1);
            return (Math.Sign(byteCount) * num).ToString() + suf[place];
        }
    }

Next, we can update the OnGet method with the following call:

public async Task OnGet()
        {
            DirectoryRecords = await GetDirectoryRecords();
            FileRecords = await GetFileRecords();
            CurrentDirectoryFullPath = await _directoryService.GetFullDirectoryPathAsync(CurrentDirectoryId);
            if (CurrentDirectoryId > 0)
                ParentDirectoryIdOfCurrentDirectory = await _directoryService.GetParentDirectoryId(CurrentDirectoryId);
        }

Now we can update Index.cshtml to display our list of files, including a link to down the file:

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<p>
    @Model.CurrentDirectoryFullPath
</p>

<p>
    <a href="newdirectory?parent-directory=@Model.CurrentDirectoryId" class="me-3">New Directory</a>
    <a href="upload?directory=@Model.CurrentDirectoryId">Upload here</a>
</p>

@if (!Model.DirectoryRecords.Any() && !Model.FileRecords.Any() && Model.ParentDirectoryIdOfCurrentDirectory == null)
{
    <p>Nothing to display.</p>
}
else
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th>Name</th>
                <th>Size</th>
                <th>Uploaded</th>
                <th>Last Downloaded</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @if (Model.CurrentDirectoryId > 0)
            {
                <tr>
                    <td>
                        <a href="/?directory=@Model.ParentDirectoryIdOfCurrentDirectory">Up</a>
                    </td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                </tr>
            }
            @foreach (var directory in Model.DirectoryRecords)
            {
                <tr>
                    <td>
                        <a href="/?directory=@directory.Id">@directory.Name</a>
                    </td>
                    <td></td>
                    <td></td>
                    <td></td>
                    <td></td>
                </tr>
            }
            @foreach (var fileRecord in Model.FileRecords)
            {
                <tr>
                    <td>@fileRecord.Name</td>
                    <td>@fileRecord.Size</td>
                    <td>@fileRecord.Uploaded</td>
                    <td>@fileRecord.LastDownloaded</td>
                    <td>
                        <a href="@Url.Page("Index", "Download",
                            new { fileId = fileRecord.Id })">
                            Download
                        </a>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

That concludes the tutorial! I hope that you’ve gained some value from this blog post. If you have, please consider subscribing to the blog using the Subscribe button on the right-hand side of the page. If you’re enjoying the blog and want to support it further, why not consider buying me a coffee? It would really help me to churn out code faster!

5 thoughts on “Building a File Storage Server in .NET 7 C# Tutorial”

      1. yes. Because I began to study NET 7, so the code in the article is still not able to implement this function. I wonder if you can use your source code to practice.

Comments are closed.

Scroll to Top