La forma más rápida de servir un archivo usando PHP

98

Estoy tratando de armar una función que reciba una ruta de archivo, identifique qué es, establezca los encabezados apropiados y la sirva como lo haría Apache.

La razón por la que hago esto es porque necesito usar PHP para procesar cierta información sobre la solicitud antes de entregar el archivo.

La velocidad es fundamental

virtual () no es una opción

Debe funcionar en un entorno de alojamiento compartido donde el usuario no tiene control del servidor web (Apache / nginx, etc.)

Esto es lo que tengo hasta ahora:

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
fuente
10
¿Por qué no dejas que Apache haga esto? Siempre será considerablemente más rápido que iniciar el intérprete de PHP ...
Billy ONeal
4
Necesito procesar la solicitud y almacenar cierta información en la base de datos antes de enviar el archivo.
Kirk Ouimet
3
¿Puedo sugerir una manera de conseguir la extensión sin las expresiones regulares más caros: $extension = end(explode(".", $pathToFile)), o puede hacerlo con substr y strrpos: $extension = substr($pathToFile, strrpos($pathToFile, '.')). Además, como alternativa mime_content_type(), puede probar una llamada al sistema:$mimetype = exec("file -bi '$pathToFile'", $output);
Fanis Hatzidakis
¿A qué te refieres con más rápido ? ¿El tiempo de descarga más rápido?
Alix Axel

Respuestas:

140

Mi respuesta anterior fue parcial y no está bien documentada, aquí hay una actualización con un resumen de las soluciones de ella y de otros en la discusión.

Las soluciones están ordenadas de la mejor a la peor, pero también de la solución que necesita más control sobre el servidor web a la que necesita menos. No parece haber una manera fácil de tener una solución que sea rápida y funcione en todas partes.


Usando el encabezado X-SendFile

Según lo documentado por otros, en realidad es la mejor manera. La base es que usted hace su control de acceso en php y luego, en lugar de enviar el archivo usted mismo, le dice al servidor web que lo haga.

El código php básico es:

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

¿Dónde $file_nameestá la ruta completa en el sistema de archivos?

El principal problema con esta solución es que debe ser permitido por el servidor web y no está instalado por defecto (apache), no está activo por defecto (lighttpd) o necesita una configuración específica (nginx).

apache

Bajo apache, si usa mod_php, debe instalar un módulo llamado mod_xsendfile y luego configurarlo (ya sea en la configuración de apache o .htaccess si lo permite)

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

Con este módulo, la ruta del archivo puede ser absoluta o relativa a la especificada XSendFilePath .

Lighttpd

El mod_fastcgi admite esto cuando se configura con

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

La documentación de la función está en el wiki de lighttpd , documentan el X-LIGHTTPD-send-fileencabezado pero elX-Sendfile nombre también funciona

Nginx

En Nginx no puede usar el X-Sendfileencabezado, debe usar su propio encabezado con el nombre X-Accel-Redirect. Está habilitado de forma predeterminada y la única diferencia real es que su argumento debe ser un URI, no un sistema de archivos. La consecuencia es que debe definir una ubicación marcada como interna en su configuración para evitar que los clientes encuentren la URL del archivo real y vayan directamente a ella, su wiki contiene una buena explicación de esto.

Encabezado de Symlinks y Location

Puede usar enlaces simbólicos y redirigirlos, simplemente cree enlaces simbólicos a su archivo con nombres aleatorios cuando un usuario esté autorizado a acceder a un archivo y redirigir al usuario a él usando:

header("Location: " . $url_of_symlink);

Obviamente, necesitará una forma de podarlos cuando se llame al script para crearlos o mediante cron (en la máquina si tiene acceso o mediante algún servicio webcron de lo contrario)

Bajo apache, debe poder habilitar FollowSymLinksen un.htaccess o en la configuración de apache.

Control de acceso por IP y encabezado de ubicación

Otro truco es generar archivos de acceso apache desde php que permitan la IP explícita del usuario. Bajo apache significa usar mod_authz_host( mod_access)Allow from comandos .

El problema es que bloquear el acceso al archivo (ya que varios usuarios pueden querer hacer esto al mismo tiempo) no es trivial y podría hacer que algunos usuarios esperen mucho tiempo. Y aún necesita podar el archivo de todos modos.

Obviamente, otro problema sería que varias personas detrás de la misma IP podrían acceder al archivo.

Cuando todo lo demás falla

Si realmente no tiene ninguna forma de que su servidor web lo ayude, la única solución que queda es readfile, que está disponible en todas las versiones de php actualmente en uso y funciona bastante bien (pero no es realmente eficiente).


Combinando soluciones

En fin, la mejor manera de enviar un archivo realmente rápido si desea que su código php se pueda usar en todas partes es tener una opción configurable en algún lugar, con instrucciones sobre cómo activarlo dependiendo del servidor web y tal vez una detección automática en su instalación. guión.

Es bastante similar a lo que se hace en muchos software para

  • URL limpias ( mod_rewriteen apache)
  • Funciones criptográficas (mcrypt módulo php)
  • Soporte de cadenas multibyte ( mbstringmódulo php)
Julien Roncaglia
fuente
¿Hay algún problema con hacer algunos trabajos de PHP (verifique las cookies / otros parámetros GET / POST contra la base de datos) antes de hacerlo header("Location: " . $path);?
Afriza N. Arief
2
No hay problema para tal acción, lo que debe tener cuidado con el envío de contenido (impresión, eco) ya que el encabezado debe ir antes de cualquier contenido y hacer cosas después de enviar este encabezado, no es una redirección inmediata y el código después será ejecutado la mayor parte del tiempo, pero no tiene garantías de que el navegador no cortará la conexión.
Julien Roncaglia
Jords: No sabía que Apache también admitía esto, agregaré esto a mi respuesta cuando tenga tiempo. El único problema es que no estoy unificado (X-Accel-Redirect nginx, por ejemplo), por lo que se necesita una segunda solución si el servidor no lo admite. Pero debería agregarlo a mi respuesta.
Julien Roncaglia
¿Dónde puedo permitir que .htaccess controle XSendFilePath?
Keyne Viana
1
@Keyne No creo que puedas. tn123.org/mod_xsendfile no enumera .htaccess en el contexto de la opción XSendFilePath
cheshirekow
33

La forma más rápida: no lo hagas. Mire en el encabezado x-sendfile para nginx , también hay cosas similares para otros servidores web. Esto significa que aún puede hacer control de acceso, etc. en php, pero delegar el envío real del archivo a un servidor web diseñado para eso.

PD: Me da escalofríos solo de pensar en lo mucho más eficiente que es usar esto con nginx, en comparación con leer y enviar el archivo en php. Solo piense si 100 personas están descargando un archivo: con php + apache, siendo generoso, eso es probablemente 100 * 15mb = 1.5GB (aproximadamente, dispárenme), de RAM allí mismo. Nginx simplemente enviará el archivo al kernel y luego se cargará directamente desde el disco en los búferes de la red. ¡Rápido!

PPS: Y, con este método, aún puede hacer todo el control de acceso, las cosas de base de datos que desee.

Jords
fuente
4
Permítanme agregar que esto también existe para Apache: jasny.net/articles/how-i-php-x-sendfile . Puede hacer que el script detecte el servidor y envíe los encabezados adecuados. Si no existe ninguno (y el usuario no tiene control sobre el servidor según la pregunta), vuelva a la normalidadreadfile()
Fanis Hatzidakis
Ahora bien, esto es simplemente increíble: siempre odié aumentar el límite de memoria en mis hosts virtuales solo para que PHP sirva un archivo, y con esto no debería tener que hacerlo. Lo intentaré muy pronto.
Greg W
1
Y para el crédito donde se debe el crédito, Lighttpd fue el primer servidor web en implementar esto (y el resto lo copió, lo cual está bien, ya que es una gran idea. Pero dé el crédito donde se debe el crédito) ...
ircmaxell
1
Esta respuesta sigue siendo votada, pero no funcionará en un entorno donde el servidor web y su configuración están fuera del control del usuario.
Kirk Ouimet
De hecho, agregó eso a su pregunta después de que publiqué esta respuesta. Y si el rendimiento es un problema, entonces el servidor web debe estar bajo su control.
Jords
23

Aquí va una solución PHP pura. He adaptado la siguiente función de mi marco personal :

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

El código es tan eficiente como puede ser, cierra el controlador de sesión para que otros scripts PHP puedan ejecutarse simultáneamente para el mismo usuario / sesión. También admite la entrega de descargas en rangos (que también es lo que hace Apache de forma predeterminada, sospecho), para que las personas puedan pausar / reanudar las descargas y también beneficiarse de velocidades de descarga más altas con aceleradores de descarga. También le permite especificar la velocidad máxima (en Kbps) a la que la descarga (parte) debe ser servida a través del $speedargumento.

Alix Axel
fuente
2
Obviamente, esto es solo una buena idea si no puede usar X-Sendfile o una de sus variantes para que el kernel envíe el archivo. Debería poder reemplazar el ciclo feof () / fread () anterior con [ php.net/manual/en/function.eio-sendfile.php](PHP's eio_sendfile ()] llamada, que logra lo mismo en PHP. Esto no es tan rápido como hacerlo directamente en el kernel, ya que cualquier salida generada en PHP todavía tiene que volver a salir a través del proceso del servidor web, pero será muchísimo más rápido que hacerlo en código PHP.
Brian C
@BrianC: Claro, pero no puede limitar la velocidad o la capacidad de varias partes con X-Sendfile (que puede no estar disponible) y eiotampoco siempre está disponible. Aún así, +1, no sabía sobre esa extensión pecl. =)
Alix Axel
¿Sería útil admitir la codificación de transferencia: fragmentada y la codificación de contenido: gzip?
skibulk
¿Por qué $size = sprintf('%u', filesize($path))?
Svish
14
header('Location: ' . $path);
exit(0);

Deje que Apache haga el trabajo por usted.

anfetamáquina
fuente
12
Eso es más simple que el método x-sendfile, pero no funcionará para restringir el acceso a un archivo, por ejemplo, solo las personas que hayan iniciado sesión. Si no necesitas hacer eso, ¡es genial!
Jords
También agregue una verificación de referencia con mod_rewrite.
sanmai
1
Puede autenticar antes de pasar el encabezado. De esa manera tampoco estará bombeando toneladas de cosas a través de la memoria de PHP.
Brent
7
@UltimateBrent La ubicación aún tiene que ser accesible para todos ... Y una verificación de referencia no es ninguna seguridad en absoluto, ya que proviene del cliente
Øyvind Skaar
@Jimbo Un token de usuario que vas a comprobar ¿cómo? ¿Con PHP? De repente, su solución está recurriendo.
Mark Amery
1

Una mejor implementación, con soporte de caché, encabezados http personalizados.

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
fuente
0

Si tiene la posibilidad de agregar extensiones PECL a su php, simplemente puede usar las funciones del paquete Fileinfo para determinar el tipo de contenido y luego enviar los encabezados adecuados ...

Andreas Linden
fuente
/ bump, ¿has mencionado esta posibilidad? :)
Andreas Linden
0

La Downloadfunción PHP mencionada aquí estaba causando cierto retraso antes de que el archivo comenzara a descargarse. No sé si esto fue causado por el uso de caché de barniz o qué, pero me ayudó a eliminar el sleep(1);completo y conjunto $speeda 1024. Ahora funciona sin ningún problema ya que es increíblemente rápido. Tal vez puedas modificar esa función también, porque vi que se usaba en todo Internet.

usuario1601422
fuente
0

Codifiqué una función muy simple para servir archivos con PHP y detección automática de tipo 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);
}

Uso

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