Pobieranie pliku przez skrypt PHP z możliwością wznowienia przerwanego połączenia

piechnat

Jak wiadomo serwer HTTP zajmuje się udostępnianiem plików. Gdy klient (zazwyczaj przeglądarka internetowa) poprosi o jakiś dokument, serwer odsyła jego zawartość poprzedzoną nagłówkami HTTP informującymi o typie MIME czy też wielkości tego dokumentu. W momencie, gdy przeglądarka nie jest w stanie obsłużyć danego typu, wyświetla użytkownikowi komunikat pytający czy plik ma zostać zapisany na dysku.

Aby za pomocą naszego skryptu PHP można było ściągnąć przykładowy plik.txt z serwera HTTP należy oszukać przeglądarkę. Dać jej do zrozumienia, że skrypt, który właśnie pobiera jest załącznikiem. Robi się to poprzez nagłówek Content-Disposition z wartością attachment. Po nim należy podać nazwę załącznika, bo w przeciwnym wypadku domyślną nazwą naszego pliku będzie nazwa skryptu, który go wysłał.

header('Content-Disposition: attachment; filename=plik.txt');

W następnej kolejności trzeba zmusić przeglądarkę do pokazania już wspomnianego pytania o zapis na dysk żeby plik.txt, nie został przez nią zwyczajnie wyświetlony. W nagłówku 'Content-Type' podajemy wartość 'application/x-unknown'.

header('Content-Type: application/x-unknown');

Teraz, mimo że jest to zwyczajny plik.txt przeglądarka, zdając się na nagłówek Content-Type, traktuje go jako plik o nieznanym formacie i jedyne co jej pozostaje to pobrać go i zapisać. Wystarczy już wysłać do niej zawartość plik.txt.

if ($fp = fopen('plik.txt', 'rb')) {
    flock($fp, 1);
    echo fread($fp, filesize('plik.txt'));
    flock($fp, 3);
    fclose($fp);
}

Co jednak, jeśli przez nasz skrypt PHP pobierany jest o wiele większy plik przy pomocy asystenta pobierania i w trakcie tej operacji zostanie zerwane połączenie? Protokół HTTP 1.1 udostępnia mechanizmy umożliwiające ściągnięcie dowolnego fragmentu pliku. Niestety w naszym skrypcie one nie będą działały, dlatego trzeba je zaimplementować samemu, aby pozwolić klientowi na wznowienie pobierania.

Po pierwsze trzeba dołączyć nagłówek Accept-Ranges. Jak na razie jedyna dostępna dla niego wartość to bytes. Dzięki temu klient wie ze może prosić o wybrane fragmenty pliku. Wykonuje to wysyłając nagłówek 'Range' z wartością bytes=a-b, gdzie a to numer bajtu, od którego chce rozpocząć pobieranie, natomiast b to numer bajtu na którym chcemy zakończyć wysyłanie. Z moich obserwacji wynika, że drugi parametr jest opcjonalny. Kiedy go nie ma, serwer powinien zwrócić wszystko do końca pliku. Wartości te liczone są od zera, czyli numer pierwszego bajtu pliku o wielkości 1024B to 0, a ostatni to 1023. Nagłówek Range dostępny jest w zmiennej $_SERVER["HTTP_RANGE"].

Cała sprawa polega już tylko na tym, aby wyciągnąć wartości, wykonać kilka obliczeń a następnie przesunąć wskaźnik do pobieranego pliku na wybrane przez klienta miejsce i wysłać mu tyle danych ile potrzebuje. Oczywiście należy go poinformować, co dokładnie jest mu wysyłane, mimo iż powinien pamiętać, o co przed chwilą prosił ;) W tym wypadku nie podaje się tak jak zwykle nagłówka HTTP/1.1 200 OK tylko HTTP/1.1 206 Partial Content. Parametry przesyłanych danych podaje się w Content-Range: bytes a-b/c, gdzie wartości a i b mają takie znaczenie jak poprzednio, natomiast c to wielkość całego pliku. Należy zwrócić uwagę na to, że w takiej sytuacji wartość nagłówka Content-Length nie powinna stanowić wielkości całego pliku a jedynie tej części która jest właśnie wysyłana. Przy żądaniu przez klienta ostatnich 400 bajtów z pliku o wielkości 1000 bajtów powinno to wyglądać tak:

header('HTTP/1.1 206 Partial Content'); 
header('Accept-Ranges: bytes'); 
header('Content-Range: bytes 600-999/1000'); 
header('Content-Length: 400');

Poniżej znajduje się przykładowy skrypt, który wykonuje opisane przeze mnie czynności. Ścieżkę do pliku, który ma być ściągnięty należy podać w zmiennej 'file' w URLu.

<?php /* -----------------------------------------------------------------------

        PHP File Downloader by Mateusz Piechnat
        [http://piechnat.prv.pl]

    ------------------------------------------------------------------------- */

    define('ALLOWED_REFERRER', '');
    define('ROOT_DIRECTORY', '.');
    define('SEND_BUF_LEN', 1024 * 8);

    /* ---------------------------------------------------------------------- */

    @set_time_limit(0);
    if (function_exists('apache_setenv')) @apache_setenv('no-gzip', 1);
    @ini_set('zlib.output_compression', 0);
    @ini_set('implicit_flush', 1);
    while (ob_get_level()) ob_end_clean();
    ob_implicit_flush(1);

    /* ---------------------------------------------------------------------- */

    $http_ref = strtoupper(@$_SERVER['HTTP_REFERER']);

    if (($http_ref !== '') && (ALLOWED_REFERRER !== ''))
    {
        if (strpos($http_ref, strtoupper(ALLOWED_REFERRER)) === false)
        {
            header('HTTP/1.1 500 Internal Server Error');
            die('Internal server error.');
        }
    }

    /* ---------------------------------------------------------------------- */

    $file = @$_GET['file'];
    if (@get_magic_quotes_gpc()) $file = stripslashes($file);

    $root_dir = realpath(ROOT_DIRECTORY);
    $file = realpath($root_dir . '/' . $file);

    if ((strpos($file, $root_dir) !== 0) || (! is_file($file)))
    {
        header('HTTP/1.1 404 File Not Found');
        die('File not found.');
    }

    /* ---------------------------------------------------------------------- */

    $fname = basename($file);
    $fsize = filesize($file);
    $ftime = filemtime($file);

    $fmime = '';
    $range = @$_SERVER['HTTP_RANGE'];

    $r_start = 0;
    $c_length = $fsize;

    /* ---------------------------------------------------------------------- */

    if (preg_match('/bytes=([0-9]*)-([0-9]*)/', $range, $tmp))
    {
        $r_start = (int) $tmp[1];
        $r_stop = (int) $tmp[2];
        if ($r_stop < $r_start) $r_stop = $fsize - 1;
        $c_length = $r_stop - $r_start + 1;

        header('HTTP/1.1 206 Partial Content');
        header('Content-Range: bytes ' .
            $r_start . '-' . $r_stop . '/' . $fsize);
    }
    else
    {
        header('HTTP/1.1 200 OK');
    }

    /* ---------------------------------------------------------------------- */

    if (function_exists('mime_content_type'))
    {
        $fmime = mime_content_type($file);
    }
    else if (function_exists('finfo_file'))
    {
        $finfo = finfo_open(FILEINFO_MIME);
        $fmime = finfo_file($finfo, $file);
        finfo_close($finfo);
    }
    if ($fmime == '')
    {
        $fmime = 'application/force-download';
    }

    /* ---------------------------------------------------------------------- */

    header('Accept-Ranges: bytes');
    header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $ftime) . ' GMT');
    header('Pragma: public');
    header('Expires: 0');
    header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
    header('Cache-Control: private', false);
    header('Content-Description: File Transfer');
    header('Content-Disposition: attachment; filename="' . $fname . '"');
    header('Content-Type: ' . $fmime);
    header('Content-Transfer-Encoding: binary');
    header('Content-Length: ' . $c_length);

    flush();

    /* ---------------------------------------------------------------------- */

    if ($fp = @fopen($file, 'rb'))
    {
        @flock($fp, 1);
        @fseek($fp, $r_start);
        while ((! feof($fp)) && ($c_length > SEND_BUF_LEN))
        {
            print(fread($fp, SEND_BUF_LEN));
            $c_length = $c_length - SEND_BUF_LEN;
            flush();
            if (connection_status() != 0) break;
        }
        if ((! feof($fp)) && (connection_status() == 0))
        {
            print(fread($fp, $c_length));
            flush();
        }
        @flock($fp, 3);
        @fclose($fp);
    }

    /* ---------------------------------------------------- END OF SCRIPT --- */

12 komentarzy

        flock($fp, 1);
        echo(fread($fp, filesize('plik.txt')));
        flock($fp, 3);
        fclose($fp);

dla dużych plików, jest to sposób raczej wątpliwy :D Łatwo przekroczyć limity pamięci.

Lepiej wysyłać metodą:

while (!feof($f))
{
    echo fread($f, 512 * 1024);
}

Artykuł bardzo dobry. Nasuwa mi się jednak pytanie: Jak wysłać tym plik większy niż 128 MB ?

http://elouai.com/force-download.php
o wiele lepszy, bo jak mam hosting PHP z ograniczeniem na fopen() .....

fajne tylko nie pozwala na wpisanie w file pełnego adresu (jakbym chcial pobrac plik z innego serwera?)

nie wiem czy tylko u mnie ale ow skrypt ma problemy w ie... mianowicie ak zalduej strone to moge pobrac plik ale jak klikne anuluj albo plik pobierze sie w calosci tak ie odmawia wspolpracy z witryna z ktorej pobieral ow plik.. jakies koncepcje ?

a co jeżeli nie mam dostępu do pliku - jest na innym serwerze ??

Przydatne - i dobrze napisane, w przeciwieństwie do wielu przykładów takich skryptów. Szkoda tylko, że ten sposób cholernie obciąża serwer jak więcej ludzi coś dużego ściąga....

super! licencja GNU mam nadzieję ?

Artykuł klasa, ale kilka komentarzy w kodzie by nie zaszkodziło...

bardzo ciekawe!! Jak zwykle piechnat trafia w 10! i ciekawe i przydatne.. (przynajmniej mi). Dziękuje :D

jak ty cos dodasz to zawsze sie przyda....