Programação Por Equipe DevBras

Capa do artigo: Upload de vários arquivos com fila: revisar, remover e enviar sem perder tudo

Upload de vários arquivos com fila: revisar, remover e enviar sem perder tudo

Monte uma fila de upload em que a pessoa vê nome e tamanho, remove o que não quer, recebe recusa na hora e, se um arquivo falhar, os outros continuam enviados.

A corretora seleciona 18 fotos do apartamento e clica em enviar. Dois minutos de barra girando e aparece "Erro no envio". Qual foto deu problema? Alguma entrou? Ela não sabe, então seleciona as 18 de novo. Desta vez o erro vem mais rápido, porque uma das fotos tem 14 MB e o servidor descarta a requisição inteira antes de olhar a primeira.

A resposta curta: entre o "escolher arquivos" e o "enviar" precisa existir uma fila visível. Ela mostra nome e tamanho de cada arquivo, recusa na hora o que tem tipo ou tamanho inválido, deixa remover um item e mostra o total. No envio, cada arquivo vai numa requisição própria, com estado individual: enviado, falhou, tentar de novo. O servidor valida tudo outra vez, porque a checagem no navegador é só conforto.

Por que o envio em lote quebra inteiro

Um input com multiple num formulário comum manda todos os arquivos num único POST. Isso esbarra em limites que valem para a requisição inteira. No PHP, o padrão de fábrica é upload_max_filesize de 2 MB por arquivo, post_max_size de 8 MB para o corpo todo e max_file_uploads de 20 arquivos por requisição; servidores web e proxies têm seus próprios tetos de corpo. Os detalhes de como esses números se combinam estão em limite de upload de verdade.

Quando o corpo passa do post_max_size, o PHP descarta os dados: $_POST e $_FILES chegam vazios. O seu código não tem nem a lista de nomes para dizer qual arquivo atrapalhou. Daí o "erro no envio" genérico. E se o lote passa de 20 arquivos, os excedentes são ignorados, sem que a pessoa saiba quais.

A fila resolve os dois lados: a pessoa vê e corrige antes de enviar, e cada arquivo viaja sozinho, de modo que um problema afeta só ele.

Como montar a fila no navegador

O exemplo é o cadastro de fotos de imóveis, aceitando JPEG e PNG de até 5 MB cada e até 40 MB por anúncio.

  1. Use um input type="file" de verdade, com multiple e accept, e um rótulo que diga tipo e tamanho aceitos. Esconder o input sem cuidado quebra o teclado, como explica botão bonito de arquivo não substitui o input.
  2. Copie os arquivos escolhidos para um array seu. A lista input.files não deixa remover um item, e cada nova seleção a substitui.
  3. Valide cada arquivo ao entrar na fila: tipo e tamanho. O inválido aparece marcado como recusado, com o motivo.
  4. Mostre nome, tamanho e estado de cada item, com botão para remover.
  5. Mostre o total e desabilite o envio se passar do limite do anúncio.
  6. Envie um arquivo por requisição, atualizando o estado de cada um.
  7. Ofereça "tentar de novo" só para os que falharam.
<label for="fotos">Fotos do imóvel (JPEG ou PNG, até 5 MB cada, 40 MB no total)</label>
<input type="file" id="fotos" multiple accept="image/jpeg,image/png">
<ul id="fila" aria-live="polite"></ul>
<p id="resumo"></p>
<button type="button" id="enviar" disabled>Enviar</button>
const LIMITE_ARQUIVO = 5 * 1024 * 1024;
const LIMITE_TOTAL = 40 * 1024 * 1024;
const TIPOS = ['image/jpeg', 'image/png'];
const fila = [];
let enviandoLote = false;

const input = document.getElementById('fotos');
const lista = document.getElementById('fila');
const resumo = document.getElementById('resumo');
const botao = document.getElementById('enviar');

input.addEventListener('change', function () {
  for (const arquivo of input.files) {
    let erro = '';
    if (!TIPOS.includes(arquivo.type)) erro = 'tipo não aceito';
    else if (arquivo.size > LIMITE_ARQUIVO) erro = 'maior que 5 MB';
    fila.push({ arquivo: arquivo, erro: erro, estado: erro ? 'recusado' : 'pronto' });
  }
  input.value = '';
  desenhar();
});

function acao(texto, rotulo, aoClicar) {
  const b = document.createElement('button');
  b.type = 'button';
  b.textContent = texto;
  b.setAttribute('aria-label', texto + ' ' + rotulo);
  b.addEventListener('click', aoClicar);
  return b;
}

function desenhar() {
  lista.replaceChildren();
  fila.forEach(function (item, i) {
    const li = document.createElement('li');
    const kb = Math.round(item.arquivo.size / 1024);
    li.textContent = item.arquivo.name + ' (' + kb + ' KB): ' + (item.erro || item.estado) + ' ';
    if (!enviandoLote && (item.estado === 'pronto' || item.estado === 'recusado')) {
      li.appendChild(acao('Remover', item.arquivo.name, function () { fila.splice(i, 1); desenhar(); }));
    }
    if (!enviandoLote && item.estado === 'falhou') {
      li.appendChild(acao('Tentar de novo', item.arquivo.name, function () {
        item.estado = 'pronto';
        item.erro = '';
        desenhar();
      }));
    }
    lista.appendChild(li);
  });

  const prontos = fila.filter(function (f) { return f.estado === 'pronto'; });
  const total = prontos.reduce(function (soma, f) { return soma + f.arquivo.size; }, 0);
  resumo.textContent = prontos.length + ' pronto(s), ' + (total / 1048576).toFixed(1) + ' MB'
    + (total > LIMITE_TOTAL ? '. Passou de 40 MB: remova algum arquivo.' : '');
  botao.disabled = enviandoLote || prontos.length === 0 || total > LIMITE_TOTAL;
  botao.textContent = 'Enviar ' + prontos.length + ' arquivo(s)';
}

botao.addEventListener('click', async function () {
  enviandoLote = true;
  for (const item of fila) {
    if (item.estado !== 'pronto') continue;
    item.estado = 'enviando';
    desenhar();
    const dados = new FormData();
    dados.append('foto', item.arquivo);
    try {
      const resposta = await fetch('/upload.php', { method: 'POST', body: dados });
      if (resposta.ok) {
        item.estado = 'enviado';
      } else {
        item.estado = 'falhou';
        item.erro = 'servidor recusou (' + resposta.status + ')';
      }
    } catch (e) {
      item.estado = 'falhou';
      item.erro = 'sem conexão';
    }
    desenhar();
  }
  enviandoLote = false;
  desenhar();
});

O nome do arquivo entra com textContent, porque nome de arquivo também é texto do usuário e pode conter HTML. Os botões de remover ficam escondidos durante o envio, para ninguém alterar a fila enquanto o laço a percorre. Limpar input.value depois de copiar permite escolher mais fotos, inclusive a mesma de novo, sem perder a fila.

O envio aqui é sequencial, um de cada vez. Para muitos arquivos pequenos, duas ou três requisições simultâneas aceleram; mais que isso costuma disputar banda sem ganho. Se quiser mostrar porcentagem de cada arquivo, veja barra de progresso do upload.

O servidor confere tudo de novo

O arquivo.type do navegador é um palpite baseado na extensão, e qualquer pessoa pode chamar o endpoint sem passar pela sua página. O servidor valida tamanho e tipo real pelo conteúdo, gera o nome e grava fora da pasta pública. Uma versão enxuta em PHP:

<?php
header('Content-Type: application/json');

$f = $_FILES['foto'] ?? null;
if ($f === null || $f['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit(json_encode(['erro' => 'arquivo ausente ou incompleto']));
}
if ($f['size'] > 5 * 1024 * 1024) {
    http_response_code(413);
    exit(json_encode(['erro' => 'maior que 5 MB']));
}

$tipo = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
$extensoes = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (!isset($extensoes[$tipo])) {
    http_response_code(415);
    exit(json_encode(['erro' => 'tipo não aceito']));
}

$nome = bin2hex(random_bytes(16)) . '.' . $extensoes[$tipo];
if (!move_uploaded_file($f['tmp_name'], '/srv/uploads/' . $nome)) {
    http_response_code(500);
    exit(json_encode(['erro' => 'falha ao gravar']));
}

http_response_code(201);
echo json_encode(['arquivo' => $nome]);

O porquê de não confiar na extensão está em tipo de arquivo permitido não é a extensão. Num sistema real, o endpoint também confere se a pessoa está logada e se o anúncio é dela, e conta quantas fotos ele já tem antes de aceitar mais uma.

O fim do envio precisa de um resumo claro

Quando o laço termina, a pessoa quer uma frase só: "16 fotos enviadas, 2 falharam". Coloque esse resumo num elemento com aria-live, para que quem usa leitor de tela também ouça o resultado, e mantenha os itens que falharam visíveis no topo, com o motivo e o botão de nova tentativa. Não limpe a fila automaticamente: a corretora precisa conferir o que entrou antes de sair da página. Se ela tentar fechar a aba com itens ainda enviando, um aviso de confirmação evita perder o lote no meio.

Se a página também aceita arrastar arquivos, a área de soltar alimenta a mesma fila, com as mesmas validações. Os cuidados com essa área estão em soltar arquivo na página.

Erros comuns

  • Todos os arquivos num único POST: um arquivo grande estoura o limite da requisição e nenhum entra, sem indicar qual causou o problema.
  • Sem fila visível: a pessoa não vê o que escolheu e só descobre a foto errada depois do envio.
  • Não permitir remover um item: para tirar uma foto, ela precisa selecionar todas de novo.
  • Validar só no navegador: qualquer chamada direta ao endpoint grava o arquivo que quiser.
  • Recomeçar do zero quando um falha: os que já entraram são enviados de novo e viram duplicatas.
  • Inserir o nome do arquivo com innerHTML: um nome malicioso executa script na página.

Checklist

  • A fila mostra nome, tamanho e estado de cada arquivo antes do envio.
  • Dá para remover qualquer item da fila sem recomeçar a seleção.
  • Tipo e tamanho inválidos são recusados na hora, com o motivo.
  • O total aparece e o envio fica bloqueado acima do limite.
  • Cada arquivo é enviado numa requisição própria.
  • Arquivos que falharam podem ser reenviados sem reenviar os outros.
  • O servidor valida tamanho e tipo real e gera o nome do arquivo.

Abra o seu formulário de upload, escolha cinco arquivos e tente remover o terceiro antes de clicar em enviar. Dá? Se não dá, a fila ainda não existe.

Avalie este artigo

Referência: http://webzoon.blogspot.com.br/2012/09/upload-de-arquivos-multiplos-usando.html