Moyen le plus rapide de servir un fichier en utilisant PHP

98

J'essaie de créer une fonction qui reçoit un chemin de fichier, identifie ce que c'est, définit les en-têtes appropriés et la sert comme le ferait Apache.

La raison pour laquelle je fais cela est que je dois utiliser PHP pour traiter certaines informations sur la demande avant de servir le fichier.

La vitesse est essentielle

virtual () n'est pas une option

Doit travailler dans un environnement d'hébergement partagé où l'utilisateur n'a aucun contrôle sur le serveur Web (Apache / nginx, etc.)

Voici ce que j'ai jusqu'à présent:

File::output($path);

<?php
class File {
static function output($path) {
    // Check if the file exists
    if(!File::exists($path)) {
        header('HTTP/1.0 404 Not Found');
        exit();
    }

    // Set the content-type header
    header('Content-Type: '.File::mimeType($path));

    // Handle caching
    $fileModificationTime = gmdate('D, d M Y H:i:s', File::modificationTime($path)).' GMT';
    $headers = getallheaders();
    if(isset($headers['If-Modified-Since']) && $headers['If-Modified-Since'] == $fileModificationTime) {
        header('HTTP/1.1 304 Not Modified');
        exit();
    }
    header('Last-Modified: '.$fileModificationTime);

    // Read the file
    readfile($path);

    exit();
}

static function mimeType($path) {
    preg_match("|\.([a-z0-9]{2,4})$|i", $path, $fileSuffix);

    switch(strtolower($fileSuffix[1])) {
        case 'js' :
            return 'application/x-javascript';
        case 'json' :
            return 'application/json';
        case 'jpg' :
        case 'jpeg' :
        case 'jpe' :
            return 'image/jpg';
        case 'png' :
        case 'gif' :
        case 'bmp' :
        case 'tiff' :
            return 'image/'.strtolower($fileSuffix[1]);
        case 'css' :
            return 'text/css';
        case 'xml' :
            return 'application/xml';
        case 'doc' :
        case 'docx' :
            return 'application/msword';
        case 'xls' :
        case 'xlt' :
        case 'xlm' :
        case 'xld' :
        case 'xla' :
        case 'xlc' :
        case 'xlw' :
        case 'xll' :
            return 'application/vnd.ms-excel';
        case 'ppt' :
        case 'pps' :
            return 'application/vnd.ms-powerpoint';
        case 'rtf' :
            return 'application/rtf';
        case 'pdf' :
            return 'application/pdf';
        case 'html' :
        case 'htm' :
        case 'php' :
            return 'text/html';
        case 'txt' :
            return 'text/plain';
        case 'mpeg' :
        case 'mpg' :
        case 'mpe' :
            return 'video/mpeg';
        case 'mp3' :
            return 'audio/mpeg3';
        case 'wav' :
            return 'audio/wav';
        case 'aiff' :
        case 'aif' :
            return 'audio/aiff';
        case 'avi' :
            return 'video/msvideo';
        case 'wmv' :
            return 'video/x-ms-wmv';
        case 'mov' :
            return 'video/quicktime';
        case 'zip' :
            return 'application/zip';
        case 'tar' :
            return 'application/x-tar';
        case 'swf' :
            return 'application/x-shockwave-flash';
        default :
            if(function_exists('mime_content_type')) {
                $fileSuffix = mime_content_type($path);
            }
            return 'unknown/' . trim($fileSuffix[0], '.');
    }
}
}
?>
Kirk Ouimet
la source
10
Pourquoi ne laissez-vous pas Apache faire cela? Ce sera toujours beaucoup plus rapide que de démarrer l'interpréteur PHP ...
Billy ONeal
4
Je dois traiter la demande et stocker certaines informations dans la base de données avant de sortir le fichier.
Kirk Ouimet
3
Puis - je suggérer un moyen d'obtenir l'extension sans les expressions régulières plus chers: $extension = end(explode(".", $pathToFile))ou vous pouvez le faire avec substr et strrpos: $extension = substr($pathToFile, strrpos($pathToFile, '.')). mime_content_type()Vous pouvez également essayer un appel système en guise de solution de secours :$mimetype = exec("file -bi '$pathToFile'", $output);
Fanis Hatzidakis
Qu'entendez-vous par le plus rapide ? Temps de téléchargement le plus rapide?
Alix Axel

Réponses:

140

Ma réponse précédente était partielle et mal documentée, voici une mise à jour avec un résumé des solutions de celle-ci et des autres dans la discussion.

Les solutions sont classées de la meilleure solution à la pire, mais aussi de la solution nécessitant le plus de contrôle sur le serveur Web à celle nécessitant le moins. Il ne semble pas y avoir de moyen simple d'avoir une solution à la fois rapide et efficace partout.


Utilisation de l'en-tête X-SendFile

Tel que documenté par d'autres, c'est en fait le meilleur moyen. La base est que vous effectuez votre contrôle d'accès en php, puis au lieu d'envoyer le fichier vous-même, vous dites au serveur Web de le faire.

Le code PHP de base est:

header("X-Sendfile: $file_name");
header("Content-type: application/octet-stream");
header('Content-Disposition: attachment; filename="' . basename($file_name) . '"');

$file_nameest le chemin d'accès complet sur le système de fichiers.

Le principal problème avec cette solution est qu'elle doit être autorisée par le serveur Web et qu'elle n'est pas installée par défaut (apache), n'est pas active par défaut (lighttpd) ou nécessite une configuration spécifique (nginx).

Apache

Sous apache si vous utilisez mod_php vous devez installer un module appelé mod_xsendfile puis le configurer (soit dans apache config ou .htaccess si vous l'autorisez)

XSendFile on
XSendFilePath /home/www/example.com/htdocs/files/

Avec ce module, le chemin du fichier peut être absolu ou relatif à celui spécifié XSendFilePath.

Lighttpd

Le mod_fastcgi prend en charge cela lorsqu'il est configuré avec

"allow-x-send-file" => "enable" 

La documentation de la fonctionnalité se trouve sur le wiki lighttpd, ils documentent l'en- X-LIGHTTPD-send-filetête mais le X-Sendfilenom fonctionne également

Nginx

Sur Nginx, vous ne pouvez pas utiliser l'en- X-Sendfiletête, vous devez utiliser leur propre en-tête nommé X-Accel-Redirect. Il est activé par défaut et la seule vraie différence est que son argument doit être un URI et non un système de fichiers. La conséquence est que vous devez définir un emplacement marqué comme interne dans votre configuration pour éviter que les clients trouvent l'URL réelle du fichier et y accèdent directement, leur wiki en contient une bonne explication .

En-tête de liens symboliques et d'emplacement

Vous pouvez utiliser des liens symboliques et les rediriger, créez simplement des liens symboliques vers votre fichier avec des noms aléatoires lorsqu'un utilisateur est autorisé à accéder à un fichier et rediriger l'utilisateur vers celui-ci en utilisant:

header("Location: " . $url_of_symlink);

Évidemment, vous aurez besoin d'un moyen de les élaguer soit lorsque le script pour les créer est appelé, soit via cron (sur la machine si vous avez accès ou via un service webcron sinon)

Sous apache, vous devez pouvoir activer FollowSymLinksdans un .htaccessou dans la configuration apache.

Contrôle d'accès par IP et en-tête de localisation

Un autre hack consiste à générer des fichiers d'accès apache à partir de php permettant l'IP de l'utilisateur explicite. Sous Apache, cela signifie utiliser les commandes mod_authz_host( mod_access) Allow from.

Le problème est que le verrouillage de l'accès au fichier (car plusieurs utilisateurs peuvent vouloir le faire en même temps) n'est pas trivial et peut amener certains utilisateurs à attendre longtemps. Et vous devez quand même élaguer le fichier.

De toute évidence, un autre problème serait que plusieurs personnes derrière la même adresse IP pourraient potentiellement accéder au fichier.

Quand tout le reste échoue

Si vous n'avez vraiment aucun moyen de faire en sorte que votre serveur Web vous aide, la seule solution restante est readfile, il est disponible dans toutes les versions de php actuellement utilisées et fonctionne plutôt bien (mais n'est pas vraiment efficace).


Combiner des solutions

In fine, le meilleur moyen d'envoyer un fichier très rapidement si vous voulez que votre code php soit utilisable partout est d'avoir une option configurable quelque part, avec des instructions sur la façon de l'activer en fonction du serveur Web et peut-être une détection automatique dans votre installation scénario.

C'est assez similaire à ce qui est fait dans de nombreux logiciels pour

  • Nettoyer les URL ( mod_rewritesur Apache)
  • Fonctions cryptographiques ( mcryptmodule php)
  • Prise en charge des chaînes multi-octets ( mbstringmodule php)
Julien Roncaglia
la source
Y a-t-il un problème avec l'exécution de certains travaux PHP (vérifiez les cookies / autres paramètres GET / POST par rapport à la base de données) avant de le faire header("Location: " . $path);?
Afriza N.Arief
2
Pas de problème pour une telle action, la chose à laquelle vous devez faire attention est l'envoi de contenu (impression, écho) car l'en-tête doit précéder tout contenu et faire les choses après l'envoi de cet en-tête, ce n'est pas une redirection immédiate et du code après qu'il sera exécuté la plupart du temps, mais vous n'avez aucune garantie que le navigateur ne coupera pas la connexion.
Julien Roncaglia
Jords: Je ne savais pas qu'apache supportait également cela, je l'ajouterai à ma réponse quand j'aurai le temps. Le seul problème avec cela est que je ne suis pas unifié (X-Accel-Redirect nginx par exemple), donc une deuxième solution est nécessaire si le serveur ne le prend pas en charge. Mais je devrais l'ajouter à ma réponse.
Julien Roncaglia
Où puis-je autoriser .htaccess à contrôler XSendFilePath?
Keyne Viana
1
@Keyne Je ne pense pas que vous puissiez. tn123.org/mod_xsendfile ne répertorie pas .htaccess dans le contexte de l'option XSendFilePath
cheshirekow
33

Le moyen le plus rapide: ne le faites pas. Regardez dans l'en -tête x-sendfile pour nginx , il y a des choses similaires pour d'autres serveurs Web également. Cela signifie que vous pouvez toujours effectuer le contrôle d'accès, etc. en php, mais déléguer l'envoi réel du fichier à un serveur Web conçu pour cela.

PS: J'éprouve des frissons en pensant à quel point son utilisation avec nginx est plus efficace que la lecture et l'envoi du fichier en php. Pensez simplement si 100 personnes téléchargent un fichier: avec php + apache, étant généreux, c'est probablement 100 * 15mb = 1,5 Go (environ, tirez-moi), de RAM juste là. Nginx transférera simplement l'envoi du fichier au noyau, puis il sera chargé directement à partir du disque dans les tampons réseau. Rapide!

PPS: Et, avec cette méthode, vous pouvez toujours faire tout le contrôle d'accès, tout ce que vous voulez dans la base de données.

Jords
la source
4
Permettez-moi simplement d'ajouter que cela existe également pour Apache: jasny.net/articles/how-i-php-x-sendfile . Vous pouvez faire en sorte que le script renifle le serveur et envoie les en-têtes appropriés. S'il n'y en a pas (et que l'utilisateur n'a aucun contrôle sur le serveur selon la question), revenez à la normalereadfile()
Fanis Hatzidakis
Maintenant, c'est tout simplement génial - j'ai toujours détesté augmenter la limite de mémoire de mes hôtes virtuels juste pour que PHP serve un fichier, et avec cela, je ne devrais pas avoir à le faire. Je vais l'essayer très bientôt.
Greg W
1
Et pour le crédit où le crédit est dû, Lighttpd a été le premier serveur Web pour mettre en œuvre cette (et le reste copié, ce qui est bien car il est une excellente idée mais donner crédit où le crédit est dû.) ...
ircmaxell
1
Cette réponse continue à être votée, mais elle ne fonctionnera pas dans un environnement où le serveur Web et ses paramètres sont hors du contrôle de l'utilisateur.
Kirk Ouimet
Vous avez en fait ajouté cela à votre question après avoir publié cette réponse. Et si les performances sont un problème, le serveur Web doit être sous votre contrôle.
Jords
23

Voici une solution PHP pure. J'ai adapté la fonction suivante de mon cadre personnel :

function Download($path, $speed = null, $multipart = true)
{
    while (ob_get_level() > 0)
    {
        ob_end_clean();
    }

    if (is_file($path = realpath($path)) === true)
    {
        $file = @fopen($path, 'rb');
        $size = sprintf('%u', filesize($path));
        $speed = (empty($speed) === true) ? 1024 : floatval($speed);

        if (is_resource($file) === true)
        {
            set_time_limit(0);

            if (strlen(session_id()) > 0)
            {
                session_write_close();
            }

            if ($multipart === true)
            {
                $range = array(0, $size - 1);

                if (array_key_exists('HTTP_RANGE', $_SERVER) === true)
                {
                    $range = array_map('intval', explode('-', preg_replace('~.*=([^,]*).*~', '$1', $_SERVER['HTTP_RANGE'])));

                    if (empty($range[1]) === true)
                    {
                        $range[1] = $size - 1;
                    }

                    foreach ($range as $key => $value)
                    {
                        $range[$key] = max(0, min($value, $size - 1));
                    }

                    if (($range[0] > 0) || ($range[1] < ($size - 1)))
                    {
                        header(sprintf('%s %03u %s', 'HTTP/1.1', 206, 'Partial Content'), true, 206);
                    }
                }

                header('Accept-Ranges: bytes');
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }

            else
            {
                $range = array(0, $size - 1);
            }

            header('Pragma: public');
            header('Cache-Control: public, no-cache');
            header('Content-Type: application/octet-stream');
            header('Content-Length: ' . sprintf('%u', $range[1] - $range[0] + 1));
            header('Content-Disposition: attachment; filename="' . basename($path) . '"');
            header('Content-Transfer-Encoding: binary');

            if ($range[0] > 0)
            {
                fseek($file, $range[0]);
            }

            while ((feof($file) !== true) && (connection_status() === CONNECTION_NORMAL))
            {
                echo fread($file, round($speed * 1024)); flush(); sleep(1);
            }

            fclose($file);
        }

        exit();
    }

    else
    {
        header(sprintf('%s %03u %s', 'HTTP/1.1', 404, 'Not Found'), true, 404);
    }

    return false;
}

Le code est aussi efficace qu'il peut l'être, il ferme le gestionnaire de session afin que d'autres scripts PHP puissent s'exécuter simultanément pour le même utilisateur / session. Il prend également en charge le service de téléchargements par plages (ce que fait également Apache par défaut, je suppose), afin que les utilisateurs puissent suspendre / reprendre les téléchargements et bénéficier également de vitesses de téléchargement plus élevées avec des accélérateurs de téléchargement. Il vous permet également de spécifier la vitesse maximale (en Kbps) à laquelle le téléchargement (partie) doit être servi via l' $speedargument.

Alix Axel
la source
2
Évidemment, ce n'est une bonne idée que si vous ne pouvez pas utiliser X-Sendfile ou l'une de ses variantes pour que le noyau envoie le fichier. Vous devriez être en mesure de remplacer la boucle feof () / fread () ci-dessus par [ php.net/manual/en/function.eio-sendfile.php </font>, qui accomplit la même chose en PHP. Ce n'est pas aussi rapide que de le faire directement dans le noyau, car toute sortie générée en PHP doit toujours être renvoyée via le processus du serveur Web, mais cela va être beaucoup plus rapide que de le faire en code PHP.
Brian C
@BrianC: Bien sûr, mais vous ne pouvez pas limiter la vitesse ou la capacité en plusieurs parties avec X-Sendfile (qui peut ne pas être disponible) et eion'est pas toujours disponible. Pourtant, +1, ne connaissait pas cette extension pecl. =)
Alix Axel
Serait-il utile de prendre en charge l'encodage de transfert: chunked et l'encodage de contenu: gzip?
skibulk
Pourquoi $size = sprintf('%u', filesize($path))?
Svish
14
header('Location: ' . $path);
exit(0);

Laissez Apache faire le travail à votre place.

amphétamachine
la source
12
C'est plus simple que la méthode x-sendfile, mais ne fonctionnera pas pour restreindre l'accès à un fichier, pour ne dire que les personnes connectées. Si vous n'avez pas besoin de le faire, c'est génial!
Jords
Ajoutez également une vérification de référent avec mod_rewrite.
sanmai
1
Vous pouvez vous authentifier avant de passer l'en-tête. De cette façon, vous ne pompez pas non plus des tonnes de choses dans la mémoire de PHP.
Brent
7
@UltimateBrent L'emplacement doit encore être accessible à tous .. Et une vérification de référence n'est pas du tout une sécurité car elle vient du client
Øyvind Skaar
@Jimbo Un jeton utilisateur que vous allez vérifier comment? Avec PHP? Soudain, votre solution se répète.
Mark Amery
1

Une meilleure implémentation, avec prise en charge du cache, en-têtes http personnalisés.

serveStaticFile($fn, array(
        'headers'=>array(
            'Content-Type' => 'image/x-icon',
            'Cache-Control' =>  'public, max-age=604800',
            'Expires' => gmdate("D, d M Y H:i:s", time() + 30 * 86400) . " GMT",
        )
    ));

function serveStaticFile($path, $options = array()) {
    $path = realpath($path);
    if (is_file($path)) {
        if(session_id())
            session_write_close();

        header_remove();
        set_time_limit(0);
        $size = filesize($path);
        $lastModifiedTime = filemtime($path);
        $fp = @fopen($path, 'rb');
        $range = array(0, $size - 1);

        header('Last-Modified: ' . gmdate("D, d M Y H:i:s", $lastModifiedTime)." GMT");
        if (( ! empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $lastModifiedTime ) ) {
            header("HTTP/1.1 304 Not Modified", true, 304);
            return true;
        }

        if (isset($_SERVER['HTTP_RANGE'])) {
            //$valid = preg_match('^bytes=\d*-\d*(,\d*-\d*)*$', $_SERVER['HTTP_RANGE']);
            if(substr($_SERVER['HTTP_RANGE'], 0, 6) != 'bytes=') {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size); // Required in 416.
                return false;
            }

            $ranges = explode(',', substr($_SERVER['HTTP_RANGE'], 6));
            $range = explode('-', $ranges[0]); // to do: only support the first range now.

            if ($range[0] === '') $range[0] = 0;
            if ($range[1] === '') $range[1] = $size - 1;

            if (($range[0] >= 0) && ($range[1] <= $size - 1) && ($range[0] <= $range[1])) {
                header('HTTP/1.1 206 Partial Content', true, 206);
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }
            else {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size);
                return false;
            }
        }

        $contentLength = $range[1] - $range[0] + 1;

        //header('Content-Disposition: attachment; filename="xxxxx"');
        $headers = array(
            'Accept-Ranges' => 'bytes',
            'Content-Length' => $contentLength,
            'Content-Type' => 'application/octet-stream',
        );

        if(!empty($options['headers'])) {
            $headers = array_merge($headers, $options['headers']);
        }
        foreach($headers as $k=>$v) {
            header("$k: $v", true);
        }

        if ($range[0] > 0) {
            fseek($fp, $range[0]);
        }
        $sentSize = 0;
        while (!feof($fp) && (connection_status() === CONNECTION_NORMAL)) {
            $readingSize = $contentLength - $sentSize;
            $readingSize = min($readingSize, 512 * 1024);
            if($readingSize <= 0) break;

            $data = fread($fp, $readingSize);
            if(!$data) break;
            $sentSize += strlen($data);
            echo $data;
            flush();
        }

        fclose($fp);
        return true;
    }
    else {
        header('HTTP/1.1 404 Not Found', true, 404);
        return false;
    }
}
Shawn
la source
0

si vous avez la possibilité d'ajouter des extensions PECL à votre php, vous pouvez simplement utiliser les fonctions du package Fileinfo pour déterminer le type de contenu puis envoyer les en-têtes appropriés ...

Andreas Linden
la source
/ bump, avez-vous évoqué cette possibilité? :)
Andreas Linden
0

La Downloadfonction PHP mentionnée ici entraînait un certain retard avant que le téléchargement du fichier ne commence réellement. Je ne sais pas si cela a été causé en utilisant le cache de vernis ou quoi, mais pour moi il a aidé à enlever le sleep(1);complètement et ensemble $speedà 1024. Maintenant, cela fonctionne sans aucun problème car c'est rapide comme l'enfer. Vous pourriez peut-être aussi modifier cette fonction, car je l'ai vue utilisée partout sur Internet.

user1601422
la source
0

J'ai codé une fonction très simple pour servir des fichiers avec PHP et détection automatique de type MIME:

function serve_file($filepath, $new_filename=null) {
    $filename = basename($filepath);
    if (!$new_filename) {
        $new_filename = $filename;
    }
    $mime_type = mime_content_type($filepath);
    header('Content-type: '.$mime_type);
    header('Content-Disposition: attachment; filename="downloaded.pdf"');
    readfile($filepath);
}

Usage

serve_file("/no_apache/invoice243.pdf");
Samuel Dauzon
la source