PHP

Upload de Arquivos Já leu

5 min de leitura

Upload de Arquivos
Upload de arquivos parece simples — e na superfície é. Mas quando você considera segurança, validação real do tipo do arquivo, limites de tamanho, nomes de arquivo maliciosos, armazenamento organizado e suporte a múltipl

Upload de arquivos parece simples — e na superfície é. Mas quando você considera segurança, validação real do tipo do arquivo, limites de tamanho, nomes de arquivo maliciosos, armazenamento organizado e suporte a múltiplos arquivos, o assunto revela profundidade considerável. Neste artigo construímos um sistema de upload robusto do zero.


Como o upload funciona

Quando um formulário com enctype="multipart/form-data" é submetido com arquivos, o PHP recebe os dados na superglobal $_FILES. Cada arquivo chega como um array com cinco campos:

<?php
// $_FILES['arquivo'] contém:
[
    'name'     => 'foto.jpg',           // Nome original no computador do usuário
    'type'     => 'image/jpeg',         // MIME type declarado pelo navegador (NÃO confie nisso)
    'tmp_name' => '/tmp/php8HgZ3x',     // Caminho temporário onde o PHP salvou
    'error'    => UPLOAD_ERR_OK,        // Código de erro (0 = sem erro)
    'size'     => 245678,               // Tamanho em bytes
]

Validação completa

<?php
declare(strict_types=1);

class ValidadorDeUpload
{
    private array $erros = [];

    private const TIPOS_PERMITIDOS = [
        'image/jpeg' => ['jpg', 'jpeg'],
        'image/png'  => ['png'],
        'image/webp' => ['webp'],
        'image/gif'  => ['gif'],
    ];

    private const TAMANHO_MAXIMO = 5 * 1024 * 1024; // 5MB

    public function validar(array $arquivo): bool
    {
        $this->erros = [];

        // 1. Verificar código de erro do PHP
        if ($arquivo['error'] !== UPLOAD_ERR_OK) {
            $this->erros[] = match ($arquivo['error']) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Arquivo excede o tamanho máximo permitido.',
                UPLOAD_ERR_PARTIAL   => 'O arquivo foi enviado parcialmente.',
                UPLOAD_ERR_NO_FILE   => 'Nenhum arquivo foi selecionado.',
                default              => 'Erro desconhecido no upload.',
            };
            return false;
        }

        // 2. Verificar se é um upload legítimo (não um arquivo local do servidor)
        if (!is_uploaded_file($arquivo['tmp_name'])) {
            $this->erros[] = 'Arquivo inválido.';
            return false;
        }

        // 3. Verificar tamanho
        if ($arquivo['size'] > self::TAMANHO_MAXIMO) {
            $this->erros[] = 'Arquivo excede 5MB.';
            return false;
        }

        // 4. Verificar MIME type REAL com finfo (não confiar no $_FILES['type'])
        $finfo    = new finfo(FILEINFO_MIME_TYPE);
        $mimeReal = $finfo->file($arquivo['tmp_name']);

        if (!array_key_exists($mimeReal, self::TIPOS_PERMITIDOS)) {
            $this->erros[] = "Tipo de arquivo não permitido: {$mimeReal}";
            return false;
        }

        // 5. Verificar extensão contra o MIME real (dupla verificação)
        $extensao = strtolower(pathinfo($arquivo['name'], PATHINFO_EXTENSION));
        if (!in_array($extensao, self::TIPOS_PERMITIDOS[$mimeReal], strict: true)) {
            $this->erros[] = 'Extensão não corresponde ao tipo do arquivo.';
            return false;
        }

        return true;
    }

    public function erros(): array { return $this->erros; }
}

Armazenamento seguro

<?php
declare(strict_types=1);

class ServicoDeUpload
{
    public function __construct(
        private readonly string $diretorioBase,
        private readonly ValidadorDeUpload $validador,
    ) {
        if (!is_dir($this->diretorioBase)) {
            mkdir($this->diretorioBase, 0755, recursive: true);
        }
    }

    public function salvar(array $arquivo, string $subdiretorio = ''): string
    {
        if (!$this->validador->validar($arquivo)) {
            throw new InvalidArgumentException(
                implode(', ', $this->validador->erros())
            );
        }

        // NUNCA use o nome original do usuário — pode conter ../../../etc/passwd
        $extensao    = strtolower(pathinfo($arquivo['name'], PATHINFO_EXTENSION));
        $nomeSeguro  = bin2hex(random_bytes(16)) . '.' . $extensao; // Nome aleatório

        $diretorio = rtrim($this->diretorioBase . '/' . ltrim($subdiretorio, '/'), '/');
        if (!is_dir($diretorio)) {
            mkdir($diretorio, 0755, recursive: true);
        }

        $destino = $diretorio . '/' . $nomeSeguro;

        // move_uploaded_file: move o arquivo temporário para o destino final
        // Mais seguro que rename() pois verifica se é um upload legítimo
        if (!move_uploaded_file($arquivo['tmp_name'], $destino)) {
            throw new RuntimeException('Falha ao salvar o arquivo.');
        }

        // Retorna o caminho relativo ao diretorioBase
        return ltrim($subdiretorio . '/' . $nomeSeguro, '/');
    }
}

// Uso:
$servico = new ServicoDeUpload(
    diretorioBase: __DIR__ . '/storage/uploads',
    validador:     new ValidadorDeUpload(),
);

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['foto'])) {
    try {
        $caminho = $servico->salvar($_FILES['foto'], 'fotos/perfil');
        echo "Arquivo salvo em: {$caminho}";
    } catch (InvalidArgumentException $e) {
        echo "Erro de validação: " . $e->getMessage();
    }
}

Upload múltiplo

<!-- name="fotos[]" permite enviar múltiplos arquivos -->
<input type="file" name="fotos[]" multiple accept="image/*">
<?php

// $_FILES['fotos'] com múltiplos arquivos chega num formato diferente
// Precisamos reorganizar para iterar normalmente

function reorganizarArquivos(array $arquivos): array
{
    $resultado = [];
    foreach ($arquivos['name'] as $i => $nome) {
        $resultado[] = [
            'name'     => $nome,
            'type'     => $arquivos['type'][$i],
            'tmp_name' => $arquivos['tmp_name'][$i],
            'error'    => $arquivos['error'][$i],
            'size'     => $arquivos['size'][$i],
        ];
    }
    return $resultado;
}

if (isset($_FILES['fotos'])) {
    $arquivos = reorganizarArquivos($_FILES['fotos']);
    $salvos   = [];
    $erros    = [];

    foreach ($arquivos as $i => $arquivo) {
        try {
            $salvos[] = $servico->salvar($arquivo, 'galeria');
        } catch (InvalidArgumentException $e) {
            $erros[] = "Arquivo " . ($i + 1) . ": " . $e->getMessage();
        }
    }
}

Resumo

Ponto Detalhe
$_FILES['type'] Não confie — use finfo para o MIME real
is_uploaded_file() Sempre verifique antes de mover
Nome do arquivo Nunca use o nome original — gere um aleatório
Diretório de upload Fora do document root ou com .htaccess bloqueando execução
move_uploaded_file() Prefira a rename() para uploads

Exercício da semana

  1. Implemente upload de avatar de usuário: salve o caminho no banco, exiba no perfil, limite a 2MB e aceite apenas JPEG, PNG e WebP.

  2. Adicione redimensionamento de imagem usando a extensão GD: após o upload, crie uma versão thumbnail (150x150) e uma versão média (800x600) mantendo proporção.

  3. Implemente upload múltiplo de documentos PDF (máx. 10MB cada, máx. 5 arquivos por vez). Armazene os metadados (nome original, tamanho, data) no banco.

  4. Desafio: implemente armazenamento em dois backends intercambiáveis via interface StorageInterface: LocalStorage (disco) e S3Storage (simulado com arquivo de log). O ServicoDeUpload deve receber o backend por injeção de dependência.


Referências

  • PHP Manual — Upload de arquivos: https://www.php.net/manual/pt_BR/features.file-upload.php
  • PHP Manual — finfo: https://www.php.net/manual/pt_BR/class.finfo.php
  • OWASP — File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
Comentários

Mais em PHP

Testes Automatizados com PHPUnit
Testes Automatizados com PHPUnit

Testes automatizados são o mecanismo que permite modificar código com confian...

SQLite com PHP
SQLite com PHP

SQLite é o banco de dados mais instalado do mundo — está presente em todos os...

PDO Avançado, Prepared Statements e Padrão Repository
PDO Avançado, Prepared Statements e Padrão Repository

PDO (PHP Data Objects) é a camada de abstração de banco de dados nativa do PH...