Trabajando con ficheros (PHP)

TEORÍA

Para empezar, comenzaremos con la subida de ficheros al servidor y luego se explica cómo manejar los ficheros (guardar el fichero en un directorio del servidor, descargar el fichero y borrar el fichero).

La sección de la subida del fichero al servidor es bastante importante para entender qué ocurre cuando subimos un fichero al servidor, es por ello que esa parte tiene una parte teórica. El resto es pura práctica.

SUBIDA DEL FICHERO

Se deben mandar los datos con un «Content-Type: multipart/form-data» (si quisiéramos subir el fichero a través de una página HTML, se debe indicar en el formulario de HTML que se desea este tipo de contenido en su atributo «enctype«):

<form enctype="multipart/form-data" action="url.php" method="POST">
    Fichero: <input name="mi_fichero" type="file" />
    <input type="submit" value="Enviar" />
</form>

Habitualmente se mandan los datos de un formulario con el tipo de contenido «Content-Type: application/x-www-form-urlencoded» cuando no se tienen ficheros en él ya que está pensado para mandar datos pequeños, porque cuando se manda este «Content-Type«, el navegador sustituye los caracteres «no alfa-numéricos» con 3 bytes (concretamente con la cadena «%HH«, esto es, el símbolo del porcentaje y dos dígitos hexadecimales), lo que significaría que un fichero podría llegar a tener 3 veces su tamaño real en el formulario (lo cual es un problema como cabe esperar).

Es por ello que se usa el tipo de contenido «multipart/form-data«. Sin embargo no se debe usar este tipo de contenido para los formularios sin ficheros, ya que la cantidad de información enviada será «pequeña» y las cabeceras que se añaden en este «Content-Type» aumentaría el tamaño final del cuerpo del mensaje HTTP que se va a mandar al servidor. Para ilustrarlo, este es el aspecto que tiene un formulario «multipart/form-data«:

POST / HTTP/1.1
[[ Otras cabeceras HTTP ... ]]
Content-Type: multipart/form-data; boundary=---------------------------735323031399963166993862150
Content-Length: 834

-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="input1"

contenido del input1
-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="input2"

contenido del input2
-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="fichero"; filename="imagen.jpg"
Content-Type: application/octet-stream

/*Contenido binario de imagen.jpg*/
-----------------------------735323031399963166993862150

Como se puede apreciar, cada input del formulario enviado se manda como una parte, y cada parte tiene unas cabeceras (Content-Disposition) separadas por un string (boundary). Si sólo hay inputs que no sean de tipo fichero (<input type=»file»>) se va a añadir más información de la necesaria, el tamaño del body va a aumentar sin razón para ello.

Así que, en resumen, el «Content-Type: multipart/form-data» sólo debe usarse para la subida de ficheros.

Ficheros y JSON

Es posible que hayas trabajado con JSON para mandar datos de un formulario al servidor, sobre todo si la aplicación Web sigue una arquitectura API REST (de hecho, es bastante cómodo trabajar y examinar lo que se está mandando y recibiendo con este formato). Pero llega un momento en el que se quieren mandar ficheros a través de una petición HTTP con AJAX.

Lamento deciros que mandar ficheros en una petición HTTP con AJAX en formato JSON no es para nada aconsejable. Para empezar habría que codificar el fichero en base64 (por ejemplo) ya que no podemos poner el fichero en binario directamente (JSON no soporta datos binarios) lo que produciría un aumento superior al 33% del fichero debido al método que usa base64 para codificar los datos binarios.

Hay otros métodos para codificar el fichero (como base85 ó base91) pero tampoco solucionan el problema del aumento del tamaño del fichero y sus implementaciones no son fáciles de encontrar para todos los lenguajes.

Por otro lado, el fichero iría directamente en memoria RAM del servidor, lo cual, como se puede suponer, es un gran problema (imagina que el servidor te permite subir hasta 5 gigas pero el script de php tiene un límite de memoria de 500 megas, no cabría el fichero en la memoria del script, además de que está consumiendo recursos de forma absurda).

Cuando se manda un fichero al servidor con el «Content-Type: application/json«, se suele acceder a estos datos mediante el método «file_get_contents» al flujo de E/S «php://input«, lo que provocará que se cargue en memoria RAM lo que se haya mandado en el cuerpo de la petición POST (a menos que se consiguiera leer el flujo E/S de tal forma que los datos del fichero subido se escribiesen en un fichero temporal y el resto de datos fuesen a la memoria RAM, para ello se tendría que «parsear» el cuerpo de la petición HTTP poco a poco hasta encontrar un fichero).

¿Cómo maneja la subida de ficheros PHP?

Cuando se mandan datos con el tipo de contenido «multipart/form-data«, el script PHP parsea los datos y detecta qué partes del formulario recibido son ficheros y qué partes son inputs «sencillos». En ese momento, siguiendo lo especificado en el estándar rfc1867, PHP guarda los ficheros en un directorio temporal del disco duro (definido en php.ini) y devuelve los metadatos de este fichero en el array global $_FILES. El resto de datos van directamente al array global $_POST. Tanto $_FILES como $_POST están en memoria RAM, pero los ficheros en sí están en el disco duro.

Entonces, aunque encontrásemos una manera de subir ficheros con JSON sin que se añadan datos en la codificación del mismo, tendríamos el problema de que PHP cargaría los datos en memoria directamente. No podemos decirle que parte de estos datos los meta en un fichero temporal ya que esa funcionalidad está implementada en su función interna desarrollada en C llamada «rfc1867_post_handler» la cual se ejecuta al detectar el «Content-Type: multipart/form-data» y es el que determina qué datos van a un fichero temporal y qué datos van a la memoria RAM.

memory_limit & upload_max_filesize

Entonces, como es de esperar, el límite de memoria que puede utilizar un script PHP no tiene por qué ser el mismo que el de la directiva usada para limitar el tamaño máximo de un fichero que se puede subir al servidor, ya que este no se carga en memoria RAM.

PRÁCTICA

Para el manejo de ficheros, vamos a crear una clase en PHP llamada «FileManager«. Todas las funciones que se explican a continuación formarán parte de esta clase.

SUBIDA DEL FICHERO

La idea básica es comprobar el tipo de fichero que se ha subido (comprobar qué tipo de objeto MIME es) usando las funcionalidades del objeto «finfo«, y, tras comprobar que es adecuado (deberíamos tener una lista de los archivos que aceptamos en nuestro servidor), y que coincide con lo que el usuario nos dice que está subiendo al servidor, moverlo a un directorio donde almacenaremos nuestros ficheros mediante la función «move_uploaded_file()«.

Comprobar los tipos MIME:

private static $acceptedFiles = array(
	'Vídeo'=>array(
		'video/mp4'=>'mp4', 
		'video/mkv'=>'mkv'),
	
	'Audio'=>array(
		'audio/mpeg'=>'mp3', 
		'audio/wav'=>'wav'),
	
	'Imagen'=>array(
		'image/jpeg'=>'jpg', 
		'image/gif'=>'gif', 
		'image/bmp'=>'bmp', 
		'image/png'=>'png')
);

// Comprueba si el tipo mime está en la lista de mimes permitidos
public function mimeIsAllowed($mime){
  foreach (self::$acceptedFiles as $mimeGroup => $groupMimeTypes) {
    foreach ($groupMimeTypes as $mimeType => $fileExtension) {
      if($mime == $mimeType) return true;
    }
  }
return false;
}

// Comprueba si el MIME pasado por parámetro coincide con el del
// fichero indicado
public function checkMimeType($src, $mime_type){
  $detected_mime_type = self::getMimeType($src);
  if(!$this->mimeIsAllowed($mime_type)) return false;
  return ($detected_mime_type == $mime_type);
}

// Devuelve el tipo MIME del fichero indicado
public static function getMimeType($src){
  $file_info = new finfo(FILEINFO_MIME); 
  $detected_mime_type = $file_info->buffer(file_get_contents($src));
  $detected_mime_type = explode(";", $detected_mime_type, 2);
  $detected_mime_type = $detected_mime_type[0];
  return $detected_mime_type;
}

La dirección del fichero temporal que ha guardado PHP en la subida del archivo se almacena en el atributo «tmp_name» del array $_FILES (esto es, $_FILES[<name>][‘tmp_name’]). Este fichero temporal se borrará automáticamente al terminar el script PHP.

Cuando se sube un fichero y se produce algún error, podemos encontrar el código del mismo en el atributo «error» del array $_FILES del fichero concreto (esto es, $_FILES[<name>][‘error’]). Hay una lista de errores explicados aquí, pero para manejarlos y tener una traducción del mismo, podemos usar esta excepción llamada «UploadException» (fuente):

<?php
//source: https://secure.php.net/manual/es/features.file-upload.errors.php#89374
class UploadException extends Exception {
    public function __construct($code) {
        $message = $this->codeToMessage($code);
        parent::__construct($message, $code);
    }

    private function codeToMessage($code)
    {
        switch ($code) {
            case UPLOAD_ERR_INI_SIZE:
                $message = "The uploaded file exceeds the upload_max_filesize directive in php.ini";
                break;
            case UPLOAD_ERR_FORM_SIZE:
                $message = "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form";
                break;
            case UPLOAD_ERR_PARTIAL:
                $message = "The uploaded file was only partially uploaded";
                break;
            case UPLOAD_ERR_NO_FILE:
                $message = "No file was uploaded";
                break;
            case UPLOAD_ERR_NO_TMP_DIR:
                $message = "Missing a temporary folder";
                break;
            case UPLOAD_ERR_CANT_WRITE:
                $message = "Failed to write file to disk";
                break;
            case UPLOAD_ERR_EXTENSION:
                $message = "File upload stopped by extension";
                break;

            default:
                $message = "Unknown upload error";
                break;
        }
        return $message;
    }
}

Entonces, teniendo el chequeo de los objetos MIME subidos y teniendo la clase «UploadException«, movemos el fichero que se ha subido durante la ejecución del script PHP usando el método «move_uploaded_file«:

public function storeUploadedFile($inputName, $path, $fileName, $mime_type){
  if($_FILES[$inputName]['error'] !== UPLOAD_ERR_OK){
    throw new UploadException($_FILES[$inputName]['error']);
  }
  $uploadedFile = $_FILES[$inputName]['tmp_name'];
  $detected_mime_type = self::getMimeType($uploadedFile);
  // Check MIME types
  if(!$this->mimeIsAllowed($mime_type))
    throw new MimeNotAllowedException("El mime indicado no está permitido");
  if($detected_mime_type != $mime_type) 
    throw new MismatchMimeTypesException("El tipo del archivo indicado ({$mime_type}) no coincide con el tipo de archivo detectado ({$detected_mime_type})", 1);
  // Store file
  $pathToFile = $path.'/'.$fileName;
  if(!move_uploaded_file($uploadedFile, $pathToFile)){
    $error = error_get_last();
    throw new IOException("Error al guardar el archivo subido: ".$error['message']);
  }else{
    return true;
  }	
}

NOTA: Las excepciones que aquí se encuentran son herencias de la clase «Exception» y no tienen ninguna funcionalidad especial salvo la de distinguir el tipo de error que se ha producido durante la ejecución de la función.

DETERMINAR EL TAMAÑO MÁXIMO DE SUBIDA

Para saber cuál es el tamaño que se puede subir al servidor, se deben leer las directrices «post_max_size» y «upload_max_filesize» de «php.ini«. Como el formato de los valores de estos atributos puede variar, también hay que parsearlo (fuente):

//Source: https://stackoverflow.com/questions/13076480/php-get-actual-maximum-upload-size
// Devuelve el tamaño máximo en bytes que puede subirse al servidor
public static function file_upload_max_bytes() {
  static $max_size = -1;

  if ($max_size < 0) {
    // Leer post_max_size.
    $post_max_size = self::parse_size(ini_get('post_max_size'));
    if ($post_max_size > 0) {
      $max_size = $post_max_size;
    }

    // Si upload_max_size es menor, este es el tamaño de subida 
    // real a menos que sea cero, lo que indica no-limit
    $upload_max = self::parse_size(ini_get('upload_max_filesize'));
    if ($upload_max > 0 && $upload_max < $max_size) {
      $max_size = $upload_max;
    }
  }
  return $max_size;
}

private static function parse_size($size) {
  // Quitar los caracteres que no representen unidades de almacenamiento
  $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); 
  // Quitar los caracteres no numéricos
  $size = preg_replace('/[^0-9\.]/', '', $size);
  if ($unit) {
    // Encuentra la unidad indicada para la subida y lo multiplica 
    // por un kilobyte (si b=>0, si k=>1 ...)
    return round($size * pow(1024, stripos('bkmgtpezy', $unit[0])));
  }
  else {
    return round($size);
  }
}

DESCARGAR EL FICHERO

Tenemos dos situaciones al descargar el fichero:

  • Que los ficheros sean «públicos» (accesibles mediante URL).
  • Que los ficheros sean «privados» (accesibles únicamente a través de un script PHP).

En la primera situación, descargar el fichero es tan sencillo como escribir en la URL la dirección del fichero (www.example.com/media/fichero.jpg).

En la segunda situación, el fichero sólo puede ser accesible después de que el script PHP haya comprobado que el usuario tiene permisos para leer el fichero (en ese caso, se debe bloquear el acceso a los ficheros mediante directivas del servidor HTTP, como por ejemplo el fichero .htaccess, de esta forma sólo puede acceder el script PHP).

En cualquiera de los casos, si queremos descargar un fichero pulsando un botón (en lugar de que el usuario tenga que descargar la imagen usando el menú contextual del puntero del ratón), la idea es devolverle al usuario el resultado de la función «readfile()«, previamente indicándole en las cabeceras el nombre del fichero, el tamaño del mismo la última fecha de modificación del fichero, y, lo más importante, indicar que el contenido viene como un adjunto (Content-Disposition: attachment), sino, cuando pidamos este archivo con javascript nos abrirá una página en lugar de indicarnos el navegador que este fichero es una descarga.

public function dowloadFile($path, $fileName){
	$path_to_file = $path.'/'.$fileName;
	if (!file_exists($path_to_file)) throw new IOException('Fichero no encontrado');
	$mime = self::getMimeType($path_to_file);
	http_response_code(200);
	header("Content-Type: " . $mime);
	header('Content-Disposition: attachment; filename="'.basename($fileName).'"');
	header("Cache-Control: max-age=2592000, public");
	header("Expires: ".gmdate('D, d M Y H:i:s', time()+2592000) . ' GMT');
	header("Last-Modified: ".gmdate('D, d M Y H:i:s', @filemtime($path_to_file)) . ' GMT' );
	header('Content-Length: ' . filesize($path_to_file));
	readfile($path_to_file);
	die();
}

Al ejecutar este script PHP el resultado será algo como esto:

Ficheros privados: previsualización y descarga

A la hora de descargar un fichero que es privado (sólo accesible a través de PHP) tenemos 2 situaciones a su vez:

  • Queremos previsualizarlo dentro de un documento HTML.
  • Queremos descargarlo como un documento adjunto (como en la situación anterior que usaba la función «readfile()«).

Si queremos descargarlo sencillamente hay que utilizar el método anterior con las cabeceras antes expuestas.

Sin embargo, si queremos previsualizarlo, podemos encontrarnos con que el fichero sea demasiado grande como para esperar que la función «readfile()» termine de leer todos los datos y luego mandarlos al cliente (como ocurriría por ejemplo con ficheros de vídeo o audio). En esos casos se manda el archivo como un streaming (esto es, se envían los bytes del fichero poco a poco) mediante la cabecera «Content-Range» y el estado «HTTP 206 Partial Content«. El propio servidor HTTP y el navegador entendenderán esta información y sabrán como «pegarla». El script PHP irá leyendo unos bytes del contenido del fichero con la función «fread()» y los irá mandando al servidor con la función «flush()«. En esencia:

ob_get_clean();
header("Content-Type: " . $mime);
header("Accept-Ranges: bytes 0-{$fileSize-1}");
header("Content-Range: bytes {$bytesInicio}-{$bytesFin}/{$fileSize-1}");

//Abrir el fichero en binario para IE
$stream = fopen($this->path, 'rb'); 
while(!feof($stream)) {
  // Se tiene que comprobar que fread no se pasa del tamaño máximo del fichero
  $data = fread($stream, 102400 /*1 KB*/);
  echo $data;
  flush();
}

Para crear un stream, puedes usar esta clase publicada en github aunque tiene un par de fallos que están arreglados en esta clase a la que he llamado Stream. Nótese que usaré esta clase «Stream» como una clase embebida de «FileManager» y ambas harán uso de algunos métodos de la otra.

Entonces, finalmente, el método para previsualizar el fichero sería el siguiente:

private function mimeIsVideo($mime){
	foreach (self::$acceptedFiles['Vídeo'] as $mimeType => $fileExtension) {
		if($mime == $mimeType) return true;
	}
	return false;
}

private function mimeIsAudio($mime){
	foreach (self::$acceptedFiles['Audio'] as $mimeType => $fileExtension) {
		if($mime == $mimeType) return true;
	}
	return false;
}

public function previewFile($path, $fileName){
	$path_to_file = $path.'/'.$fileName;
	if (!file_exists($path_to_file)) throw new IOException('Fichero no encontrado');
	$mime = self::getMimeType($path_to_file);
	if($this->mimeIsVideo($mime)){
		$stream = new Stream($path_to_file);
		$stream->start();
	}else if($this->mimeIsAudio($mime)){
		$stream = new Stream($path_to_file);
		$stream->start();
	}else{
		$this->dowloadFile($path, $fileName);
	}
}

BORRAR EL FICHERO

El proceso es tan sencillo como aplicar «unlink()» al fichero que queremos borrar:

public function deleteFile($path, $fileName){
	$path_to_file = $path.'/'.$fileName;
	if(!file_exists($path_to_file)){
		throw new IOException('Fichero no encontrado');
	}
	if(true===unlink($path_to_file)){
		return true;
	}else{
		$error = error_get_last();
		if($error['message']!=''){
			throw new IOException($error['message']);
		}else{
			throw new IOException("No se pudo borrar el archivo");
		}
	}
}

DESCARGAS

Puedes descargar la clase «FileManager«, «Stream» y sus excepciones asociadas aquí.


Créditos de fuentes externas:

Bibliografía:

Deja un comentario