We have moved our forum to GitHub Discussions. For questions about Phalcon v3/v4/v5 you can visit here and for Phalcon v6 here.

the response of download a file

hi, i try to implement a function to download a file in my micro api with Cors.

this is my controller function of downloading action get .../download/filename/version

public function downloadAction($name, $version, $mode = 'attachment'){

        $errors = [];
        $data = [];

        if ($name != null) {
            if ((!is_string($name)) && (!is_null($name)))
                $errors['name'] = 'tag must be a string';
            $data['name'] = $name;
        }

        if ($version != null) {
            if (!ctype_digit($version) || ($version < 0))
                $errors['version'] = 'The version must be a positive Integer';
            $data['version'] = $version;
        }

        if ($errors) {
        $exception = new Http400Exception(_('Input parameters validation error'), self::ERROR_INVALID_REQUEST);
        throw $exception->addErrorDetails($errors);
    }

        try {
            $downloadFile = $this->filesService->downloadFiles($data, $mode);
        } catch (ServiceException $e) {
            switch ($e->getCode()) {
                case FilesService::ERROR_UNABLE_TO_FIND_DOWNLOAD:
                case FilesService::ERROR_UNABLE_TO_OPEN_FILE:
                    throw new Http422Exception($e->getMessage(), $e->getCode(), $e);
                default:
                    throw new Http500Exception(_('Internal Server Error'), $e->getCode(), $e);
            }

        }
    }

i found this here at the board for the downloadFiles function and change it litte bit:

public function downloadFiles(array $data, $mode){

        try {
            $search = [];

            $single = explode(".", $data['name']);

            $search['name'] = $single[0];
            $search['extension'] = $single[1];
            $search['version'] = $data['version'];

            $userId = $this->session->get("userId");
            $dir = FILES_PATH.$userId.'/';

            $files = Files::findFirst([
                'conditions' => 'name=:name: AND extension=:extension: AND version=:version:',
                'bind' => [
                    'name' => $search['name'],
                    'extension' => $search['extension'],
                    'version' => $search['version'],

                ]
            ]);

            $file_name = $files->name;
            $file_ext = $files->extension;

            echo $files->id;

            $file_path  = $dir.$files->id.'_'.$file_name.'.'.$file_ext;

            if (is_file($file_path)){
                $file_size = filesize($file_path);
                $file = @fopen($file_path, 'rb');

                if($file) {
                    //set headers, prevent chaching
                    header('Pragma: public');
                    header('Expires: -1');
                    header('Cache-Control: public, must-revalidate, post-check=0, pre-check=0');

                    //set appropriate headers for attachment or streamed file
                    if ($mode == 'attachment') {

                        header("Content-Disposotion: attachment; filename=\"{$file_name}.{$file_ext}\"");
                    }else
                        header("Content-Disposotion: inline; filename=\"{$file_name}\"");

                    // set the mime type based on extension

                    $ctype_default = 'text/plain';
                    $content_types = array(
                        'txt'   => 'text/plain',
                        'html'   => 'text/html',
                        'json'  => 'application/json',
                        'xml'   => 'application/xml',
                    );

                    $ctype = isset($content_types[$file_ext]) ? $content_types[$file_ext] : $ctype_default;

                    echo $ctype;
                    header("Content-Type: {$ctype}");

                    //check if http_range is sent by brwoser (or download manger)
                    if (isset($_SERVER['HTTP_RANGE'])) {
                        list($size_unit, $range_orig) = explode('=', $_SERVER['HTTP_RANGE'], 2);
                        if ($size_unit == 'bytes') {
                            list($range, $extra_ranges) = explode(',', $range_orig, 2);
                        } else {
                            $range = '';
                            header('HTTP/1.1 416 Requested Range Not Satisfiable');
                            exit;
                        }

                    } else
                        $range = '';

                    //figure out download piece from range (if set)
                    list($seek_start, $seek_end) = explode('-', $range, 2);

                    //set start and end based of range (if set), else set default
                    //also check for invalid ranges

                    ob_clean();

                    $seek_end = (empty($seek_end)) ? ($file_size - 1) : min(abs(intval($seek_end)), ($file_size - 1));
                    $seek_start = (empty($seek_start) || $seek_end < abs(intval($seek_start))) ? 0 : max(abs(intval($seek_start)), 0);

                    //only send partial content header if downloading a piece of the file (IE workaround)

                    if ($seek_start > 0 || $seek_end < ($file_size - 1)) {
                        header('HTTP/1.1 206 Partial Content');
                        header('Content-Range: bytes ' . $seek_start . '-' . $seek_end . '/' . $file_size);
                        header('Content-Length: ' . ($seek_end - $seek_start + 1));
                    } else
                        header("Content-Length: {$file_size}");

                    header('Accept-Ranges: bytes');

                    set_time_limit(0);
                    fseek($file, $seek_start);

                    while(!feof($file)) {
                        print(@fread($file, 1024 * 8));

                        ob_flush();
                        flush();

                        if (connection_status() != 0) {
                            @fclose($file);
                            exit;
                        }
                    }

                    // file save was a success
                    @fclose($file);
                    exit;
                } else {
                    // file couldn't be opened
                    header("HTTP/1.0 500 Internal Server Error");
                    exit;
                }
            } else
                // file does not exist
                header("HTTP/1.0 404 Not Found");
            exit;
            //  return $this->response;
        } catch (\PDOException $e) {
            if ($e->getCode() == 12004) {
                throw new ServiceException('Can not find file', self::ERROR_UNABLE_TO_FIND_FILES);
            } else {
                throw new ServiceException($e->getMessage(). $e->getCode(), $e);            }
        }catch (\Exception $e){
            $this->response->setStatusCode(500, 'Internal Server Error');
        }

    }

after that i have a middleware (set as after) for response:

public function call(Micro $app)
    {
        try {

            $return = $app->getReturnedValue();

            if (is_array($return)) {
                // Transforming arrays to JSON
                $app->response->setContent(json_encode($return));
            } elseif (!strlen($return)) {
                // Successful response without any content
                $app->response->setStatusCode('204', 'No Content');
            } else {
                // Unexpected response
                throw new Exception('Bad Response');
            }

            //Sending response to the client
            $app->response->send();
            return true;

        } catch (AbstractHttpException $e) {
            $response = $app->response;
            $response->setStatusCode($e->getCode(), $e->getMessage());
            $response->setJsonContent($e->getAppError());
            $response->send();
        } catch (\Phalcon\Http\Request\Execption $e) {
            $app->response->setStatusCode(400, 'Bad request')
                ->setJsonContent([
                    AbstractHttpException::KEY_CODE => 400,
                    AbstractHttpException::KEY_MESSAGE => 'Bad request'
                ])
                ->send();
        } catch (\Exception $e) {
            if ($e->getCode() === null) {
                $app->response->setStatusCode(500, 'Internal Server Error');
            } else {
                // $app->response->setStatusCode($e->getCode());
            }

        }
    }

with postmen it seems to work. Now i have a Angular client which calls the download endpoint and tries to save the file:

download() {
    const filename = 'test.txt';
    const version = '4';

    const options = {
      headers: new HttpHeaders().append('Authorization', this.oAuthService.getAccessToken()),
      params: new HttpParams().append('responseType', 'text')
    }

    return this.http.get(this.fileServerUrl + 'file/download/' + filename + '/' + version options)
      .subscribe(response => this.saveToFileSystem(response));
     // .then();
     // .catch( error => { console.error(error); });

  }
  private saveToFileSystem(response) {
    const contentDispositionHeader: string = response.headers.get('Content-Disposition');
    const parts: string[] = contentDispositionHeader.split(';');
    const filename = parts[1].split('=')[1];
    console.log(filename);
    const blob = new Blob([response._body], { type: 'text/plain' });
    saveAs(blob, filename);
  }

in the reponse i can see the content of the txt file, but i get always the response error:

HttpErrorResponse {headers: HttpHeaders, status: 200, statusText: "OK", url: "https://localhost:5001/file/download/test.txt/4?responseType=text", ok: false, …}
error
:
{error: SyntaxError: Unexpected token T in JSON at position 0 at JSON.parse (<anonymous>) at XMLHttp…, text: "Test"}
headers
:
HttpHeaders {normalizedNames: Map(0), lazyUpdate: null, lazyInit: ƒ}
message
:
"Http failure during parsing for https://localhost:5001/file/download/test.txt/4?responseType=text"
name
:
"HttpErrorResponse"

in my eyes the fault must be on the phalcon side. The angular client seems to work if i test the download function at another side. Could the mistake be, that after the download action, he sends a json in the after Middleware?

these are the Headers: Genereal:

HttpErrorResponse {headers: HttpHeaders, status: 200, statusText: "OK", url: "https://localhost:5001/file/download/test.txt/4?responseType=text", ok: false, …}
error
:
{error: SyntaxError: Unexpected token T in JSON at position 0 at JSON.parse (<anonymous>) at XMLHttp…, text: "Test"}
headers
:
HttpHeaders {normalizedNames: Map(0), lazyUpdate: null, lazyInit: ƒ}
message
:
"Http failure during parsing for https://localhost:5001/file/download/test.txt/4?responseType=text"
name
:
"HttpErrorResponse"

response header:

Accept-Ranges: bytes
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Range, Content-Disposition, Content-Type, Authorization
Access-Control-Allow-Methods: GET,PUT,POST,DELETE,OPTIONS
Access-Control-Allow-Origin: https://localhost:4200
Cache-Control: public, must-revalidate, post-check=0, pre-check=0
Connection: Keep-Alive
Content-Disposotion: attachment; filename="test.txt"
Content-Encoding: gzip
Content-Type: text/plain;charset=UTF-8
Date: Mon, 27 Aug 2018 15:18:25 GMT
Expires: -1
Keep-Alive: timeout=5, max=99
Pragma: public
Server: Apache/2.4.25 (Debian)
Set-Cookie: PHPSESSID=abcf0b3b4692d7c4be79f208f2ecbb42; path=/
Transfer-Encoding: chunked
Vary: Authorization,Accept-Encoding
X-Powered-By: PHP/7.1.19

request header:

Accept: application/json, text/plain, */*
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Authorization: 41dbfbcde82a7b2f8e817b613c3e2ccf17ffe41ee51cb447feff43c53f549990
Connection: keep-alive
Host: localhost:5001
Origin: https://localhost:4200
Referer: https://localhost:4200/home
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36

sorry for the whole text but I do not know how to continue.

Thank oyu very much in advance

Wow that's a lot of code.

Assuming the requested url is /download/myfile.xls/3, your download action could look like:

public function downloadAction($filename,$version){
    $file_path = // Do logic to determine the location of the requested file on the server
    header('Content-Length: '.filesize($file_path));
    header('Content-Disposition: attachment; filename="'.$filename.'"');
    readfile($file_path);
    exit();
}

Of course you could add error checking and validation, but this is basically all you need.

edited Sep '18

Don't use readfile or set content-diposition in php. Actually you can for example in nginx set header to content-diposition for certain location and as well create internal request for authorization using auth_request. This way php won't read/load this file anywhere and you can still have all authorization you need.

Or at least if you really want to send file using php use fopen and fread to send it in buffer.