Download a CSV file in PHP

Introduction

Hello, I am a french student in trainee at an association, and I must create and manage a Nextcloud server.

My clients wanted can download a contact in CSV format. So we want a button to save a contact in CSV file and that app make download this.

Problems

My problem, is I can’t make download the CSV file !
When I click to the download button I have this error in log :

Cannot modify header information - headers already sent by (output started at /var/www/html/nextcloud/apps/lauruxcontact/lib/Controller/ShowcontactController.php:148) at /var/www/html/nextcloud/lib/private/AppFramework/Http/Output.php#69

The code

The function saveInCSV in PHP

public function saveInCSV($utilisateur)
	{
		$contact = $this->contactsManager->search($utilisateur, ['UID'], ['types' => true])[0];

		$filename = "contact_export_" . date("Y-m-d") . ".csv";

		// disable caching
		$now = gmdate("D, d M Y H:i:s");
		header("Cache-Control: max-age=0, no-cache, must-revalidate, proxy-revalidate");
		header("Last-Modified: ".$now." GMT");
	
		// force download  
		header("Content-Type: application/force-download");
		header("Content-Type: application/octet-stream");
		header("Content-Type: application/download");
		// header("Content-type: text/csv");
	
		// disposition / encoding on response body
		header("Content-Disposition: attachment;filename=".$filename);
		header("Content-Transfer-Encoding: binary");

		$data = array(
			array("prenom", explode(" ", $contact['FN'])[1])
		);
		   
		ob_start();

		$df = fopen($filename, 'w');
		fputcsv($df, array_keys(reset($data)));
		foreach ($data as $row) {
			fputcsv($df, $row);
		}
		fclose($df);

		ob_get_clean();

		readfile($filename); // <= Line where is error
	}

The JS code for button

(function(){
    if(!OCA.LauruxContact) {
        /**
		 * Namespace for the files app
		 * @namespace OCA.LauruxContact
		 */
        OCA.LauruxContact = {};
    }

    /**
	 * @namespace OCA.LauruxContact.Affichercontact
	 */
    OCA.LauruxContact.Affichercontact = {
        initialize: function() {
            $('.saveInCSV').on('click', _.bind(this._clickSaveInCSV, this));
        },

        _clickSaveInCSV : function(e)
        {
            OC.msg.startSaving('#error_label_csv');
            var request = $.ajax({
				url: OC.generateUrl('/apps/lauruxcontact/showcontact/saveInCSV'),
				type: 'POST',
				data: {
                    utilisateur: e.target.id,
				}
            });
            
            request.done(function () 
            {
                OC.msg.finishedSuccess('#error_label_csv', 'Sauvegardé !');
            });

            request.fail(function () {
                OC.msg.finishedError('#error_label_csv', 'Erreur !');
            });
        }
    }
})();

$(document).ready(function(){
    OCA.LauruxContact.Affichercontact.initialize();
});

The route

['name' => 'showcontact#saveInCSV',             'url' => '/showcontact/saveInCSV',              'verb' => 'POST'],

Tests

First test

After severals test, I have the good headers but the button don’t cant download the file at client !

CSVResponse

<?php
namespace OCA\LauruxContact\Response;

use OCP\AppFramework\Http\Response;

class CSVResponse extends Response 
{
    private $csv;

    public function __construct(array $csv)
    {
        $filename = "contact_export_" . date("Y-m-d") . ".csv";
        $now = gmdate("D, d M Y H:i:s");

        $this->addHeader('Content-Type', 'application/octet-stream');
		$this->addHeader("Cache-Control", "max-age=0, no-cache, must-revalidate, proxy-revalidate");
		$this->addHeader("Last-Modified", $now." GMT");
		$this->addHeader("Content-Disposition", "attachment;filename=".$filename);
        $this->addHeader("Content-Transfer-Encoding","binary");
        
        $this->csv = $csv;
    }

    public function render()
    {
        $res = "";
        
        foreach($this->csv as $row)
        {
            $res = $res . implode(",", $row) . ",\n";
        }
        
        return $res;
    }
}

saveInCSV()

public function saveInCSV($utilisateur)
	{
		$contact = $this->contactsManager->search($utilisateur, ['UID'], ['types' => true])[0];

		$filename = "contact_export_" . date("Y-m-d") . ".csv";		

		$data = array(
			array("prenom", explode(" ", $contact['FN'])[1])
		);
		
		return new CSVResponse($data);
	}

The header

Cache-Control: max-age=0, no-cache, must-revalidate, proxy-revalidate
Connection: Keep-Alive
Content-Disposition: attachment;filename=contact_export_2020-06-15.csv
Content-Length: 14
Content-Security-Policy: default-src 'none';base-uri 'none';manifest-src 'self'
Content-Transfer-Encoding: binary
Content-Type: application/octet-stream
Date: Mon, 15 Jun 2020 15:01:36 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Feature-Policy: autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone 'none';payment 'none'
Keep-Alive: timeout=5, max=72
Last-Modified: Mon, 15 Jun 2020 15:01:37 GMT
Pragma: no-cache
Referrer-Policy: no-referrer
Server: Apache/2.4.41 (Ubuntu)
Strict-Transport-Security: max-age=15768000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Robots-Tag: none
X-XSS-Protection: 1; mode=block

Second test

With my research I had found a new class which is OCP\AppFramework\Http\DownloadResponse ! But it’s doesn’t work too, the header is correct but he don’t want download file.

CSVDownloadResponse

<?php
namespace OCA\LauruxContact\Response;

use OCP\AppFramework\Http\DownloadResponse;

class CSVDownloadResponse extends DownloadResponse 
{
  private $content;

  public function __construct(string $content, string $filename, string $contentType) {
    parent::__construct($filename, $contentType);

    $this->content = $content;
  }
    
  public function render(): string {
		return $this->content;
	}
}

saveInCSV()

public function saveInCSV($id)
	{
		$contact = $this->contactsManager->search($id, ['UID'], ['types' => true])[0];

		$filename = "contact_export_" . date("Y-m-d") . ".csv";

		$csv = array(
			array("prenom", explode(" ", $contact['FN'])[1])
		);

		$res = "";
        
        foreach($csv as $row)
        {
            $res = $res . implode(",", $row) . ",\n";
		}
		
		return new CSVDownloadResponse($res, $filename, 'application/octet-stream');
	}

showContact.js

_clickRetour : function()
        {
            document.location.href=OC.generateUrl('/apps/lauruxcontact/');
        },

        _clickSaveInCSV : function(e)
        {
            OC.msg.startSaving('#error_label_csv');
            var request = $.ajax({
				url: OC.generateUrl('/apps/lauruxcontact/showcontact/saveInCSV/'+e.target.id),
                type: 'GET'            
            });
            
            request.done(function () 
            {
                OC.msg.finishedSuccess('#error_label_csv', 'Sauvegardé !');
            });

            request.fail(function () {
                OC.msg.finishedError('#error_label_csv', 'Erreur !');
            });
        }

Routes

['name' => 'showcontact#saveInCSV',             'url' => '/showcontact/saveInCSV/{id}',         'verb' => 'GET'],

Header

Cache-Control: no-cache, no-store, must-revalidate
Connection: Keep-Alive
Content-Disposition: attachment; filename="contact_export_2020-06-16.csv"
Content-Length: 14
Content-Security-Policy: default-src 'none';base-uri 'none';manifest-src 'self'
Content-Type: application/octet-stream
Date: Tue, 16 Jun 2020 09:02:02 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Feature-Policy: autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone 'none';payment 'none'
Keep-Alive: timeout=5, max=61
Pragma: no-cache
Referrer-Policy: no-referrer
Server: Apache/2.4.41 (Ubuntu)
Strict-Transport-Security: max-age=15768000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Robots-Tag: none
X-XSS-Protection: 1; mode=block

Do you have a solution ?

Comment out the header calls for testing. I think they are the problem. You should construct a Nextcloud response object and set the headers on the object.

I have comment this :

header("Content-Type: application/force-download");
header("Content-Type: application/octet-stream");
header("Content-Type: application/download");

But he don’t want download file, but I don’t have error !

Good, so that confirms my suspicious.

Again, try to create the response with an object. Since what you want to download is neither a template nor JSON, I guess you’ll have to write your own class for this. You can find the docs at https://docs.nextcloud.com/server/19/developer_manual/app/requests/controllers.html#creating-custom-responses. Note how that sets custom headers in the constructor.

Not error, but he don’t want download my file :

My custom response :

<?php
namespace OCA\LauruxContact\Response;

use OCP\AppFramework\Http\Response;

class CSVResponse extends Response 
{
    private $csv;

    public function __construct(array $csv)
    {
        $filename = "contact_export_" . date("Y-m-d") . ".csv";
        $now = gmdate("D, d M Y H:i:s");

        $this->addHeader('Content-Type', 'application/force-download');
		$this->addHeader("Cache-Control", "max-age=0, no-cache, must-revalidate, proxy-revalidate");
		$this->addHeader("Last-Modified", $now." GMT");
		$this->addHeader("Content-Disposition", "attachment;filename=".$filename);
        $this->addHeader("Content-Transfer-Encoding","binary");
        
        $this->csv = $csv;
    }

    public function render()
    {
        $filename = "contact_export_" . date("Y-m-d") . ".csv";
        ob_start();

		$df = fopen($filename, 'w');
		fputcsv($df, array_keys(reset($this->csv)));
		foreach ($this->csv as $row) {
			fputcsv($df, $row);
		}
		fclose($df);

		return ob_get_clean();
    }
}

The function saveInCSV :

public function saveInCSV($utilisateur)
	{
		$contact = $this->contactsManager->search($utilisateur, ['UID'], ['types' => true])[0];

		$filename = "contact_export_" . date("Y-m-d") . ".csv";

		// disable caching
		

		$data = array(
			array("prenom", explode(" ", $contact['FN'])[1])
		);
		
		$resp = new CSVResponse($data);
		$resp->render();
	}

Who’s he? What is the expected vs actual behavior? Have you tried inspecting the response headers so you see if yours are actually added?

I talk of button, he must normaly, make download CSV file at client.
I have see the header of POST request to the url /showcontact/saveInCSVand I see it :

Cache-Control: no-cache, no-store, must-revalidate
Connection: Keep-Alive
Content-Length: 4
Content-Security-Policy: default-src 'none';base-uri 'none';manifest-src 'self'
Content-Type: application/json; charset=utf-8
Date: Mon, 15 Jun 2020 13:21:58 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Feature-Policy: autoplay 'none';camera 'none';fullscreen 'none';geolocation 'none';microphone 'none';payment 'none'
Keep-Alive: timeout=5, max=71
Pragma: no-cache
Referrer-Policy: no-referrer
Server: Apache/2.4.41 (Ubuntu)
Strict-Transport-Security: max-age=15768000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-Robots-Tag: none
X-XSS-Protection: 1; mode=block

I don’t think this is right. That HTTP response does hot have any Content-Disposition for example.

I know but I don’t undestand why :confused:

Upon investigation I have this error :

TypeError: Argument 3 passed to OC\AppFramework\Middleware\MiddlewareDispatcher::beforeOutput() must be of the type string, int given, called in /var/www/html/nextcloud/lib/private/AppFramework/Http/Dispatcher.php on line 124

And here the modification :
CSVResponse :

<?php
namespace OCA\LauruxContact\Response;

use OCP\AppFramework\Http\Response;

class CSVResponse extends Response 
{
    private $csv;

    public function __construct(array $csv)
    {
        $filename = "contact_export_" . date("Y-m-d") . ".csv";
        $now = gmdate("D, d M Y H:i:s");

        $this->addHeader('Content-Type', 'text/csv');
		$this->addHeader("Cache-Control", "max-age=0, no-cache, must-revalidate, proxy-revalidate");
		$this->addHeader("Last-Modified", $now." GMT");
		$this->addHeader("Content-Disposition", "attachment;filename=".$filename);
        $this->addHeader("Content-Transfer-Encoding","binary");
        
        $this->csv = $csv;
    }

    public function render()
    {
        $filename = "contact_export_" . date("Y-m-d") . ".csv";

		$df = fopen($filename, 'w');
		fputcsv($df, array_keys(reset($this->csv)));
		foreach ($this->csv as $row) {
			fputcsv($df, $row);
		}
        
        return readfile($filename);
    }
}

The function “saveInCSV”

public function saveInCSV($utilisateur)
	{
		$contact = $this->contactsManager->search($utilisateur, ['UID'], ['types' => true])[0];

		$filename = "contact_export_" . date("Y-m-d") . ".csv";		

		$data = array(
			array("prenom", explode(" ", $contact['FN'])[1])
		);
		
		return new CSVResponse($data);
	}

I have update the question, if you want ^^

I had write my second test for the problems !

I’m not sure I can follow. Does it work now?

Nope sorry

OK I have the solution !

The JS code

_clickSaveInCSV : function(e)
        {
            document.location.href=OC.generateUrl('/apps/lauruxcontact/showcontact/saveInCSV/'+e.target.id);        
        }

The PHP function

return new CSVDownloadResponse($res, $filename, 'application/octet-stream');

CSVDownloadResponse

<?php
namespace OCA\LauruxContact\Http;

use OCP\AppFramework\Http\DownloadResponse;

class CSVDownloadResponse extends DownloadResponse 
{
  private $content;

  public function __construct(string $content, string $filename, string $contentType) {
    parent::__construct($filename, $contentType);

    $this->content = $content;

    $expires = new \DateTime('now + 11 months');
    $this->addHeader('Expires', $expires->format(\DateTime::RFC1123));
    $this->addHeader('Cache-Control', 'private');
    $this->addHeader('Pragma', 'cache');
  }
    
  public function render(): string {
		return $this->content;
	}
}