I have a web app where the Angular (7) front-end communicates with a REST API on the server, and uses OpenId Connect (OIDC) for authentication. I'm using an HttpInterceptor
that adds an Authorization
header to my HTTP requests to provide the auth token to the server. So far, so good.
However, as well as traditional JSON data, my back-end is also responsible for generating documents on-the-fly. Before I added authentication, I could simply link to these documents, as in:
<a href="https://my-server.com/my-api/document?id=3">Download</a>
However, now that I've added authentication, this no longer works, because the browser does not include the auth token in the request when fetching the document - and so I get a 401-Unathorized
response from the server.
So, I can no longer rely on a vanilla HTML link - I need to create my own HTTP request, and add the auth token explicitly. But then, how can I ensure that the user experience is the same as if the user had clicked a link? Ideally, I'd like the file to be saved with the filename suggested by the server, rather than a generic filename.
I've cobbled together something that "works on my machine" based partly on this answer and others like it - though my effort is "Angular-ized" by being packaged as a re-usable directive. There's not much to it (most of the code is doing the grunt-work of figuring out what the filename should be based on the content-disposition
header sent by the server).
download-file.directive.ts:
import { Directive, HostListener, Input } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Directive({
selector: '[downloadFile]'
})
export class DownloadFileDirective {
constructor(private readonly httpClient: HttpClient) {}
private downloadUrl: string;
@Input('downloadFile')
public set url(url: string) {
this.downloadUrl = url;
};
@HostListener('click')
public async onClick(): Promise<void> {
// Download the document as a blob
const response = await this.httpClient.get(
this.downloadUrl,
{ responseType: 'blob', observe: 'response' }
).toPromise();
// Create a URL for the blob
const url = URL.createObjectURL(response.body);
// Create an anchor element to "point" to it
const anchor = document.createElement('a');
anchor.href = url;
// Get the suggested filename for the file from the response headers
anchor.download = this.getFilenameFromHeaders(response.headers) || 'file';
// Simulate a click on our anchor element
anchor.click();
// Discard the object data
URL.revokeObjectURL(url);
}
private getFilenameFromHeaders(headers: HttpHeaders) {
// The content-disposition header should include a suggested filename for the file
const contentDisposition = headers.get('Content-Disposition');
if (!contentDisposition) {
return null;
}
/* StackOverflow is full of RegEx-es for parsing the content-disposition header,
* but that's overkill for my purposes, since I have a known back-end with
* predictable behaviour. I can afford to assume that the content-disposition
* header looks like the example in the docs
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
*
* In other words, it'll be something like this:
* Content-Disposition: attachment; filename="filename.ext"
*
* I probably should allow for single and double quotes (or no quotes) around
* the filename. I don't need to worry about character-encoding since all of
* the filenames I generate on the server side should be vanilla ASCII.
*/
const leadIn = 'filename=';
const start = contentDisposition.search(leadIn);
if (start < 0) {
return null;
}
// Get the 'value' after the filename= part (which may be enclosed in quotes)
const value = contentDisposition.substring(start + leadIn.length).trim();
if (value.length === 0) {
return null;
}
// If it's not quoted, we can return the whole thing
const firstCharacter = value[0];
if (firstCharacter !== '\"' && firstCharacter !== '\'') {
return value;
}
// If it's quoted, it must have a matching end-quote
if (value.length < 2) {
return null;
}
// The end-quote must match the opening quote
const lastCharacter = value[value.length - 1];
if (lastCharacter !== firstCharacter) {
return null;
}
// Return the content of the quotes
return value.substring(1, value.length - 1);
}
}
This is used as follows:
<a downloadFile="https://my-server.com/my-api/document?id=3">Download</a>
...or, of course:
<a [downloadFile]="myUrlProperty">Download</a>
Note that I'm not explicitly adding the auth token to the HTTP request in this code, because that's already taken care of for all HttpClient
calls by my HttpInterceptor
implementation (not shown). To do this without an interceptor is simply a case of adding a header to the request (in my case, an Authorization
header).
One more thing worth mentioning, is that, if the web API being called is on a server that uses CORS, it might prevent the client-side code from accessing the content-disposition response header. To allow access to this header, you can have the server send an appropriate access-control-allow-headers
header.