Manipulando arquivos carregados por upload no Laravel

Arquivos grandes carregados por upload no Laravel

por Gustavo Straube 07/04/2017

Laravel é um framework que tem se tornado bastante popular no mundo do PHP. Uma das vantagens dele sobre outros frameworks é que ele permite que seu código fique limpo e fluído, especialmente pela forma como ele trata as dependências e outras ajudas. Mas isso é assunto para outro momento. Agora, queria falar um pouco sobre a manipulação de arquivos carregados via upload.

Para começar, queria compartilhar uma situação hipotética: por causa da lógica de negócio por trás do projeto que você está trabalhando, é necessário garantir que arquivos duplicados não sejam carregados via upload. Usar o nome do arquivo nem sempre é a melhor forma de fazer isso porque o usuário pode simplesmente usar outro nome. Então quero compartilhar uma forma de fazer isso usando Laravel.

A documentação do framework não é muito clara a respeito da classe que representa um arquivo carregado. Procurando no código-fonte, porém, é possível encontrar evidências de que o objeto retornado pelo método $request->file() é uma instância da classe SplFileInfo. Isso significa que é possível manipular o arquivo sem grandes complicações. E o melhor: é possível fazer isso antes mesmo de gravar o arquivo no destino final.

Você pode encontrar essa evidência da SplFileInfo na classe File:

class File extends \SplFileInfo 
{

Essa referência a uma classe do Symfony pode parecer estranha se você não tem familiriadade com a arquitetura do Laravel, mas ele usa vários componentes do Symfony internamente.

Bom, vamos ver como é possível fazer a manipulação na prática!

Resolvendo um upload

Uma action (método de um controller) simples para upload de arquivo geralmente pega a instância do arquivo do request, verifica se um arquivo foi enviado e grava ele no destino final. Provavelmente algo parecido com isso:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request; 
use Symfony\Component\HttpFoundation\Response;

class FileController extends Controller 
{

    /**
    * Resolve o envio do arquivo.
    *
    * @param Request $request A instância do request.
    * @return Response A instância da response.
    */
    public function upload(Request $request)
    {

        /*
        * O campo do form com o arquivo tinha o atributo name="file".
        */
        $file = $request->file('file');

        if (empty($file)) {
            abort(400, 'Nenhum arquivo foi enviado.');
        }

        $path = $file->store('uploads');

        // Faça qualquer coisa com o arquivo enviado...

    }
}

Lendo e comparando o arquivo em partes

Usando aquela instância do arquivo que pegamos do request, podemos escrever um método que compara ela com uma outra instância da classe SplFileInfo. Para agilizar o processamento, você não precisa comparar os arquivos inteiros. Ir por partes é a melhor estratégia. Você pode ler um pedaço do mesmo tamanho de ambos os arquivos e compará-los. Assim que uma diferença for encontrada, pare a execução do método.

/**
 * Compara o conteúdo de dois arquivos para verificar se há diferenças.
 *
 * Não verifica qual exatamente é a diferença entre os arquivos. Dessa 
 * forma, uma diferenção é encontrada, a função para de ler os arquivos.
 *
 * @param SplFileInfo $a O primeiro arquivo para comparar.
 * @param SplFileInfo $b O segundo arquivo para comparar.
 * @return bool Indica se há qualquer diferença entre os arquivos.
 */
private function fileDiff($a, $b) 
{
    $diff = false;
    $fa = $a->openFile();
    $fb = $b->openFile();

    /*
     * Lê o mesmo número de bytes de cada arquivo. Quebra (break) o loop 
     * assim que uma diferença for encontrada.
     */
    while (!$fa->eof() && !$fb->eof()) {
        if ($fa->fread(4096) !== $fb->fread(4096)) {
            $diff = true;
            break;
        }
    }

    /*
     * Apenas um dos arquivos chegou ao fim.
     */
    if ($fa->eof() !== $fb->eof()) {
        $diff = true;
    }

    /*
     * Closing handlers.
     */
    $fa = null;
    $fb = null;

    return $diff;
}

Melhorando a performance

A função que escrevemos acima é uma ótima maneira de comparar dois arquivos. Porém, abrir, ler e comparar o conteúdo de cada arquivo enviado anteriormente para a aplicação pode consumir muitos recursos (tempo, I/O, processamento). Podemos reduzir significamente esse consumo comparando o tamanho dos arquivos primeiro, e então apenas abrir e comparar o conteúdo dos arquivos que tiverem o mesmo tamanho. Isso ficaria assim:

/**
 * Verifica se o arquivo passado já foi enviado antes.
 *
 * Passa de arquivo em arquivo no diretório de uploads, conferindo se algum 
 * pode ser igual ao arquivo sendo enviado.
 *
 * @param SplFileInfo $file O arquivo para verificar.
 * @return bool Indica se arquivo já foi enviando antes.
 */
private function isAlreadyUploaded($file) 
{
    $size = $file->getSize();

    /*
     * O arquivo onde os arquivos são gravados.
     */
    $path = storage_path('app/uploads/');

    if (!is_dir($path)) {
        return false;
    }

    $files = scandir($path);
    foreach ($files as $f) {
        $filePath = $path . $f;
        if (!is_file($filePath)) {
            continue;
        }

        /*
         * Se ambos os arquivos tiverem o mesmo tamanho, compara o conteúdo.
         */
        if (filesize($filePath) === $size) {

            /*
             * Verifica se há alguma diferença, usando a função que escrevemos 
             * acima.
             */
            $diff = $this->fileDiff(new \SplFileInfo($filePath), $file);

            /*
             * Retorna se os arquivos **não** são diferentes, ou seja, iguais. 
             * Isso significa que já foi enviado antes.
             */
            return !$diff;
        }
    }
    return false;
}

Gravando o arquivo

Voltando à action que definimos no começo e usando esse último método que escrevemos, podemos finalmente gravar o arquivo. É importante lembrar de chamar o método de gravação (store) apenas depois de ter verificado se o arquivo já não existe.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request; 
use Symfony\Component\HttpFoundation\Response;

class FileController extends Controller 
{

    /**
     * Resolve o envio do arquivo.
     *
     * @param Request $request A instância do request.
     * @return Response A instância da response.
     */
    public function upload(Request $request)
    {

        /*
         * O campo do form com o arquivo tinha o atributo name="file".
         */
        $file = $request->file('file');

        if (empty($file)) {
            abort(400, 'Nenhum arquivo foi enviado.');
        }

        /*
         * Já existe um arquivo igual ao que está sendo enviado?
         */
        if ($this->isAlreadyUploaded($file)) {
            abort(400, 'Esse mesmo arquivo já foi enviado antes.');
        }

        /*
         * Apenas grava o arquivo depois da verificação.
         */
        $path = $file->store('uploads');

        // Faça qualquer coisa com o arquivo enviado...

    }
}

Conclusão

Essa é apenas uma maneira de trabalhar com o arquivo retornado pelo request, pensando no que ele é internamente. Existem provavelmente outras mil maneiras de utilizar isso para resolver outros problemas ou ao menos tornar seu código mais fluído.

Também é importante entender que essa é apenas uma forma de fazer isso usando o framework. Você provavelmente pode usar outras táticas para resolver o mesmo problema. Por exemplo, seria possível armazenar o arquivo enviado em um diretório temporário e então fazer as comparações necessárias. Ou você pode aproveitar a lógica descrita aqui e usar as funções nativas do PHP para upload ao invés das funções do Laravel.