I'm a little stuck on what is the best tact for initiating a file download on an application I am developing. Both the backend (django RESTful API) and the frontend (angular 9) can be modified to accomplish this in the best way.
Minimum background: The application needs to allow users to download files. Currently, the backend allows multiple configurations for how it stores files-- the files can either be stored on the API host or they can be in bucket storage (google cloud here, but that shouldn't matter).
Obviously, the client shouldn't care about how those files are stored. They should just be able to go to an endpoint like https://example.com/api/files/<file UUID>/download/
to start the download. As currently written, when the backend receives the request, it responds differently depending on which type of storage is used. If the files are stored on the server, it simply responds with the file contents:
def get(self, request, *args, **kwargs):
...
contents = open(filepath, 'rb')
mime_type, _ = mimetypes.guess_type(filepath)
response = HttpResponse(content = contents)
response['Content-Type'] = mime_type
response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(filepath)
return response
On the other hand, if the files are stored in bucket storage, I create a signed URL using the google cloud SDK and return a 302 with that signed URL as the redirect location.
That all works fine if I'm directly interacting with the API. Once I involve Angular I don't know how to wire this up. Ideally, the UI would just be a button-- the user clicks and the download starts.
Based on looking at different sources (in particular, Angular 6 Downloading file from rest api), I thought I may try this by creating a custom angular directive. For example, the HTML has:
<a fileDownload [resourceId]="row.id">
<button [disabled]="!row.is_active">
Download
</button>
</a>
and the fileDownload
directive is (in part) like:
@Directive({
selector: '[fileDownload]',
exportAs: 'fileDownload',
})
export class FileDownloadDirective implements OnDestroy {
@Input() resourceId: string;
private destroy$: Subject<void> = new Subject<void>();
constructor(private ref: ElementRef, private fileService: FileService) {}
@HostListener('click')
onClick(): void {
this.fileService.downloadFile(this.resourceId)
.subscribe(x => {
// what should go here????
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
and my fileService
method is:
downloadFile(id: number | string): Observable<any> {
return this.httpClient.get(`${this.API_URL}/resources/download/${id}/`);
}
As I commented in the code above, I'm not sure what (if anything) to do with the response when I subscribe. I tried the version in the referenced SO thread, but regardless of what I do in my subscription, I get a CORS error. I put a few console.log
statements in there and they don't show up, so it doesn't seem like I'm even getting into the subscribe
callback.
In any case, the browser intercepts the 302 and then immediately tries to follow the redirect. This then results in a CORS failure which I can't seem to fix. Based on this https://github.com/googleapis/python-storage/issues/3 , I looked at the preflight OPTIONS request. The browser's inspector shows this failing due to no CORS Access-Control-Allow-Origin
header. The request was:
OPTIONS /my-bucket/...<snip>...
Host: storage.googleapis.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization
Referer: http://example.com:4200/
Origin: http://example.com:4200
Connection: keep-alive
and the response
HTTP/2 200 OK
x-guploader-uploadid: <...>
date: Sat, 20 Mar 2021 12:22:03 GMT
expires: Sat, 20 Mar 2021 12:22:03 GMT
cache-control: private, max-age=0
content-length: 0
server: UploadServer
content-type: text/html; charset=UTF-8
alt-svc: h3-29=":443"; ma=2592000,h3-T051=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
X-Firefox-Spdy: h2
Notably, there is no Access-Control-Allow-Origin
header and the browser does not proceed with the actual GET
request for the file.
Using cURL, I can replicate that the OPTIONS
request does not return the Access-Control-Allow-Origin
header. The signed url is valid since I can download the file with cURL with a GET
request.
From what I can tell, I believe my bucket is setup correctly:
$ gsutil cors get gs://my-bucket
[{"maxAgeSeconds": 3600, "method": ["GET"], "origin": ["*"], "responseHeader": ["Content-Type", "Access-Control-Allow-Origin"]}]
Are there better ways to do this? I suppose I could write a HttpInterceptor
to catch the 302 and modify the request. Or is it better to not have the API respond with a 302? Since I'm telling it to get the file from google bucket storage, that would seem to be the most ideal response since I am effectively redirecting the request.
Thanks for any help/suggestions!