File Uploads with FilePond and .NET Core

Posted By  MH - Studio Manager06/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 (or .NET 5) and AWS services. Here’s a quick look at how we do it.

.NET Core File Upload Model

We first created a simple model to represent our files in the database. It’s got a 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 foreign key for the related object in our 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 DateTime CreatedOn { get; set; }

        public int CaseId { get; set; }
    }

After writing the model, we need a new controller with dependency injection for our EF context and we also include a reference to the Amazon S3 SDK. We won’t be implementing our S3 helper methods here, there’s plenty of examples to be found on the Amazon S3 documentation.

    [Route("api/[controller]")]
    [ApiController]
    public class AttachmentController : ControllerBase
    {
        private readonly CaseContext _context;
        private readonly IAmazonS3 _amazonS3;

        public AttachmentController(CaseContext context, IAmazonS3 amazonS3)
        {
            _context = context;
            _amazonS3 = amazonS3;
        }

        private static string GetBucketName() => "Bucket";

        /// You might not even be using S3, you'll have to implement these, sorry! 
        /// Check out the amazon docs for more information:
        /// https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-netcore.html
        private Task<bool> UploadToS3Async(MemoryStream newMemoryStream, object guid)
        {
            throw new NotImplementedException();
        }

        private Task<bool> DeleteFromS3Async(DeleteObjectRequest deleteObjectRequest)
        {
            throw new NotImplementedException();
        }

        private Stream GetS3FileStreamAsync(string bucketName, string imageKey)
        {
            throw new NotImplementedException();
        }
    }

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="file"
            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:

<script>
let ApiUrl = "/attachment/"
const pond = FilePond.create(inputElement,{
    server: {
        url: ApiUrl,
    }
});
</script>

Process

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

[HttpPost]
public async Task<ActionResult> Process([FromForm] int caseId, IFormFile file, CancellationToken cancellationToken)
{
    if (file is null)
    {
        return BadRequest("Process Error: No file submitted");
    }

    // We do some internal application validation here with our caseId

    try
    {
        // get a guid to use as the filename as they're highly unique
        var guid = Guid.NewGuid().ToString();
        var newimage = string.Format("{0}.{1}", guid, file.FileName.Split('.').LastOrDefault());
        // upload to AWS
        using var newMemoryStream = new MemoryStream();
        await file.CopyToAsync(newMemoryStream, cancellationToken);
        var uploadResponse = await S3Helper.UploadToS3Async(_amazonS3, newMemoryStream, S3Helper.GetRaffleBucketPath(), newimage, cancellationToken);

        if (!uploadResponse)
        {
            return BadRequest("Process Error: Upload Failed.");
        }
        var attachment = new Attachment
        {
            FileName = Path.GetFileNameWithoutExtension(file.FileName),
            FileType = Path.GetExtension(file.FileName).Replace(".", String.Empty),
            FileSize = file.Length,
            CreatedOn = DateTime.Now,
            CaseId = caseId,
            S3url = string.Format(
                "https://{0}.s3.{1}.amazonaws.com/{2}/{3}",
                S3Helper.BucketName,
                _amazonS3.Config.RegionEndpoint.SystemName,
                S3Helper.RaffleBucketSubdirectory,
                newimage
            ),
            Guid = guid
        };
        await _context.AddAsync(attachment, cancellationToken);
        await _context.SaveChangesAsync(cancellationToken);
        return Ok(guid);
    }
    catch (Exception e)
    {
        return BadRequest($"Process Error: {e.Message}"); // Oops!
    }
}

Because we needed an extra piece of information in our system, we override FilePond’s process method to add the view model’s CaseId into form data.

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', ApiUrl);
            // 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,
        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. FilePond expects a delete request to your server api route to land in the right place, so mark the method with [HttpDelete].

// DELETE: api/RaffleImagesUpload/
// To protect from overposting attacks, enable the specific properties you want to bind to, for
// more details, see https://go.microsoft.com/fwlink/?linkid=2123754.
[HttpDelete]
public async Task<ActionResult> Revert()
{

    // The server id will be send in the delete request body as plain text
    using StreamReader reader = new(Request.Body, Encoding.UTF8);
    string guid = await reader.ReadToEndAsync();
    if (string.IsNullOrEmpty(guid))
    {
        return BadRequest("Revert Error: Invalid unique file ID");
    }
    var attachment = _context.Attachments.FirstOrDefault(i => i.Guid == guid);
    // We do some internal application validation here
    try
    {
        // Form the request to delete from s3
        var deleteObjectRequest = new DeleteObjectRequest
        {
            BucketName = GetBucketName(), // add your own bucket name
            Key = guid
        };
        // https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-netcore.html
        await DeleteFromS3Async(deleteObjectRequest);
            
        attachment.Deleted = true;
        _context.Update(attachment);
        await _context.SaveChangesAsync();
        return Ok();
    }
    catch (AmazonS3Exception e)
    {
        return BadRequest(string.Format("Revert Error:'{0}' when writing an object", e.Message));
    }
    catch (Exception e)
    {
        return BadRequest(string.Format("Revert Error:'{0}' when writing an object", e.Message));
    }
}

Load

We can process and revert images. But when we refresh our page, there’s nothing left behind. We solve this by implementing the Load endpoint and adding the file guids to the view. First into the view:

const pond = FilePond.create(inputElement, {
    server: {
        url: api,
        process: process, // Function moved for brevity
        remove: remove, // Function moved for brevity
        load: "./load/",
    },
    files: [
        @foreach(var attachment in Model.Attachments.Where(f => !f.Deleted))
        {
            <text>
            {
                source: "@attachment.Guid",
                options: {
                    type: 'local', // local to indicate an already uploaded file, so it hits the load endpoint
                }
            },
            </text>
        }
    ],
})

The endpoint is just as easy. FilePond will send the request to your specified location with a string representing the file’s server id. Use that ID to find your entity and pull it back down from the cloud.

[HttpGet("Load/{id}")]
public async Task<IActionResult> Load(string id)
{
    if (string.IsNullOrEmpty(id))
    {
        return NotFound("Load Error: Invalid parameters");
    }
    var attachment = await _context.Attachments.SingleOrDefaultAsync(i => i.Guid.Equals(id));
    if (attachment is null)
    {
        return NotFound("Load Error: File not found");
    }

    var imageKey = string.Format("{0}.{1}", attachment.Guid, attachment.FileType);
    using Stream ImageStream = GetS3FileStreamAsync(GetBucketName(), imageKey);
    Response.Headers.Add("Content-Disposition", new ContentDisposition
    {
        FileName = string.Format("{0}.{1}", attachment.FileName, attachment.FileType),
        Inline = true // false = prompt the user for downloading; true = browser to try to show the file inline
    }.ToString());
    return File(ImageStream, "image/" + attachment.FileType);
}

Remove

Attachments loaded from the API don’t interact with the “Revert” method. If you want to delete one of these files we need to implement the “Remove” method. FilePond doesn’t enable this one by default, so it’s all up to us. We found the logic to be identical, so we added a override method to point the api to the Revert server endpoint.

remove: (source, load, error) => {
    const request = new XMLHttpRequest();
    request.open('DELETE', api);
    // 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(source);
}

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

Any feedback? Please get in touch. Gist!