Programação Por Equipe DevBras

Capa do artigo: Copiar arquivo em PHP com copy() sem deixar o usuário escolher origem e destino

Copiar arquivo em PHP com copy() sem deixar o usuário escolher origem e destino

A origem sai de uma lista que o código conhece, o destino tem nome gerado por você e a cópia passa por um arquivo temporário. Assim copy() não sobrescreve nem lê o que não deve.

O botão "usar contrato padrão" chama copiar.php?de=contrato.pdf&para=cliente-42/contrato.pdf. Funciona. Até alguém trocar a URL para de=../../config/banco.php&para=../public/banco.txt e baixar a senha do banco pelo navegador. A resposta curta: a origem não é um caminho que vem da requisição, é uma chave que o código traduz para um arquivo conhecido; o destino tem pasta e nome gerados pelo sistema; e a cópia vai primeiro para um arquivo temporário e só depois é renomeada para o nome final.

O exemplo continua no sistema de contabilidade: modelos de documento ficam em /var/www/dados/modelos e cada cliente tem sua pasta em /var/www/dados/clientes.

Por que copy() é mais poderoso do que parece

A função copy($origem, $destino) lê qualquer coisa que o processo do PHP consiga ler e grava onde ele consiga gravar. Ela não sabe nada sobre a sua pasta de modelos. Se a origem tiver .., sobe de nível. Se o destino já existir, é sobrescrito sem aviso. E como o PHP trata URLs e wrappers como arquivos, uma origem do tipo http://... ou php://filter/... também funciona, dependendo da configuração, o que transforma a cópia num jeito de fazer o servidor buscar endereços internos.

No destino, o risco é igual ou pior. Se a pessoa escolhe o nome final, ela pode escolher .php dentro da pasta pública, e o arquivo copiado vira código executável no seu servidor.

Como copiar com origem e destino controlados

  1. Receba uma chave curta (como contrato) e traduza por um array no código, ou pelo id de uma tabela.
  2. Resolva a origem com realpath e confira que ela está dentro da pasta de modelos e é um arquivo comum.
  3. Monte o destino a partir do id do cliente e de um nome aleatório, com extensão de uma lista permitida.
  4. Copie para um nome temporário na mesma pasta do destino.
  5. Confira a cópia comparando o hash dos dois arquivos.
  6. Renomeie o temporário para o nome final e registre a operação no banco.
<?php
const RAIZ_MODELOS  = '/var/www/dados/modelos';
const RAIZ_CLIENTES = '/var/www/dados/clientes';

const MODELOS = [
    'contrato' => 'contrato-padrao-2026.pdf',
    'proposta' => 'proposta-comercial.pdf',
    'checklist' => 'checklist-abertura.xlsx',
];

function copiarModeloParaCliente(string $chave, int $clienteId): string
{
    if (!isset(MODELOS[$chave])) {
        throw new InvalidArgumentException('Modelo desconhecido.');
    }

    $raizModelos = realpath(RAIZ_MODELOS);
    $origem = realpath($raizModelos . '/' . MODELOS[$chave]);
    if ($origem === false || !str_starts_with($origem, $raizModelos . '/') || !is_file($origem)) {
        throw new RuntimeException('Arquivo de modelo ausente.');
    }

    $extensao = strtolower(pathinfo($origem, PATHINFO_EXTENSION));
    if (!in_array($extensao, ['pdf', 'xlsx', 'docx'], true)) {
        throw new RuntimeException('Extensão não permitida.');
    }

    $pasta = realpath(RAIZ_CLIENTES . '/cliente-' . $clienteId);
    if ($pasta === false || !str_starts_with($pasta, realpath(RAIZ_CLIENTES) . '/')) {
        throw new RuntimeException('Pasta do cliente inexistente.');
    }

    $destino = $pasta . '/' . bin2hex(random_bytes(8)) . '.' . $extensao;
    $temporario = $destino . '.parcial';

    if (!copy($origem, $temporario)) {
        throw new RuntimeException('Falha ao copiar.');
    }
    if (hash_file('sha256', $temporario) !== hash_file('sha256', $origem)) {
        unlink($temporario);
        throw new RuntimeException('A cópia não confere com a origem.');
    }
    if (!rename($temporario, $destino)) {
        unlink($temporario);
        throw new RuntimeException('Falha ao finalizar a cópia.');
    }

    return $destino;
}

A requisição agora só manda ?modelo=contrato. Qualquer outro valor cai no isset e para ali. Mesmo assim, a origem passa pelo realpath: se alguém trocar o arquivo de modelo por um link simbólico apontando para fora, a comparação com a raiz pega. E como realpath devolve false para URLs e wrappers, a porta do http:// também fica fechada.

Por que copiar para um temporário e renomear

Uma cópia de arquivo grande leva tempo. Se outra parte do sistema, como a listagem de documentos do cliente, enxergar o arquivo no meio da cópia, mostra um PDF pela metade. Pior: se o disco encher, sobra um arquivo truncado com nome de definitivo. Gravando com sufixo .parcial e renomeando no fim, o nome final só aparece quando o conteúdo está completo. Num mesmo sistema de arquivos Linux, o rename é atômico: quem lista a pasta vê o arquivo antigo ou o novo, nunca um estado intermediário. Por isso o temporário fica na mesma pasta do destino, e não em /tmp, que pode estar em outro disco.

E se o destino já existir

Com nome aleatório de 16 caracteres hexadecimais, colisão é praticamente impossível, e o copy não vai sobrescrever o trabalho de ninguém. Se a regra do negócio exigir nome legível, como "contrato.pdf", decida explicitamente: recusar com mensagem, ou versionar com sufixo (contrato-2.pdf). O nome que a pessoa vê pode morar no banco, ao lado do nome real em disco, e ser usado só no Content-Disposition do download.

Copiar um upload é outra função

Se o arquivo veio de um formulário, não use copy a partir de $_FILES['arquivo']['tmp_name']. Use move_uploaded_file, que confere se aquele caminho é mesmo um upload feito nesta requisição e recusa qualquer outro. Todo o tratamento do envio, incluindo tipo real do arquivo e tamanho, está em upload de arquivo em PHP. Para criar a pasta de destino com segurança, veja criar pasta em PHP sem deixar o usuário escolher o caminho.

Falha que aparece

O copy devolve false e emite um aviso quando falta permissão ou espaço. Se o código ignora o retorno, a tela diz "documento copiado" e o cliente abre uma pasta vazia. No exemplo, cada falha vira exceção; quem chama decide a mensagem para a pessoa ("não foi possível copiar o modelo, tente de novo") e grava o detalhe técnico no log. Disco cheio também merece alerta próprio, porque não se resolve tentando outra vez.

Erros comuns

  • Aceitar caminho de origem pela URL. A pessoa lê qualquer arquivo que o PHP alcança, inclusive configuração com senha.
  • Deixar o usuário escolher nome e extensão do destino. Um .php copiado para a pasta pública vira código executável.
  • Confiar que copy avisa antes de sobrescrever. Ele não avisa; o arquivo antigo some sem rastro.
  • Copiar direto para o nome final. Leitores enxergam o arquivo incompleto, e uma falha deixa lixo com cara de documento válido.
  • Usar copy no arquivo de upload. Perde a verificação que move_uploaded_file faz sobre a origem.
  • Ignorar o retorno. A interface mostra sucesso e o arquivo não existe.

Checklist

  • A origem é escolhida por chave ou id, nunca por caminho vindo da requisição.
  • Origem e pasta de destino passam por realpath e ficam dentro das raízes previstas.
  • O nome final é gerado pelo sistema, com extensão de uma lista permitida.
  • Nenhum destino fica dentro da pasta pública do site.
  • A cópia vai para um temporário na mesma pasta e é renomeada no fim.
  • O hash da cópia é comparado com o da origem em arquivos importantes.
  • Toda falha de copy ou rename vira erro tratado e linha no log.

Procure no seu projeto todas as chamadas de copy(, rename( e file_put_contents(. Em quantas delas algum pedaço do caminho vem de $_GET, $_POST ou de um nome de arquivo enviado pelo usuário?

Avalie este artigo

Referência: http://www.scriptbrasil.com.br/forum/topic/29277-copiar-arquivos-via-php/