Seamless File Uploads with FilePond JS and ASP.NET Core (.NET 6)
I’ve often been asked to develop modern user-friendly file uploads. FilePond, a JavaScript file upload library, often fits the bill and it’s extremely powerful when integrated with .NET Core. Plenty of potential to integrate with popular cloud file storage services like AWS S3 and Azure Blob Storage.
.NET Core File Upload Model
We have a simple model to structure the properties of our files. It’s got a unique identifier, Id. Information we can capture from the file, its 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, DocumentId.
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 DocumentId { get; set; }
}
After writing the model, we need a new controller with dependency injection for our EF context. There’s also two stub methods for uploading and deleting the file from the local server.
[Route("api/[controller]")]
[ApiController]
public class AttachmentController : ControllerBase
{
private readonly DocumentContext _context;
private readonly IWebHostEnvironment _env;
public AttachmentController(
DocumentContext context,
IWebHostEnvironment environment)
{
_context = context;
_env = environment;
}
/// <summary>
/// Upload the file locally. Look into Amazon S3 and Azure blob storage.
/// </summary>
/// <param name="loadedFile"></param>
/// <param name="name"></param>
/// <returns></returns>
private async Task UploadFile(IFormFile loadedFile, string name)
{
var trustedFileNameForFileStorage = name;
var path = Path.Combine(
_env.WebRootPath,
GetBucketName(),
trustedFileNameForFileStorage);
await using FileStream fs = new(path, FileMode.Create);
await loadedFile.OpenReadStream().CopyToAsync(fs);
}
/// <summary>
/// Delete the local file.
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
private void DeleteFile(string name)
{
var path = Path.Combine(_env.WebRootPath, GetBucketName(), name);
System.IO.File.Delete(path);
}
}
Getting The File Upload Onto The Page
We’re working on something like the standard Edit view scaffolded by our Document 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 and point it at our controller URL.
@model Models.Document
<!-- 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 ApiUrl = "/api/attachment/";
const pond = FilePond.create(inputElement,{
server: {
url: ApiUrl,
process: null,
load: null,
remove: null,
fetch: null,
}
});
</script>
FilePond Server API
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 can use the Guid. This id is then expected to be used to revert uploads or restore earlier uploads. Let’s handle that processing ourselves:
// POST: api/Attachment/
[HttpPost]
public async Task<ActionResult> Process(
[FromForm] int documentId,
IFormFile file,
CancellationToken cancellationToken)
{
if (file is null)
{
return BadRequest("Process Error: No file submitted");
}
if(documentId == default ||
!_context.Documents.Any(i => i.Id == documentId))
{
return BadRequest("Process Error: No document assigned");
}
try
{
// get a guid to use as the filename as they're highly unique
var guid = Guid.NewGuid().ToString();
var fileType = Path.GetExtension(file.FileName)
.Replace(".", string.Empty);
var newImage = $"{guid}.{fileType}";
using var newMemoryStream = new MemoryStream();
await file.CopyToAsync(newMemoryStream, cancellationToken);
try
{
await UploadFile(file, newImage);
}
catch (Exception)
{
return BadRequest($"{nameof(Process)} Error: Upload Failed.");
}
var attachment = new Attachment
{
FileName = Path.GetFileNameWithoutExtension(file.FileName),
FileType = fileType,
FileSize = file.Length,
CreatedOn = DateTime.Now,
DocumentId = documentId,
Guid = guid
};
await _context.AddAsync(attachment, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Ok(guid);
}
catch (Exception e)
{
return BadRequest($"'{nameof(Process)}' Error: {e.Message}"); // Oops!
}
}
Because we need an extra piece of information in our system, DocumentId, we override FilePond’s process method to add it into form data.
function process (fieldName, file, metadata, load, error, progress, abort) {
const formData = new FormData();
formData.append(fieldName, file, file.name);
formData.append("DocumentId", "@Model.Id");
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) {
// the load method accepts either a string (id) or an object
load(request.responseText);
}
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();
}
};
}
const pond = FilePond.create(inputElement,{
server: {
url: ApiUrl,
process: process,
load: "./load/",
remove: null,
fetch: null,
}
});
Revert
Okay, we’ve got files landing 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 annotate the method with [HttpDelete].
// DELETE: api/Attachment/
[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($"{nameof(Revert)} Error: Invalid unique file ID");
}
var attachment = _context.Attachments.FirstOrDefault(i => i.Guid == guid);
// We do some internal application validation here
try
{
DeleteFile($"{attachment.Guid}.{attachment.FileType}");
}
catch (Exception e)
{
return BadRequest($"{nameof(Revert)} Error:'{e.Message}' when deleting an object");
}
attachment.Deleted = true;
_context.Update(attachment);
await _context.SaveChangesAsync();
return Ok();
}
Load
We can process and revert images. But when we refresh our page, there’s nothing left behind. We solve this by implementing the FilePond Load endpoint and adding the file Guids to the view. First into the view:
const pond = FilePond.create(inputElement, {
server: {
url: api,
process: process,
remove: null,
load: "./load/",
fetch: null,
},
files: [
@foreach(var attachment in Model.Attachments)
{
<text>
{
source: "@attachment.Guid",
options: {
// local indicates an already uploaded file,
// so it hits the load endpoint
type: 'local',
}
},
</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 your image 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");
}
Response.Headers.Add("Content-Disposition", new ContentDisposition
{
FileName = $"{attachment.FileName}.{attachment.FileType}",
Inline = true
}.ToString());
var imageKey = $"{attachment.Guid}.{attachment.FileType}";
var path = Path.Combine(_env.WebRootPath, GetBucketName(), imageKey);
var bytes = System.IO.File.ReadAllBytes(path);
return File(bytes, "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.
function remove (source, load, error) {
const request = new XMLHttpRequest();
request.open('DELETE', 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();// the load method accepts either a string (id) or an object
}
else {
error('Error while removing file!');
}
}
request.send(source);
}
const pond = FilePond.create(inputElement, {
server: {
url: api,
process: process,
remove: remove,
load: "./load/",
fetch: null,
},
files: [
@foreach(var attachment in Model.Attachments)
{
<text>
{
source: "@attachment.Guid",
options: {
// indicates an uploaded file, so it hits the load endpoint
type: 'local',
}
},
</text>
}
],
})
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!