Force-download avec Symfony

Aujourd’hui, nous allons aborder quelque chose de simple et répandu sur la plupart des sites Internet de nos jours : le téléchargement de fichiers.

Bien sûr, il ne s’agit pas de permettre aux utilisateurs de télécharger votre dernier rush de photos nocturnes sous forme d’archive zip, ou encore les rapports de la dernière assemblée générale de votre association en PDF; car ceci ne nécessite en rien l’intervention de symfony.

Par contre, dès qu’une action doit être entreprise pour vérifier l’authenticité de l’utilisateur, ou ne serait-ce qu’une table de log pour savoir qui a téléchargé quel fichier, on va avoir besoin de symfony (à moins d’avoir envie de réinventer la roue).

Pour commencer, une action simple, qui affiche selon votre convenance une liste de fichiers ou le détail d’un fichier en particulier, mais surtout, un lien de téléchargement. Pour mon exemple, j’ai déjà fais l’installation du projet, et du plugin sfDoctrineGuardPlugin (avec les fixtures par défaut du plugin).

Mon schéma de base de données ressemble à ça:

Software:
  columns:
    name: string
    path: string
    size: integer
 
Download:
  options:
    symfony:          { form: false, filter: false }
  actAs:
    Timestampable:
      updated:        { disabled: true }
  columns:
    user_id:          { type: integer(4), notnull: true }
    software_id:      { type: integer(4), notnull: true }
  relations:
    Software:
      local:          software_id
      foreign:        id
      foreignAlias:   Downloads
    User:
      class:          sfGuardUser
      local:          user_id
      foreign:        id
      foreignAlias:   Downloads

Mes fixtures, à ça:

Software:
  test:
    name:     symfony latest
    path:     <?php echo sfConfig::get('sf_data_dir') ?>/symfony-1.4.6.tgz
    size:     3233168

Une fois ceci chargé en base de données, on va pouvoir se lancer dans le développement, et plus particulièrement le téléchargement. Pour me simplifier la tâche lors de cet exemple, un unique fichier pourra être télécharger, ainsi, la route pour lancer le « download » du fichier est aussi simple que:

download:
  class:      sfDoctrineRoute
  url:        /download/:id
  options:
    model:    Software
    type:     object
  param:
    module:   file
    action:   download

De ce fait, pour télécharger notre fichier d’exemple, il suffit sur n’importe quelle page, d’inclure un lien vers celui-ci, de cette façon:

<p><?php echo link_to('Télécharger', '@download') ?></p>

Tout le code sera centralisé dans l’action, mais je le répète, ce n’est qu’à titre d’exemple, il serait bien plus approprié de faire un lien de ce type:

<p><?php echo link_to('Télécharger '.$file, 'download', $file) ?></p>

Regardons maintenant plus en détail le contenu de notre action «download» du module «file» (cf. la route ci-dessus)

  public function executeDownload(sfWebRequest $request)
  {
    $this->forward404Unless($this->getUser()->isAuthenticated());
 
    $file = $this->getRoute()->getObject();
 
    $this->forward404Unless(file_exists($file->getPath()), 'Fichier introuvable');
 
    $this->getResponse()->clearHttpHeaders();
    $this->getResponse()->setContentType('application/force-download');
    $this->getResponse()->setHttpHeader('Content-Disposition', 'attachment; filename="' . basename($file->getPath()).'"');
    $this->getResponse()->setHttpHeader('Content-Transfer-Encoding', 'binary');
    $this->getResponse()->setHttpHeader('Content-Length', $file->getSize());
    $this->getResponse()->setHttpHeader('Connection', 'close');
 
    $this->getResponse()->setContent(file_get_contents($file->getPath()));
    $this->getResponse()->send();
  }

A ce niveau, deux problèmes majeurs se posent:

  • file_get_contents est dépendant du paramètre memory_limit de php
  • la web_debug_toolbar va venir s’immiscer dans chaque download

Le problème de la web_debug_toolbar peut vite être résolu en la désactivant dans l’action avec sfConfig::set(‘sf_web_debug_false’, false) mais il reste le problème du téléchargement de fichiers dont la taille dépasse notre memory_limit. Ce paramètre étant généralement bas en production (128 Mo par défaut), ça peut vite être génant. La solution est d’utiliser la fonction php readfile() qui elle, envoit directement des blocs de 8 Ko sur la sortie, et donc n’est pas soumis à la limite d’utilisation de mémoire PHP.

On remplace donc:

    $this->getResponse()->setContent(file_get_contents($file->getPath()));
    $this->getResponse()->send();

par

    $this->getResponse()->sendHttpHeaders();
    @readfile($file->getPath());
    throw new sfStopException();

L’exception sfStopException() est une manière un peu «crade» de mettre un terme à l’exécution du script, mais c’est moins pire qu’un « die » et du coup, ça permet dans un deuxième temps de ne pas être embêté par la web_debug_toolbar.
Il arrive souvent aussi que les headers ne soient pas envoyés, c’est dû au fait que la sortie est bufferisée, ce problème se résoud avec l’utilisation des fonctions flush() et/ou ob_end_clean() juste avant le readfile.

Maintenant, on avait pour but de passer par symfony pour enregistrer des logs sur les téléchargements. Rien de plus simple, un exemple pourrait être le code suivant à rajouter après la vérification de l’existence du fichier (file_exists()) :

    $dl = new Download();
    $dl->setSoftware($file);
    $dl->setUser($this->getUser()->getGuardUser());
    // other stuff
    $dl->save();

A ce stade, tout pourrait sembler correct. C’était sans compter une fourberie de PHP, qui va bloquer toute autre action sur votre site pendant un téléchargement. En effet, les sessions fonctionnent avec un « LOCK » et vu que les sessions sont en auto_start avec symfony, le téléchargement se fait pendant une session, et aucune autre action de la part de l’utilisateur ne sera accepté pendant un téléchargement, car sa session étant identifiée comme « en cours d’écriture » (le principe de lock).
Une personne ayant déjà rencontré ce problème pourrait se dire qu’en fermant la session à l’aide de

$this->getUser()->shutdown();

dans l’action résoudrait le problème, et ce n’est pas le cas, même si en effet, la fonction shutdown() va libérer la session grâce à la fonction session_write_close(), elle ne sera exécuté qu’à la fin du script, donc dans notre cas, ça n’a aucun intêret, il faut faire le session_write_close() directement dans notre action.

Pour finir, voici le code complet de notre action :

  public function executeDownload(sfWebRequest $request)
  {
    $this->forward404Unless($this->getUser()->isAuthenticated());
 
    $file = $this->getRoute()->getObject();
 
    $this->forward404Unless(file_exists($file->getPath()), 'Fichier introuvable');
 
    $dl = new Download();
    $dl->setSoftware($file);
    $dl->setUser($this->getUser()->getGuardUser());
    // other stuff
    $dl->save();
 
    session_write_close();
 
    $this->getResponse()->clearHttpHeaders();
    $this->getResponse()->setContentType('application/force-download');
    $this->getResponse()->setHttpHeader('Content-Disposition', 'attachment; filename="' . basename($file->getPath()).'"');
    $this->getResponse()->setHttpHeader('Content-Transfer-Encoding', 'binary');
    $this->getResponse()->setHttpHeader('Content-Length', $file->getSize());
    $this->getResponse()->setHttpHeader('Connection', 'close');
 
    $this->getResponse()->sendHttpHeaders();
 
//    @ob_end_clean();
//    flush();
 
    @readfile($file->getPath());
 
    throw new sfStopException();
  }

J’espère que ceci vous aidera dans vos développements actuels et futurs,
@ bientôt.

Partagez cet article