Barra de upload em 100% não é sucesso: mostre o erro
Barra de upload chegou a 100% e o arquivo sumiu? Mostre o status de cada arquivo, leia a resposta do servidor PHP e liste no fim o que falhou e por quê.
A dona do Ateliê Lume sobe cinco fotos de um colar novo, vê as cinco barras chegarem a 100% e fecha a aba. No dia seguinte, o produto aparece com duas fotos. As outras três passaram de 5 MB e o PHP recusou, mas a tela nunca contou isso. A barra mede bytes saindo do navegador, não arquivo aceito pelo servidor.
A resposta curta: trate o upload em três estados visíveis (enviando, processando, resultado) e só escreva "salvo" depois de ler a resposta do servidor para aquele arquivo. Se a resposta for erro, a linha daquele arquivo mostra o erro em texto, e a pessoa sabe exatamente o que refazer.
Este guia trata do envio de vários arquivos para um backend PHP: uma linha de status por arquivo, as respostas de erro do servidor e o resumo no final. Se o seu caso é um arquivo grande só, com a espera depois do 100% e o risco de envio duplicado, o guia certo é barra de progresso de upload que não mente no 100%.
Por que a barra chega a 100% e o arquivo não existe
O evento de progresso do upload é disparado pelo navegador enquanto ele empurra o corpo da requisição pela rede. Quando o último byte sai, a barra marca 100%. Nesse momento o servidor ainda nem começou a decidir nada: o PHP precisa terminar de receber, conferir os limites, gravar o arquivo temporário e entregar para o seu script, que ainda vai validar tipo e tamanho e mover para o destino final.
Cada uma dessas etapas pode falhar depois da barra cheia:
- Limite do PHP:
upload_max_filesizerecusa o arquivo individual;post_max_sizerecusa a requisição inteira, e aí$_FILESe$_POSTchegam vazios. - Limite do servidor web: o Nginx corta pelo
client_max_body_size(1 MB por padrão) e responde 413 antes do PHP ver qualquer coisa. - Regra sua: tipo não permitido, imagem corrompida, dimensão errada.
- Infraestrutura: disco cheio, permissão errada na pasta, timeout no proxy.
Muitos plugins antigos de upload disparavam o "concluído" no fim do envio e tratavam qualquer resposta como sucesso. Esse padrão sobreviveu em muito código copiado. Aqui o foco é o que vem depois do percentual: a resposta de cada arquivo.
Como separar envio de gravação, passo a passo
- Crie uma linha por arquivo na tela, com nome, barra e um texto de status. Se a pessoa precisa revisar e remover arquivos antes de enviar, monte a fila como em upload de vários arquivos com fila.
- Durante o envio, a barra avança e o status diz "enviando".
- Quando o corpo termina de subir, troque o status para "processando no servidor". A barra cheia não é sucesso, é espera.
- Quando a resposta chega, leia o código HTTP e o JSON. 2xx com
ok: truevira "salvo"; qualquer outra coisa vira a mensagem de erro que o servidor mandou. - Trate falha de rede e timeout como erro explícito, com opção de tentar de novo só aquele arquivo.
- No fim, mostre um resumo: "3 de 5 salvos" e a lista dos que falharam com o motivo.
O lado do navegador
O fetch não tem evento de progresso de upload, então o XMLHttpRequest continua sendo a ferramenta certa para a barra. O ponto importante é o evento load do xhr.upload: ele marca o fim do envio, não o fim do processamento. A resposta do servidor chega no evento load do próprio xhr.
function enviarArquivo(arquivo, linha) {
const barra = linha.querySelector('progress');
const status = linha.querySelector('.status');
return new Promise(function (resolve) {
const xhr = new XMLHttpRequest();
const dados = new FormData();
dados.append('foto', arquivo);
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
barra.value = Math.round((e.loaded / e.total) * 100);
}
status.textContent = 'Enviando...';
});
xhr.upload.addEventListener('load', function () {
status.textContent = 'Processando no servidor...';
});
xhr.addEventListener('load', function () {
let corpo = null;
try {
corpo = JSON.parse(xhr.responseText);
} catch (e) {
corpo = null;
}
if (xhr.status >= 200 && xhr.status < 300 && corpo && corpo.ok) {
status.textContent = 'Salvo';
resolve({ nome: arquivo.name, ok: true });
} else {
const motivo = (corpo && corpo.erro) || ('Erro ' + xhr.status + ' no servidor');
status.textContent = 'Falhou: ' + motivo;
resolve({ nome: arquivo.name, ok: false, motivo: motivo });
}
});
xhr.addEventListener('error', function () {
status.textContent = 'Falhou: sem conexão com o servidor';
resolve({ nome: arquivo.name, ok: false, motivo: 'rede' });
});
xhr.addEventListener('timeout', function () {
status.textContent = 'Falhou: o servidor demorou demais';
resolve({ nome: arquivo.name, ok: false, motivo: 'timeout' });
});
xhr.open('POST', '/upload.php');
xhr.timeout = 120000;
xhr.send(dados);
});
}
Repare que o texto de status usa textContent, nunca innerHTML: a mensagem pode conter o nome do arquivo, e nome de arquivo é entrada do usuário. A função sempre resolve com um objeto de resultado, o que facilita montar o resumo final com Promise.all ou com um laço sequencial.
O lado do servidor
O servidor precisa responder algo que a tela consiga ler, com código HTTP coerente. O detalhe que mais engana: quando a requisição passa do post_max_size, o PHP descarta tudo e o script recebe $_FILES vazio, como se nada tivesse sido enviado.
<?php
header('Content-Type: application/json; charset=utf-8');
function responder(int $codigo, array $corpo): never
{
http_response_code($codigo);
echo json_encode($corpo, JSON_UNESCAPED_UNICODE);
exit;
}
if (empty($_FILES) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
responder(413, ['ok' => false, 'erro' => 'Arquivo maior que o limite do servidor.']);
}
$f = $_FILES['foto'] ?? null;
if ($f === null || $f['error'] !== UPLOAD_ERR_OK) {
$msg = match ($f['error'] ?? null) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Arquivo maior que o limite.',
UPLOAD_ERR_PARTIAL => 'O envio foi interrompido. Tente de novo.',
UPLOAD_ERR_NO_FILE => 'Nenhum arquivo recebido.',
default => 'O servidor não conseguiu receber o arquivo.',
};
responder(400, ['ok' => false, 'erro' => $msg]);
}
if ($f['size'] > 5 * 1024 * 1024) {
responder(413, ['ok' => false, 'erro' => 'A foto passa de 5 MB.']);
}
$tipos = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
if (!isset($tipos[$mime])) {
responder(415, ['ok' => false, 'erro' => 'Envie JPG, PNG ou WebP.']);
}
$nome = bin2hex(random_bytes(16)) . '.' . $tipos[$mime];
$destino = __DIR__ . '/../uploads/' . $nome;
if (!move_uploaded_file($f['tmp_name'], $destino)) {
error_log('upload: falha ao mover para ' . $destino);
responder(500, ['ok' => false, 'erro' => 'Não foi possível salvar. Tente mais tarde.']);
}
responder(201, ['ok' => true, 'arquivo' => $nome]);
Os códigos de $f['error'] estão na lista de erros de upload do manual do PHP. O tipo é conferido pelo conteúdo com finfo, não pela extensão nem pelo type que o navegador declarou. O nome final é gerado pelo servidor. Para o resto da validação, como reprocessar a imagem e onde guardar, veja o guia de upload de arquivo em PHP.
O resumo que fecha a conta
No caso do Ateliê Lume, o resultado final precisa dizer algo como: "2 de 5 fotos salvas. colar-frente.jpg, colar-lado.jpg e colar-fecho.jpg passam de 5 MB." Com isso a pessoa reduz as três e reenvia só elas. Sem isso, ela descobre o problema pelo cliente que reclamou da página do produto.
E o limite que você mostra na tela ("até 5 MB") precisa ser o menor entre o PHP, o servidor web e a sua regra. Se o Nginx corta em 1 MB, a mensagem do seu PHP nunca aparece; quem responde é uma página HTML de 413 que o seu JSON.parse não entende. Por isso o código acima cai no "Erro 413 no servidor" quando não há JSON. O post sobre o limite de upload de verdade mostra como alinhar os três números.
Sem JavaScript, o formulário ainda envia
A barra é melhoria, não requisito. O <input type="file" multiple> dentro de um formulário com enctype="multipart/form-data" envia sem nenhum script. Se o JavaScript falhar ao carregar, a pessoa ainda consegue subir as fotos e recebe uma página de resultado gerada pelo servidor. Comece por esse formulário e só depois intercepte o envio para mostrar progresso.
Erros comuns
- Escrever "concluído" no evento de progresso a 100%: a pessoa sai da página antes de o servidor recusar o arquivo, e o erro nunca é visto.
- Tratar qualquer resposta como sucesso: um 500 com página HTML de erro aparece como "salvo", e o arquivo não existe.
- Uma barra só para vários arquivos: se um de cinco falha, não há onde mostrar qual; a pessoa reenvia tudo ou não reenvia nada.
- Confiar no limite do plugin: o
maxSizeno JavaScript é conforto; quem decide é o servidor, e ele pode ter um limite menor. - Ignorar o
$_FILESvazio: quando passa dopost_max_size, o script responde "nenhum arquivo", e a pessoa acha que o botão está quebrado. - Mostrar o erro com
innerHTML: o nome do arquivo vira vetor de XSS na própria tela de upload.
Checklist
- Cada arquivo tem a sua linha com status em texto, não só cor.
- O fim do envio mostra "processando", e só a resposta do servidor mostra "salvo".
- O servidor responde JSON com código HTTP coerente (201, 400, 413, 415, 500).
- Requisição acima do
post_max_sizedevolve uma mensagem clara, não "nenhum arquivo". - Falha de rede e timeout aparecem como erro com opção de reenviar aquele arquivo.
- O resumo final lista os arquivos que falharam e o motivo de cada um.
- O limite exibido na tela é o menor entre PHP, servidor web e sua regra.
- O formulário funciona sem JavaScript.
Faça o teste agora: suba no seu sistema um arquivo de 30 MB e outro com extensão trocada. A tela diz que deu errado e por quê, ou a barra chega a 100% e fica por isso mesmo?
Referência: https://diegosanches.wordpress.com/2011/01/16/upload-multiplo-com-jquery-e-barra-de-progresso/