A short article showing a method for handling file uploads and downloads.
Introduction
I was recently working on web site where I needed to upload and download files. Seems this is a point of pain for some so I thought I'd write a little article (as it's been SO LONG since my last one it's well overdue) as much for my own posterity as much as anything else.
Background
The application I was building was a shop front for selling digital products. The front end is all written in VueJS with an ASP.NET Core backend API serving the files and SPA.
Using the code
The code is pretty self explanatory so I'll just include a brief synopsys of what each block is doing rather than going over everything in detail and muddying the topic.
Registering Your API
main.ts
Import your api module
import api from './services/api'; -- this is my sites api
...
Vue.prototype.$api = api;
api-plugin.d.ts
Attaches the $api
variable to Vue and gives it a type of Api
.
import { Api } from './services/api';
declare module 'vue/types/vue' {
interface Vue {
$api: Api;
}
}
export default Api;
Uploading a File
In my VueJS component I created a variable inside the data()
object for holding the files to be sent to the server.
files: new FormData(),
I added a handler method to respond to the user adding a file to the uploaded
handleFileUpload(fileList: any) {
this.files.append('file', fileList[0], fileList[0].name);
},
The Vue component template contains the file input element
<input type="file" v-on:change="handleFileUpload($event.target.files)" />
Submitting The File
When the user then performs an action on your UI that triggers the uploads I call my API.
this.$api.uploadFile(this.files)
.then((response: <<YourResponseType>>) => {
this.hasError = false;
this.message = 'File uploaded';
}).catch((error) => {
this.hasError = true;
this.message = 'Error uploading file';
});
The API Service Method
The component method shown above in tern calls this method on my API service.
public async uploadFile(fileData: FormData): Promise<<<YourResponseType>>> {
return await axios.post('/api/to/your/upload/handler', fileData, { headers: { 'Content-Type': 'multipart/form-data' } })
.then((response: any) => {
return response.data;
})
.catch((error: any) => {
throw new Error(error);
});
}
ASP.NET Core API Method
The code within this method will vary greatly based on your own requirements but the basic structure will look something like this.
[HttpPost("/api/to/your/upload/handler")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> UploadHandler(IFormCollection uploads)
{
if (uploads.Files.Length <= 0) { return BadRequest("No file data"); }
foreach (var f in uploads.Files)
{
var filePath = "YourGeneratedUniqueFilePath";
using (var stream = System.IO.File.Create(filePath))
{
await file.CopyToAsync(stream);
}
}
return Ok();
}
Downloading A File
Starting at the server side this time, your API method will look something like this. Since I was using Kestral I opted to use the ControllerBase.PhysicalFile()
method but theres is also the base controller ControllerBase.File()
return method on your controllers should that suit your needs better.
Since my uploads were associated with an entity in my data store, downloads were requested via an ID value but you could use any method that suits your needs.
[HttpGet("[action]")]
public async Task<IActionResult> GetFile(string id)
{
var dir = "GetOrBuildYourDirectoryString";
var fileName = "GetYourFileName";
var mimeType = GetMimeType(fileName);
var path = Path.Combine(dir, fileName);
return PhysicalFile(path, mimeType, version.FileName);
}
public string GetMimeType(string fileName)
{
var provider = new FileExtensionContentTypeProvider();
string contentType;
if (!provider.TryGetContentType(fileName, out contentType))
{
contentType = "application/octet-stream";
}
return contentType;
}
Note: The FileExtensionContentTypeProvider
type comes from the Microsoft.AspNetCore.StaticFiles NuGet package
Install-Package Microsoft.AspNetCore.StaticFiles -Version 2.2.0
Client Side API Download
In order to call this GetFile()
method on the server our client side API service needs to expose a download method. This is where things can get a little tricky. You may have to configure your server to provide and/or expose the content disposition header. This is a little out of scope for this article as I want to remain concise to the topic.
I didn't need to perform any specific steps to access this header but I did have to perform a little jiggery pokery to extract the data I needed on the client side - chiefly the file name. This code isn't particularly nice unfortunately. If anyone has suggestions on how this might be imporved please let me know.
public async downloadFile(id: string): Promise<void> {
return await axios.get('/api/download/getfile?id=' + id, { responseType : 'blob' } )
.then((response: any) => {
const disposition = response.headers['content-disposition'];
let fileName = '';
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
const matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
fileName = matches[1].replace(/['"]/g, '');
}
}
const fileUrl = window.URL.createObjectURL(new Blob([response.data]));
const fileLink = document.createElement('a');
fileLink.href = fileUrl;
fileLink.setAttribute('download', fileName);
document.body.appendChild(fileLink);
fileLink.click();
})
.catch((error: any) => {
throw new Error(error);
});
}
This will setup the client side download and open the local users normal browser file save dialog.
Protecting Uploaded Files
Given that the code above is "manually" handling file downloads as well as uploads it stands to reason that simple URLs to files within the browser HTML isn't the desired scenario. In my case the uploaded files were placed in a directory that I wanted to handle downloads for.
In order to protect this "downloads" directory I mapped a little bit of logic to the .NET Core IApplicationBuilder
instance within the StartUp.cs Configure
method. This intercepts any request to this URL and sends a 401
response.
app.Map("/downloads", subApp => {
subApp.Use(async (context, next) =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
});
});
Anyone attempting to access a file within this downloads
directory essentially gets booted out and the browser receives an error response for the server.
I hope you find something of use in this short descrption of an approach. Any feedback, suggestions or improvements are most welcome.
Thanks for reading.
History
V1.0 - 7th March 2020