Programação Por Equipe DevBras

Capa do artigo: Compactar pasta em ZIP com PHP sem mandar o .env junto

Compactar pasta em ZIP com PHP sem mandar o .env junto

Monte o ZIP com ZipArchive a partir de uma lista branca de extensões, gere fora da requisição e entregue por link autenticado que expira.

O cliente do escritório pede "me manda os documentos de setembro". Alguém escreve um script que compacta a pasta inteira e publica o link. Dois meses depois, outra pessoa baixa esse mesmo ZIP e encontra, além dos PDFs, o .env com a senha do banco e uma pasta backups com o dump completo. A resposta curta: com ZipArchive, adicione só arquivos que passam numa lista branca de extensões, dentro de uma pasta de origem fixa; gere o ZIP num processo separado da página; e entregue por um script que confere a sessão, com nome aleatório e prazo de validade.

O exemplo é o mesmo sistema de contabilidade que guarda documentos em /var/www/dados/clientes/cliente-42/2026/09 e precisa oferecer o pacote do mês para download.

Por que "compactar a pasta" vaza mais do que devia

Os scripts clássicos percorrem o diretório recursivamente e adicionam tudo o que encontram. Isso inclui arquivos ocultos (que começam com ponto e você não vê no explorador), arquivos temporários de edição, o ZIP gerado no mês anterior e qualquer coisa que alguém colocou ali "só por um dia". Também segue o que estiver no caminho: um link simbólico para outra pasta leva o conteúdo dela junto.

O segundo problema é onde o ZIP fica. Se ele é gravado na pasta pública com nome previsível, como documentos-setembro.zip, qualquer pessoa que adivinhar o nome baixa, sem login. E se é gerado dentro da requisição, uma pasta de 2 GB estoura o tempo limite da página e deixa um arquivo pela metade no disco.

Como montar o ZIP com lista branca

  1. Resolva a pasta de origem com realpath e guarde esse caminho como referência.
  2. Percorra com RecursiveDirectoryIterator, pulando . e ...
  3. Ignore links simbólicos, arquivos ocultos e tudo cuja extensão não esteja na lista.
  4. Confira que o caminho real de cada arquivo continua dentro da origem.
  5. Adicione com um nome relativo, para o ZIP não carregar o caminho completo do servidor.
  6. Feche o ZIP e confira o retorno de close(), que é quando a gravação acontece de fato.
<?php
function gerarZip(string $origem, string $destinoZip): int
{
    $raiz = realpath($origem);
    if ($raiz === false || !is_dir($raiz)) {
        throw new RuntimeException('Pasta de origem inexistente.');
    }

    $permitidas = ['pdf', 'jpg', 'jpeg', 'png', 'xlsx', 'xml'];

    $zip = new ZipArchive();
    if ($zip->open($destinoZip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
        throw new RuntimeException('Não foi possível criar o ZIP.');
    }

    $itens = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($raiz, FilesystemIterator::SKIP_DOTS)
    );

    $total = 0;
    foreach ($itens as $arquivo) {
        if ($arquivo->isLink() || !$arquivo->isFile()) {
            continue;
        }
        if (str_starts_with($arquivo->getFilename(), '.')) {
            continue;
        }
        if (!in_array(strtolower($arquivo->getExtension()), $permitidas, true)) {
            continue;
        }
        $real = $arquivo->getRealPath();
        if ($real === false || !str_starts_with($real, $raiz . '/')) {
            continue;
        }
        $zip->addFile($real, substr($real, strlen($raiz) + 1));
        $total++;
    }

    if (!$zip->close()) {
        throw new RuntimeException('Falha ao gravar o ZIP.');
    }
    return $total;
}

A lista branca é o coração do código. Uma lista negra ("tudo menos .env") falha no dia em que aparecer config.php.bak, chave.pem ou dump.sql. Com a lista branca, um tipo novo de arquivo fica de fora até alguém decidir, conscientemente, que ele pode sair. O nome relativo no segundo argumento de addFile faz o ZIP abrir com 2026/09/nota-123.pdf, e não com var/www/dados/..., que entregaria a estrutura do servidor.

Um detalhe do addFile

O addFile não lê o arquivo na hora; ele anota o caminho, e a leitura acontece no close(). Por isso o erro de um arquivo apagado no meio do caminho, ou de disco cheio, aparece no fechamento. Se você não confere o retorno de close(), entrega um ZIP corrompido e só descobre quando o cliente reclama.

Como gerar fora da requisição

O botão "baixar documentos de setembro" não deveria compactar nada. Ele registra um pedido numa tabela e responde "estamos preparando, você recebe um aviso". Um processo em segundo plano, disparado por cron ou por um worker de fila, faz o trabalho pesado:

<?php
// bin/gerar-pacote.php, executado pelo worker: php bin/gerar-pacote.php 42 2026-09
[$_, $clienteId, $mes] = $argv;
$clienteId = (int) $clienteId;
if (!preg_match('/^[0-9]{4}-[0-9]{2}$/', $mes)) {
    exit(1);
}

$origem  = '/var/www/dados/clientes/cliente-' . $clienteId . '/' . str_replace('-', '/', $mes);
$token   = bin2hex(random_bytes(16));
$destino = '/var/www/dados/pacotes/' . $token . '.zip';

$total = gerarZip($origem, $destino);
// grave $token, $clienteId, $total e a validade (agora + 24 horas) na tabela de pacotes

Rodando no terminal, o script não tem o limite de tempo de uma página web, e uma falha não deixa o usuário olhando para um carregamento infinito. O nome do arquivo é aleatório, com 32 caracteres hexadecimais, e fica fora da pasta pública.

Como entregar o ZIP só para quem pode

<?php
// baixar-pacote.php?t=...
require __DIR__ . '/bootstrap.php';
$conta = contaLogada($pdo);
$token = (string) ($_GET['t'] ?? '');

if (!$conta || !preg_match('/^[a-f0-9]{32}$/', $token)) {
    http_response_code(404);
    exit;
}

$stmt = $pdo->prepare('SELECT 1 FROM pacotes WHERE token = ? AND cliente_id = ? AND expira_em > NOW()');
$stmt->execute([$token, $conta['cliente_id']]);
$caminho = '/var/www/dados/pacotes/' . $token . '.zip';

if (!$stmt->fetchColumn() || !is_file($caminho)) {
    http_response_code(404);
    exit;
}

header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="documentos.zip"');
header('Content-Length: ' . filesize($caminho));
readfile($caminho);

Aqui a função contaLogada devolve também o cliente_id da conta, lido do banco. O token sozinho não basta: o script confere que o pacote pertence à conta logada e que ainda não expirou. E um cron apaga o que passou do prazo, para o disco não virar arquivo morto de pacotes que ninguém vai baixar de novo:

0 3 * * * find /var/www/dados/pacotes -name '*.zip' -mmin +1440 -delete

Como conferir o que entrou

Antes de liberar o recurso, gere um pacote de teste e liste o conteúdo sem extrair. Procure qualquer nome que comece com ponto, termine em .sql, .env, .pem ou .bak, ou que você não reconheça:

$ unzip -l /var/www/dados/pacotes/teste.zip

O caminho inverso, receber um ZIP e extrair sem que ele escreva fora da pasta, tem suas próprias armadilhas e está em descompactar ZIP em PHP sem escrever arquivo fora da pasta.

Erros comuns

  • Compactar a pasta do projeto inteira. O pacote leva código, .env, chaves e às vezes o histórico do Git, que guarda até segredos já apagados.
  • Usar lista negra de extensões. O primeiro arquivo com extensão que ninguém previu, como um .bak, sai junto.
  • Gravar o ZIP na pasta pública com nome previsível. Qualquer pessoa baixa sem login, e buscadores podem indexar o link.
  • Gerar dentro da requisição. Pastas grandes estouram o tempo limite e deixam arquivos parciais no disco.
  • Ignorar o retorno de close(). O ZIP corrompido é entregue como se estivesse pronto.
  • Nunca apagar os pacotes. O disco enche, e cópias de documentos sensíveis ficam guardadas sem motivo.

Checklist

  • A origem é uma pasta fixa, resolvida com realpath.
  • Só entram extensões de uma lista branca; ocultos e links simbólicos ficam de fora.
  • Os nomes dentro do ZIP são relativos à origem.
  • O retorno de close() é verificado.
  • A geração roda em segundo plano, não na página.
  • O arquivo tem nome aleatório e fica fora da pasta pública.
  • O download confere sessão, dono do pacote e validade.
  • Um cron apaga pacotes vencidos.

Pegue o último ZIP que o seu sistema gerou e rode unzip -l nele agora. Existe algum arquivo ali que você não mandaria por e-mail para o cliente?

Avalie este artigo

Referência: http://alexandredisalvo.wordpress.com/2012/04/24/compactar-diretorio-via-php/