File Uploads with FilePond and .NET Core

MH - Senior Developer

06/09/19

We’ve had a number of interesting projects recently and we’ve seen a large demand for modern user-friendly file uploads. FilePond, a JavaScript file upload library, fit the bill and we found it extremely powerful when integrated with .NET core and AWS services. Here’s a quick look at how we did it.

.NET Core Model

We first created a simple model to represent our files in the database. It’s got a standard unique identifier, Id. A few properties we can capture from the file, it’s name, type, size and if the user has chosen to delete the file. A GUID ( Globally Unique Identifier ), which we will use as the reference to store these files in our bucket. Finally, the Id for the object that the file is connected to in the system, CaseId.

    public class Attachment
    {
        public int Id { get; set; }
        public string Filename { get; set; }
        public string Filetype { get; set; }
        public long FileSize { get; set; }
        public string Guid { get; set; }
        public bool Deleted { get; set; }

        public int CaseId{ get; set; }
    }

After writing the model, we used the handy controller scaffolding tools in visual studio to generate a controller to deal with related http requests. In Solution Explorer, right-click Controllers > Add > Controller, press Next and select the Attachment Model.

Get the file upload on the page

We’re working on something akin to the standard Edit view scaffolded by our Case model controller, but we need to get the new file input on the page. The FilePond docs are a good place to start when getting the upload element onto the page. We need to include the scripts and styles, then generate the FilePond element with the included scripts.

@model Models.Case
<!-- In the document head -->
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet">

<!-- On your page -->
<input type="file"
            class="filepond"
            id="filepond"
            name="files"
            multiple >
<!-- Before the end of the body tag -->
<script src="https://unpkg.com/filepond/dist/filepond.js"></script>
<script>
const inputElement = document.querySelector('input[type="file"]');
const pond = FilePond.create( inputElement );
</script>

Getting FilePond Talking to Our Controller

FilePond needs access to our server to save the files, but the front end library has no access without our intervention. So we need to point FilePond at our controller URL:

FilePond.setOptions({
    server: {
        url: "/attachment/",
    }
}

Process

Okay, now we’re talking. Not much happening yet. When FilePond talks to the server it makes a few assumptions. FilePond sends the file and expects the server to return a unique id. The id can then be used to revert uploads or restore earlier uploads, later on. Let’s handle that processing ourselves:

// Expect a Attachment Doc, containg the caseID and a list of files!
[HttpPost]
public async Task<ActionResult> Create(Models.Attachment doc, List<IFormFile> files)
{
    if (files != null && doc.CaseID != 0)
    { // We dont want to process if the data seems malformed
        foreach (var file in files)
        {
            doc.Guid = Guid.NewGuid().ToString();
            doc.Filename = file.FileName.Split('.').FirstOrDefault();
            doc.Filetype = file.FileName.Split('.').LastOrDefault().ToLower();
            doc.CreatedOn = DateTime.Now;
            doc.FileSize = file.Length;
            using (var newMemoryStream = new MemoryStream())
            { // We copy the file into a memory stream to pass to S3.
//https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-netcore.html
                await file.CopyToAsync(newMemoryStream);
                await UploadToS3Async(newMemoryStream, doc.Guid);
            }

            _context.Attachments.Add(doc);
            await _context.SaveChangesAsync();
        }
        return Ok(doc.Guid); // Return the guid we used as the file reference
    }
    else
    {
        return BadRequest("File null"); // Oops!
    }
}

Because we needed that extra piece of information in our system, we expanded the process method to append it into form data, from the view model.

FilePond.setOptions({
    server: {
        url: "/attachment/",
        process:(fieldName, file, metadata, load, error, progress, abort) => {
            const formData = new FormData();
            formData.append(fieldName, file, file.name);
            formData.append("CaseID", "@Model.CaseId");

            const request = new XMLHttpRequest();
            request.open('POST', '/attachment/Create');
            // Setting computable to false switches the loading indicator to infinite mode
            request.upload.onprogress = (e) => {
                progress(e.lengthComputable, e.loaded, e.total);
            };

            request.onload = function () {
            if (request.status >= 200 && request.status < 300) {
                load(request.responseText);// the load method accepts either a string (id) or an object
            }
            else {
                error('Error during Upload!');
            }
        };

        request.send(formData);
        //expose an abort method so the request can be cancelled
        return {
            abort: () => {
                // This function is entered if the user has tapped the cancel button
                request.abort();
                // Let FilePond know the request has been cancelled
                abort();
            }
        };
        }, // we've not implemented these endpoints yet, so leave them null!
        fetch: null,
        revert: null,
        remove: null,
    }
})

Revert

Okay, we’ve got files landing in our S3 bucket left, right and centre. Let’s get rid of some. Specifically, allow the user to revert their upload.

// Remember that null revert endpoint?
revert: "remove/",
[HttpDelete]
public async Task<ActionResult> Revert()
{
    // Read the request body
    string content = "";
    using (StreamReader sr = new StreamReader(Request.Body))
    {
        content = sr.ReadToEnd();
    }
    // Some system specific validation to check you're dealing with the right user + case
    var attachment = _context.Attachments.Include(i => i.Case.Contractor).SingleOrDefault(i => i.Guid == content);
    var thisCase = attachment.Case;

    try
    {
        var deleteObjectRequest = new DeleteObjectRequest
        {
            BucketName = GetBucketName(),
            Key = content
        };

        await _amazonS3.DeleteObjectAsync(deleteObjectRequest);
        attachment.Deleted = true;
        _context.SaveChanges();
        return Ok();
    }
    catch (Exception e)
    {
        return BadRequest(string.Format("Error encountered on server. Message:'{0}' when writing an object", e.Message));
    }
}

Remove

Finally, we need a concrete delete endpoint.

[HttpDelete]
public async Task<ActionResult> Remove(string id)
{
    string content = id;
    // Some system specific validation to check you're dealing with the right user + case
    var attachment = _context.Attachments.Include(i => i.Case.Contractor).SingleOrDefault(i => i.Guid == content);
    var thisCase = attachment.Case;
    if (!isValid) // perform own validation
    {
        return BadRequest();
    }
    try
    {
        var deleteObjectRequest = new DeleteObjectRequest
        {
            BucketName = GetBucketName(),
            Key = content
        };

        await _amazonS3.DeleteObjectAsync(deleteObjectRequest);
        attachment.Deleted = true;
        _context.SaveChanges();
        return Ok();
    }
    catch (Exception e)
    {
        return BadRequest(string.Format("Error encountered on server. Message:'{0}' when writing an object", e.Message));
    }
}

To facilitate our URL convention (Attachment/Remove/?Id), we’ll handle some of the remove method ourselves:

remove: (source, load, error) => {
    const request = new XMLHttpRequest();
    request.open('DELETE', '/attachment/remove/' + source);
    // Setting computable to false switches the loading indicator to infinite mode
    request.upload.onprogress = (e) => {
        progress(e.lengthComputable, e.loaded, e.total);
    };
    request.onload = function () {
        if (request.status >= 200 && request.status < 300) {
            load();// the load method accepts either a string (id) or an object
        }
        else {
            error('Error while removing file!');
        }
    }
    request.send(null);
}

And that’s that. All done. We’ve enjoyed using FilePond and it’s helped us to create some amazing experiences for our users.