0

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.

Here's the network activity: enter image description here

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!

blawney_dfci
  • 594
  • 5
  • 18

1 Answers1

0

Might be that the request in the fileService method, is not correct. Checking the documentation, to do a request to the Cloud Storage API for downloading a file:

https://storage.googleapis.com/download/storage/v1/b/BUCKET_NAME/o/OBJECT_NAME?alt=media

Replacing BUCKET_NAME and OBJECT_NAME with the appropriate values.

Also, you might find useful this NodeJS sample code, that demonstrates how to download a file from Cloud Storage:


// The ID of your GCS bucket 
// const bucketName = 'your-unique-bucket-name';

// The ID of your GCS file 
// const fileName = 'your-file-name';

// The path to which the file should be downloaded 
// const destFileName = '/local/path/to/file.txt';

// Imports the Google Cloud client library 
const {Storage} = require('@google-cloud/storage');

// Creates a client 
const storage = new Storage();

async function downloadFile() {   
  const options = {
    destination: destFileName,
  };

  // Downloads the file   
  await storage.bucket(bucketName).file(fileName).download(options);

  console.log(
    `gs://${bucketName}/${fileName} downloaded to ${destFileName}.`
  );
}

downloadFile().catch(console.error);

Sergi
  • 135
  • 7