Najszybszy sposób na udostępnienie pliku za pomocą PHP

98

Próbuję stworzyć funkcję, która odbiera ścieżkę do pliku, identyfikuje, co to jest, ustawia odpowiednie nagłówki i obsługuje ją tak, jak zrobiłby to Apache.

Powodem, dla którego to robię, jest to, że muszę użyć PHP do przetworzenia niektórych informacji o żądaniu przed udostępnieniem pliku.

Szybkość jest krytyczna

virtual () nie wchodzi w grę

Musi pracować we współdzielonym środowisku hostingowym, w którym użytkownik nie ma kontroli nad serwerem internetowym (Apache / nginx itp.)

Oto, co mam do tej pory:

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
źródło
10
Dlaczego nie pozwalasz Apache to zrobić? To zawsze będzie znacznie szybsze niż uruchomienie interpretera PHP ...
Billy ONeal
4
Muszę przetworzyć żądanie i zapisać pewne informacje w bazie danych przed wyprowadzeniem pliku.
Kirk Ouimet
3
Mogę zaproponować sposób na rozszerzenie bez droższych wyrażeń regularnych: $extension = end(explode(".", $pathToFile))czy można to zrobić z substr i strrpos: $extension = substr($pathToFile, strrpos($pathToFile, '.')). Dodatkowo mime_content_type()możesz spróbować wywołania systemowego:$mimetype = exec("file -bi '$pathToFile'", $output);
Fanis Hatzidakis
Co masz na myśli mówiąc najszybciej ? Najszybszy czas pobierania?
Alix Axel

Odpowiedzi:

140

Moja poprzednia odpowiedź była częściowa i niezbyt dobrze udokumentowana, oto aktualizacja z podsumowaniem rozwiązań z niej i od innych w dyskusji.

Rozwiązania są uporządkowane od najlepszego do najgorszego, ale także od rozwiązania wymagającego największej kontroli nad serwerem WWW do tego, które potrzebuje mniej. Wydaje się, że nie ma łatwego sposobu na znalezienie jednego rozwiązania, które jest zarówno szybkie, jak i działa wszędzie.


Korzystanie z nagłówka X-SendFile

Jak udokumentowali inni, to właściwie najlepszy sposób. Podstawą jest to, że wykonujesz kontrolę dostępu w php, a następnie zamiast samodzielnie wysyłać plik, każesz to zrobić serwerowi WWW.

Podstawowy kod php to:

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

Gdzie $file_namejest pełna ścieżka w systemie plików.

Głównym problemem związanym z tym rozwiązaniem jest to, że musi być dozwolone przez serwer sieciowy i albo nie jest instalowane domyślnie (apache), nie jest domyślnie aktywne (lighttpd) lub wymaga określonej konfiguracji (nginx).

Apache

Pod Apache, jeśli używasz mod_php, musisz zainstalować moduł o nazwie mod_xsendfile, a następnie skonfigurować go (w konfiguracji apache lub .htaccess, jeśli na to pozwolisz)

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

W tym module ścieżka pliku może być bezwzględna lub względna w stosunku do określonego XSendFilePath.

Lighttpd

Mod_fastcgi obsługuje to, gdy jest skonfigurowany z

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

Dokumentacja funkcji znajduje się na lighttpd wiki, gdzie dokumentuje X-LIGHTTPD-send-filenagłówek, ale X-Sendfilenazwa również działa

Nginx

Na Nginx nie możesz użyć X-Sendfilenagłówka, musisz użyć własnego nagłówka o nazwie X-Accel-Redirect. Jest domyślnie włączony, a jedyną prawdziwą różnicą jest to, że jego argumentem powinien być identyfikator URI, a nie system plików. Konsekwencją jest to, że musisz zdefiniować lokalizację oznaczoną jako wewnętrzną w twojej konfiguracji, aby uniknąć znalezienia przez klientów prawdziwego adresu URL pliku i bezpośredniego przejścia do niego, ich wiki zawiera dobre wyjaśnienie tego.

Nagłówek łącza symboliczne i lokalizacja

Możesz użyć linków symbolicznych i przekierować do nich, po prostu utwórz dowiązania symboliczne do swojego pliku z losowymi nazwami, gdy użytkownik jest upoważniony do dostępu do pliku i przekieruj użytkownika do niego za pomocą:

header("Location: " . $url_of_symlink);

Oczywiście będziesz potrzebować sposobu, aby je przyciąć, gdy zostanie wywołany skrypt do ich utworzenia lub przez cron (na komputerze, jeśli masz dostęp lub za pośrednictwem usługi webcron w przeciwnym razie)

W Apache musisz mieć możliwość włączenia FollowSymLinksw .htaccesskonfiguracji Apache lub w konfiguracji Apache.

Kontrola dostępu według adresu IP i nagłówka lokalizacji

Innym hackem jest generowanie plików dostępu Apache z php, umożliwiając jawne IP użytkownika. W apache oznacza to użycie poleceń mod_authz_host( mod_access) Allow from.

Problem polega na tym, że blokowanie dostępu do pliku (ponieważ wielu użytkowników może chcieć to zrobić w tym samym czasie) jest nietrywialne i może prowadzić do długiego oczekiwania niektórych użytkowników. I tak nadal musisz przyciąć plik.

Oczywiście innym problemem byłoby to, że wiele osób korzystających z tego samego adresu IP mogłoby potencjalnie uzyskać dostęp do pliku.

Kiedy wszystko inne zawodzi

Jeśli naprawdę nie masz możliwości, aby Twój serwer WWW Ci pomógł, jedynym rozwiązaniem pozostaje readfile, który jest dostępny we wszystkich aktualnie używanych wersjach php i działa całkiem dobrze (ale nie jest zbyt wydajny).


Łączenie rozwiązań

W porządku, najlepszym sposobem na naprawdę szybkie wysłanie pliku, jeśli chcesz, aby kod php był użyteczny wszędzie, jest gdzieś konfigurowalna opcja, z instrukcjami, jak ją aktywować w zależności od serwera internetowego i być może automatycznego wykrywania podczas instalacji scenariusz.

Jest to bardzo podobne do tego, co robi się w wielu programach

  • Czyste adresy URL ( mod_rewritena Apache)
  • Funkcje kryptograficzne ( mcryptmoduł PHP)
  • Obsługa ciągów wielobajtowych ( mbstringmoduł PHP)
Julien Roncaglia
źródło
Czy jest jakiś problem z wykonywaniem niektórych prac PHP (sprawdź plik cookie / inne parametry GET / POST w bazie danych) przed wykonaniem header("Location: " . $path);?
Afriza N. Arief
2
Nie ma problemu z taką akcją, na co trzeba uważać to wysyłanie treści (print, echo) gdyż nagłówek musi znajdować się przed jakąkolwiek treścią i robienie rzeczy po wysłaniu tego nagłówka, nie jest to natychmiastowe przekierowanie i kod po nim będzie wykonywane przez większość czasu, ale nie masz gwarancji, że przeglądarka nie przerwie połączenia.
Julien Roncaglia
Jords: Nie wiedziałem, że Apache również to obsługuje, dodam to do mojej odpowiedzi, kiedy będę miał czas. Jedynym problemem jest to, że i nie jest ujednolicony (na przykład X-Accel-Redirect nginx), więc potrzebne jest drugie rozwiązanie, jeśli serwer go nie obsługuje. Ale powinienem dodać to do mojej odpowiedzi.
Julien Roncaglia
Gdzie mogę zezwolić .htaccess na kontrolowanie ścieżki XSendFilePath?
Keyne Viana,
1
@Keyne Myślę, że nie możesz. tn123.org/mod_xsendfile nie wymienia .htaccess w kontekście opcji XSendFilePath
cheshirekow
33

Najszybszy sposób: nie rób tego. Zajrzyj do nagłówka x-sendfile dla nginx , są też podobne rzeczy dla innych serwerów WWW. Oznacza to, że nadal możesz kontrolować dostęp itp. W php, ale delegować faktyczne wysyłanie pliku na serwer sieciowy przeznaczony do tego celu.

PS: Przeraża mnie myślenie o tym, o ile wydajniejsze jest używanie tego z nginx w porównaniu do czytania i wysyłania pliku w php. Pomyśl tylko, jeśli 100 osób pobiera plik: z php + apache, będąc hojnym, to prawdopodobnie 100 * 15 MB = 1,5 GB (w przybliżeniu, zastrzel mnie) pamięci RAM. Nginx po prostu przekaże plik do jądra, a następnie zostanie załadowany bezpośrednio z dysku do buforów sieciowych. Szybki!

PPS: Dzięki tej metodzie nadal możesz wykonywać całą kontrolę dostępu, operacje związane z bazą danych.

Jords
źródło
4
Dodam tylko, że dotyczy to również Apache: jasny.net/articles/how-i-php-x-sendfile . Możesz sprawić, by skrypt wyszukał serwer i wysłał odpowiednie nagłówki. Jeśli żaden nie istnieje (a użytkownik nie ma kontroli nad serwerem zgodnie z pytaniem), wróć do normyreadfile()
Fanis Hatzidakis
Teraz to jest po prostu niesamowite - zawsze nienawidziłem zwiększania limitu pamięci na moich wirtualnych hostach tylko po to, aby PHP obsłużyło plik, a przy tym nie powinienem tego robić. Wkrótce go wypróbuję.
Greg W
1
A jeśli chodzi o kredyt tam, gdzie należy się kredyt, Lighttpd był pierwszym serwerem WWW, który to zaimplementował (a reszta go skopiowała, co jest w porządku, ponieważ jest to świetny pomysł. Ale podaj kredyt tam, gdzie należy się kredyt) ...
ircmaxell
1
Ta odpowiedź jest wciąż pozytywnie oceniana, ale nie zadziała w środowisku, w którym serwer WWW i jego ustawienia są poza kontrolą użytkownika.
Kirk Ouimet,
Właściwie dodałeś to do swojego pytania po tym, jak opublikowałem tę odpowiedź. A jeśli problemem jest wydajność, serwer sieciowy musi być pod Twoją kontrolą.
Jords,
23

Oto czyste rozwiązanie PHP. Zaadaptowałem następującą funkcję z moich osobistych ram :

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;
}

Kod jest tak wydajny, jak to tylko możliwe, zamyka procedurę obsługi sesji, aby inne skrypty PHP mogły działać jednocześnie dla tego samego użytkownika / sesji. Obsługuje również serwowanie pobrań w zakresach (co podejrzewam, że domyślnie robi to Apache), dzięki czemu ludzie mogą wstrzymywać / wznawiać pobieranie, a także korzystać z wyższych prędkości pobierania dzięki akceleratorom pobierania. Pozwala również określić maksymalną prędkość (w Kb / s), z jaką pobieranie (część) powinno być obsługiwane za pośrednictwem $speedargumentu.

Alix Axel
źródło
2
Oczywiście jest to dobry pomysł tylko wtedy, gdy nie możesz użyć X-Sendfile lub jednego z jego wariantów, aby jądro wysłało plik. Powinieneś być w stanie zamienić powyższą pętlę feof () / fread () na [ php.net/manual/en/function.eio-sendfile.php](PHP's eio_sendfile ()], co robi to samo w PHP. Nie jest to tak szybkie, jak zrobienie tego bezpośrednio w jądrze, ponieważ każde wyjście wygenerowane w PHP wciąż musi wrócić przez proces serwera internetowego, ale będzie to dużo szybsze niż zrobienie tego w kodzie PHP.
Brian C
@BrianC: Jasne, ale nie można ograniczać szybkości ani zdolności wieloczęściowej za pomocą X-Sendfile (która może nie być dostępna) i eionie zawsze jest dostępna. Mimo to +1 nie wiedziało o tym rozszerzeniu pecl. =)
Alix Axel
Czy przydałaby się obsługa kodowania transferu: chunked i content-encoding: gzip?
skibulk
Dlaczego $size = sprintf('%u', filesize($path))?
Svish
14
header('Location: ' . $path);
exit(0);

Pozwól Apache wykonać pracę za Ciebie.

amfetamachina
źródło
12
Jest to prostsze niż metoda x-sendfile, ale nie będzie działać w celu ograniczenia dostępu do pliku, na przykład tylko zalogowanych osób. Jeśli nie musisz tego robić, to świetnie!
Jords,
Dodaj także sprawdzanie strony polecającej za pomocą mod_rewrite.
sanmai
1
Możesz autoryzować przed przekazaniem nagłówka. W ten sposób nie przepompowujesz też mnóstwa rzeczy przez pamięć PHP.
Brent
7
@UltimateBrent Lokalizacja nadal musi być dostępna dla wszystkich. A kontrola skierowania nie stanowi żadnego zabezpieczenia, ponieważ pochodzi od klienta
Øyvind Skaar.
@Jimbo Token użytkownika, który zamierzasz sprawdzić? Z PHP? Nagle twoje rozwiązanie się powtarza.
Mark Amery,
1

Lepsza implementacja z obsługą pamięci podręcznej i dostosowanymi nagłówkami HTTP.

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
źródło
0

jeśli masz możliwość dodania rozszerzeń PECL do swojego php, możesz po prostu użyć funkcji z pakietu Fileinfo, aby określić typ zawartości, a następnie wysłać odpowiednie nagłówki ...

Andreas Linden
źródło
/ bump, czy wspomniałeś o tej możliwości? :)
Andreas Linden
0

DownloadWspomniana tutaj funkcja PHP powodowała pewne opóźnienie przed faktycznym rozpoczęciem pobierania pliku. Nie wiem, czy było to spowodowane użyciem cache lakieru czy co, ale u mnie pomogło to sleep(1);całkowicie usunąć i ustawić $speedna 1024. Teraz działa bez problemu i jest szybki jak diabli. Może mógłbyś zmodyfikować tę funkcję, ponieważ widziałem ją w całym internecie.

user1601422
źródło
0

Zakodowałem bardzo prostą funkcję do obsługi plików z PHP i automatycznym wykrywaniem typu 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);
}

Stosowanie

serve_file("/no_apache/invoice243.pdf");
Samuel Dauzon
źródło