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.
- Use um
input type="file"de verdade, commultipleeaccept, 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. - Copie os arquivos escolhidos para um array seu. A lista
input.filesnão deixa remover um item, e cada nova seleção a substitui. - Valide cada arquivo ao entrar na fila: tipo e tamanho. O inválido aparece marcado como recusado, com o motivo.
- Mostre nome, tamanho e estado de cada item, com botão para remover.
- Mostre o total e desabilite o envio se passar do limite do anúncio.
- Envie um arquivo por requisição, atualizando o estado de cada um.
- 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.
Referência: http://webzoon.blogspot.com.br/2012/09/upload-de-arquivos-multiplos-usando.html