Programação Por Equipe DevBras

Capa do artigo: Barra de upload em 100% não é sucesso: mostre o erro

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_filesize recusa o arquivo individual; post_max_size recusa a requisição inteira, e aí $_FILES e $_POST chegam 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

  1. 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.
  2. Durante o envio, a barra avança e o status diz "enviando".
  3. Quando o corpo termina de subir, troque o status para "processando no servidor". A barra cheia não é sucesso, é espera.
  4. Quando a resposta chega, leia o código HTTP e o JSON. 2xx com ok: true vira "salvo"; qualquer outra coisa vira a mensagem de erro que o servidor mandou.
  5. Trate falha de rede e timeout como erro explícito, com opção de tentar de novo só aquele arquivo.
  6. 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 maxSize no JavaScript é conforto; quem decide é o servidor, e ele pode ter um limite menor.
  • Ignorar o $_FILES vazio: quando passa do post_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_size devolve 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?

Avalie este artigo

Referência: https://diegosanches.wordpress.com/2011/01/16/upload-multiplo-com-jquery-e-barra-de-progresso/