Comment reprendre le téléchargement d’un fichier en PHP

Dans un précédent article, nous avons vu comment forcer le téléchargement de fichier en PHP. Vous pourrez trouver, sur plein de sites différents, des codes sources plus ou moins similaires l’un à l’autre, mais il est très rare de trouver un code de téléchargement de fichier permettant la gestion de la reprise de téléchargement. Le code de mon précédent article ne permet pas, lui non plus, de gérer la reprise du transfert (je l’avais précisé en fin d’article). Mais c’était voulu, chaque chose en son temps.

Voici quelques raisons d’implémenter cette fonctionnalité :

  • pouvoir mettre en pause afin d’effectuer une autre tâche (ex: un autre téléchargement plus important).
  • coupure imprévue (problème réseau, électricité, le chat qui passe sur la prise électrique, etc).
  • économiser de la bande passante. Quand on télécharge 30Mo sur 50Mo et qu’il faut recommencer, ça consomme de la bande inutilement.
  • pour le streaming, cela permet de naviguer dans la lecture sans attendre le téléchargement entier du fichier.
  • tout simplement parce que c’est possible, alors on le fait 😉

Je vais essayer de vous montrer comment reprendre le transfert d’un fichier en expliquant au mieux chaque étape de l’implémentation. Si toutefois, vous ne souhaitez pas avoir toutes une série d’explications avant d’avoir votre sésame, vous pouvez vous rendre immédiatement en bas de l’article récupérer le code entier.

Rappel de code

Avant tout, je vous rappel le code vu dans l’article précédent pour forcer le téléchargement d’un fichier. J’ai supprimé les commentaires ainsi que la partie contrôle d’accès afin de le réduire au maximum :

<?php
set_time_limit(0);

if (empty($_GET["file"])) {
    header("HTTP/1.1 404 Not Found");
    exit;
}

if(basename($_GET["file"]) != $_GET["file"]) {
    header("HTTP/1.1 400 Bad Request");
    exit;
}
$name = $_GET["file"];

$filename = dirname(__FILE__)."/../files/".$name;
if (!is_file($filename) || !is_readable($filename)) {
    header("HTTP/1.1 404 Not Found");
    exit;
}
$size = filesize($filename);

if (ini_get("zlib.output_compression")) {
    ini_set("zlib.output_compression", "Off");
}

session_write_close();

header("Cache-Control: no-cache, must-revalidate");
header("Cache-Control: post-check=0,pre-check=0");
header("Cache-Control: max-age=0");
header("Pragma: no-cache");
header("Expires: 0");

header("Content-Type: application/force-download");
header('Content-Disposition: attachment; filename="'.$name.'"');

header("Content-Length: ".$size);

readfile($filename);

Pour plus d’informations à propos de ce code, je vous recommande la lecture de l’article en question.

Ce code envoie systématiquement tout le contenu du fichier. Pour reprendre un transfert, le navigateur va envoyer au serveur une entête spécifique prévu par le protocole HTTP. Nous allons donc modifier notre code afin de prendre en compte cette requête.

Commençons par ajouter une ligne indiquant au navigateur la prise en charge de cette fonctionnalité.

...
header('Content-Disposition: attachment; filename="'.$name.'"');

header("Accept-Ranges: bytes");

header("Content-Length: ".$size);

readfile($filename);

Cette ligne n’est pas obligatoire, mais elle est spécifiée afin d’être conforme au protocole.

Nous allons modifier la façon d’envoyer les données. Comme readfile envoie le contenu entier du fichier directement vers la sortie, sans permettre la lecture par portion, nous allons utiliser les fonctions fopen, fread et fclose.

...
header('Content-Disposition: attachment; filename="'.$name.'"');

header("Accept-Ranges: bytes");

header("Content-Length: ".$size);

$f = fopen($filename, "rb");
while (false !== $datas = fread($f, 1024)) {
    echo $datas;
}
fclose($f);

Maintenant, tout se joue sur ce que le navigateur va demander. S’il souhaite reprendre un téléchargement, il va envoyer dans une entête les informations nécessaires à la réception des bonnes données. Cette entête est intitulée Range et est de la forme :
bytes START-END/TOTAL :

  • START est l’offset du premier octet.
  • END est l’offset du dernier octet à envoyer (si non spécifié, jusque la fin du fichier).
  • TOTAL représente le nombre total d’octet du fichier.

Normalement, TOTAL doit être spécifié. Mais en testant sous Firefox et Wget, il semble qu’il ne soit pas spécifié.

Sachez que le client peut demander n’importe quelle portion de données à télécharger. Le protocole prévoit même la possibilité de récupérer plusieurs portions différentes dans une même requête (cependant, nous ne verrons pas cette partie dans cet article).

Grâce à cette fonctionnalité, certains logiciels créent plusieurs requêtes simultanées afin d’accélérer le téléchargement (je ne sais pas si c’est toujours efficace de nos jours). Un des plus connu est Flashget (que j’utilisais il y a quelques années). Regardez cet aperçu :

Dans la partie basse, vous pouvez observer l’évolution du téléchargement du fichier. Les carrés gris représentent les parties non téléchargées. En bleu, vous avez les parties téléchargées. Enfin, en vert, les données en cours de téléchargement. Vous remarquez que le téléchargement n’est pas contigus. En fait, chaque partie bleu représentent une requête de téléchargement (je les ai indiquées en rouge).

Ceci signifie que cette fonctionnalité va au-delà de la reprise d’un téléchargement.

Reprenons là où nous en étions dans notre code. Nous allons prendre en compte la présence de cette entête. Mettons à jour le code en conséquence :

...
header('Content-Disposition: attachment; filename="'.$name.'"');

header("Accept-Ranges: bytes");

$start = 0;
$end = $size - 1;
if (isset($_SERVER["HTTP_RANGE"])) {

}

header("Content-Length: ".$size);

$f = fopen($filename, "rb");
while (false !== $datas = fread($f, 1024)) {
    echo $datas;
}
fclose($f);

Il faut prendre soin de placer ces lignes avant l’envoi de l’entête Content-Length. En effet, si nous envoyons une portion du fichier, la taille du contenu correspondra à cette portion. Nous initialisons deux variables $start et $end. Elles vont représenter les bornes de la portion à envoyer. Par défaut, nous envoyons tout le contenu du fichier. Nous définissons donc $start à 0 et $end à la taille du fichier (-1 puisqu’on commence à 0).

Entamons maintenant le plus « complexe ». Nous allons contrôler la valeur de l’entête afin de tester sa validité. Si l’entête est invalide, nous enverrons le code HTTP « 416 Requested Range Not Satisfiable » qui signifie que la plage de données ne peut-être satisfaite par les caractéristiques du fichier à télécharger.

Je vais expliquer point par point le code. Celui-ci est à ajouter dans le bloc de la condition, à partir de la ligne 42. Un résumé du code sera disponible.

    if (!preg_match("#bytes=([0-9]+)?-([0-9]+)?(/[0-9]+)?#i", $_SERVER['HTTP_RANGE'], $m)) {
        header("HTTP/1.1 416 Requested Range Not Satisfiable");
        exit;
    }

Cette règle permet de tester la validité du format de l’entête. C’est une RegExp basique. Les « ? » indiquent la possibilité de l’absence de valeur. En effet, START, END et TOTAL peuvent ne pas être spécifiés. Par contre, si START n’est pas spécifié, alors END doit l’être.

    $start = !empty($m[1])?(int)$m[1]:null;
    $end = !empty($m[2])?(int)$m[2]:$end;
    if (!$start && !$end || $end !== null && $end >= $size
        || $end && $start && $end < $start) {
        header("HTTP/1.1 416 Requested Range Not Satisfiable");
        exit;
    }

Au moins une des variables $start et $end doit être fournies. Si les deux sont fournies, alors $end doit être supérieur ou égale à $start. En outre, $end ne peut excéder $size-1.

    if ($start === null) {
        $start = $size - $end;
        $end -= 1;
    }

Si $start n’est pas spécifié, alors nous définissons $start par rapport à $end ce qui signifie qu’il faut envoyer les $end derniers octets. Pour cela, nous partons de la fin du fichier pour revenir en arrière de $end octets. Par exemple, si le fichier fait 700 octets et $end = 400, nous positionnons $start à 300. C’est le protocole HTTP qui définis cela ainsi. Il faut penser à décrémenter $end de 1 afin qu’il corresponde à la position du dernier octet.

Voilà pour la partie vérification et initialisation. Indiquons maintenant au client que nous allons lui envoyer un contenu partiel :

    header("HTTP/1.1 206 Partial Content");

Et bien entendu, nous lui confirmons quelle plage de données nous lui envoyons :

    header("Content-Range: ".$start."-".$end."/".$size);

$size est la taille du fichier entier, et non pas de la plage de données envoyée.

Et c’est terminé pour cette partie. Voici notre précédent code avec les modifications apportées :

...
header('Content-Disposition: attachment; filename="'.$name.'"');

header("Accept-Ranges: bytes");

$start = 0;
$end = $size - 1;
if (isset($_SERVER["HTTP_RANGE"])) {
    if (!preg_match("#bytes=([0-9]+)?-([0-9]+)?(/[0-9]+)?#i", $_SERVER['HTTP_RANGE'], $m)) {
        header("HTTP/1.1 416 Requested Range Not Satisfiable");
        exit;
    }

    $start = !empty($m[1])?(int)$m[1]:null;
    $end = !empty($m[2])?(int)$m[2]:$end;
    if (!$start && !$end || $end !== null && $end >= $size
        || $end && $start && $end < $start) {
        header("HTTP/1.1 416 Requested Range Not Satisfiable");
        exit;
    }

    if ($start === null) {
        $start = $size - $end;
        $end -= 1;
    }

    header("HTTP/1.1 206 Partial Content");
    header("Content-Range: ".$start."-".$end."/".$size);
}

header("Content-Length: ".$size);

$f = fopen($filename, "rb");
while (false !== $datas = fread($f, 1024)) {
    echo $datas;
}
fclose($f);

Nous allons modifier l’entête Content-Length afin d’y indiquer la bonne valeur. En effet, il ne faut plus envoyer la taille du fichier mais la taille de la plage de données définie par $start et $end. Ceci permettra au client de gérer le temps restant du transfert ainsi qu’une barre de progression.

Modifions le code :


header("Content-Length: ".($end-$start+1));

N’oubliez pas le +1 car on commence à 0. Par exemple, si le fichier fait 700 octets et que nous souhaitons télécharger le fichier entier, nous aurons 699 – 0 + 1 = 700.

Et pour terminer, il faut mettre à jour le code d’envoi des données. Nous allons utiliser la fonction fseek afin de déplacer la position du curseur de lecture du fichier suivant la valeur de $start. Il faut aussi arrêter d’envoyer les données quand la position $end est atteinte.

$f = fopen($filename, "rb");
fseek($f, $start);
$remainingSize = $end-$start+1;
$length = $remainingSize < 4096?$remainingSize:4096;
while (false !== $datas = fread($f, $length)) {
    echo $datas;
    $remainingSize -= $length;
    if ($remainingSize <= 0) {
         break;
    }
    if ($remainingSize < $length) {
        $length = $remainingSize;
    }
}
fclose($f);

$remainingSize contient le nombre d’octets restant à envoyer. La variable est initialisée à la taille de la plage de données à envoyer. Ensuite, je décide d’envoyer les données par lot de 4ko. $length contiendra cette valeur. Par contre, si la taille de la plage de données est inférieur à 4ko, il faut initialiser $length à la valeur de la plage pour ne pas envoyer plus de données que demandée. Ensuite, on retrouve la boucle avec la fonction fread qui récupère $length octets.

Les données sont envoyées (grâce au echo). $remainingSize est soustrait de $length octets. Si la totalité des données a été envoyée ($remainingSize < = 0), alors la boucle est stoppée (break). Si la boucle continue, nous vérifions que $remainingSize soit toujours supérieur ou égale à $length afin de ne pas envoyer plus de données que nécessaire (comme au moment de l’initialisation de $length).

Cette partie du code (la boucle while) peut être améliorée par différent moyen. Mais, ça convient bien comme ça 😉

À la fin, toutes les données devraient avoir été envoyées.

Code entier avec commentaires

Voici un récapitulatif du code auquel j’ai joint des commentaires.

<?php
set_time_limit(0);

if (empty($_GET["file"])) {
    header("HTTP/1.1 404 Not Found");
    exit;
}

if(basename($_GET["file"]) != $_GET["file"]) {
    header("HTTP/1.1 400 Bad Request");
    exit;
}
$name = $_GET["file"];

$filename = dirname(__FILE__)."/../files/".$name;
if (!is_file($filename) || !is_readable($filename)) {
    header("HTTP/1.1 404 Not Found");
    exit;
}
$size = filesize($filename);

if (ini_get("zlib.output_compression")) {
    ini_set("zlib.output_compression", "Off");
}

session_write_close();

header("Cache-Control: no-cache, must-revalidate");
header("Cache-Control: post-check=0,pre-check=0");
header("Cache-Control: max-age=0");
header("Pragma: no-cache");
header("Expires: 0");

header("Content-Type: application/force-download");
header('Content-Disposition: attachment; filename="'.$name.'"');

// on indique au client la prise
// en charge de l'envoi de données
// par portion.
header("Accept-Ranges: bytes");

// par défaut, on commence au début du fichier
$start = 0;

// par défaut, on termine à la fin du fichier (envoi complet)
$end = $size - 1;
if (isset($_SERVER["HTTP_RANGE"])) {
    // l'entête doit être dans un format valide
    if (!preg_match("#bytes=([0-9]+)?-([0-9]+)?(/[0-9]+)?#i", $_SERVER['HTTP_RANGE'], $m)) {
        header("HTTP/1.1 416 Requested Range Not Satisfiable");
        exit;
    }

    // modification de $start et $end
    // et on vérifie leur validité
    $start = !empty($m[1])?(int)$m[1]:null;
    $end = !empty($m[2])?(int)$m[2]:$end;
    if (!$start && !$end || $end !== null && $end >= $size
        || $end && $start && $end < $start) {
        header("HTTP/1.1 416 Requested Range Not Satisfiable");
        exit;
    }

    // si $start n'est pas spécifié,
    // on commence à $size - $end
    if ($start === null) {
        $start = $size - $end;
        $end -= 1;
    }

    // indique l'envoi d'un contenu partiel
    header("HTTP/1.1 206 Partial Content");

    // décrit quelle plage de données est envoyée
    header("Content-Range: ".$start."-".$end."/".$size);
}

// on indique bien la taille des données envoyées
header("Content-Length: ".($end-$start+1));

// ouverture du fichier en lecture et en mode binaire
$f = fopen($filename, "rb");

// on se positionne au bon endroit ($start)
fseek($f, $start);

// cette variable sert à connaître le nombre
// d'octet envoyé.
$remainingSize = $end-$start+1;

// calcul la taille des lots de données
// je choisi 4ko ou $remainingSize si plus
// petit que 4ko.
$length = $remainingSize < 4096?$remainingSize:4096;

while (false !== $datas = fread($f, $length)) {
    // envoie des données vers le client
    echo $datas;

    // on a envoyé $length octets,
    // on le soustrait alors du
    // nombre d'octets restant.
    $remainingSize -= $length;

    // si tout est envoyé, on quitte
    // la boucle.
    if ($remainingSize <= 0) {
        break;
    }

    // si reste moins de $length octets
    // à envoyer, on le rédefinit en conséquence.
    if ($remainingSize < $length) {
        $length = $remainingSize;
    }
}
fclose($f);

Si un point vous échappe, n’hésitez pas à me demander des éclaircissements dans les commentaires de cet article.

Conclusion

Avec ce code, vos utilisateurs seront heureux de pouvoir reprendre le transfert de leur fichier.

Dans un prochain article, nous verrons les limitations que nous pouvons imposer aux utilisateurs lors de leur téléchargement. Par exemple, nous verrons comment limiter la vitesse du téléchargement.

11 réflexions sur « Comment reprendre le téléchargement d’un fichier en PHP »

  1. Article très intéressant. Mais l’approche la plus simple et la plus performante si on a le contrôle sur le serveur Web, c’est d’utiliser l’entête X-Sendfile. Pour cela il faut un module spécifique pour Apache (mod_xsendfile). Apparemment il supporte les ranges (http://www.brighterlamp.com/2010/10/send-files-faster-better-with-php-mod_xsendfile/). Avec Nginx, on peut utiliser la fonctionnalité X-Accel-Redirect (http://wiki.nginx.org/XSendfile), mais je ne sais pas si le téléchargement partiel est supporté.

  2. Pour la simplicité de la chose, j’avoue que passer par l’entête X-Sendfile n’est pas mal du tout. En tout cas, c’est intéressant à connaître.

    En ce qui concerne l’article, je viens de le mettre à jour afin d’ajout « set_time_limit » que j’ai ajouté dans l’article précédent. Une erreur bête …

  3. Bonjour,
    J’aimerais vous contacter directement pour vous proposer quelque chose mais je ne trouve pas de moyen direct de le faire serait il possible de me revenir par email ou de me donner un moyen de vous contacter ?
    Merci

  4. C’est un très bon article (sujet intéressant, bien écrit, bien structuré, et accessible par tout le monde). Du bon boulot ! J’ai même fait un lien dans un forum de CCM (je ne connaissais pas ce standard).
    Je vais peut être utiliser cette techno dans mon site de partage / publication de ressource du coup !
    Et Chrome / FF maintenant propose cette option quand on télécharge, c’est justement grâce à ça non !?

  5. Bonjour,
    Tres bon article, je n’y connait pas grand chose en PHP, mais j’essaye de m’en inspiré pour géré un petit site perso. que j’héberge sur mon NAS (j’ai la fibre optique a la maison).
    Apriori PHP limite la taille du fichier a la valeur du memory_limit (php.ini=128Mo), et mon NAS a 512Mo de ram. J’héberge des video HD en mp4 que ma famille peut télécharger a distance (>512Mo).
    Y aurait’il un moyen de s’affranchir de la valeur de memory_limit (que je réglerait à 312Mo par exemple)?
    Peut etre faut-il activé le Cache-control?
    Actuellement j’envoi les fichiers simplement avec un lien hypertext Charger;mais comme dit dans l’article y a beaucoup davantage sur ta méthode qui par ailleurs fonctionne parfaitement pour les « petits fichiers ».

  6. Bonsoir tout le monde.

    Une petite question (en fait un blocage complet 🙁 ) !

    Comment peut on detecter la fin d’un téléchargement chez le client (ou même où il en est, genre barre de progression mais de download, genre par l’entremise d’une base MySql…) ? de plus il semble que tous les scripts pour « forcer » le download commence à tourner même si le client n’a pas encore cliqué sur « enregistrer sous » ???? avez vu quelques pistes, vous me sortiriez alors d’une grosse panade….

    Merci à tous.

  7. Avec les API HTML 5, il est maintenant possible de faire simplement des progress bar pour l’upload.
    En outre, il est possible de faire la même chose pour le download. Par contre, je n’ai rien à te montrer niveau code.

    Pour le problème du téléchargement avant même d’avoir cliqué sur « enregistrer sous », c’est hélas une fonctionnalité des navigateurs. Ils commencent à télécharger le fichier même si l’utilisateur n’a pas fait son choix. Ainsi, il y a un gain de temps pour ce dernier. On ne peut rien y faire.

  8. Salut j’ai suivi ton tuto qui est très bien fait (vraiment) mais j’ai un problème je ne peux pas télécharger des fichier supérieur a 2go car j’ai processeur 32bit (j’ai un nas) mes variables de type int sont donc limité a 2^31, y a-t-il une solution pour régler ce problème? Merci

    • Je suis désolé, je ne vais pas répondre à ta question tout simplement parce que je n’ai actuellement pas d’idée pour résoudre ton problème.
      Je n’ai pas de système 32bits sous la main. Si je trouve le temps, j’essayerai de me faire une image VirtualBox avec un OS 32 bits.

      Essaie de chercher dans ton moteur de recherche, je suis sur que d’autre applications ont rencontrés cette contrainte. Ils ont peut-être une solution.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Notifiez-moi des commentaires à venir via email. Vous pouvez aussi vous abonner sans commenter.