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¶=cliente-42/contrato.pdf. Funciona. Até alguém trocar a URL para de=../../config/banco.php¶=../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
- Receba uma chave curta (como
contrato) e traduza por um array no código, ou pelo id de uma tabela. - Resolva a origem com
realpathe confira que ela está dentro da pasta de modelos e é um arquivo comum. - Monte o destino a partir do id do cliente e de um nome aleatório, com extensão de uma lista permitida.
- Copie para um nome temporário na mesma pasta do destino.
- Confira a cópia comparando o hash dos dois arquivos.
- 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
.phpcopiado para a pasta pública vira código executável. - Confiar que
copyavisa 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
copyno arquivo de upload. Perde a verificação quemove_uploaded_filefaz 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
realpathe 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
copyourenamevira 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?
Referência: http://www.scriptbrasil.com.br/forum/topic/29277-copiar-arquivos-via-php/