Programação Por Equipe DevBras

Capa do artigo: Baixar imagem em Java com timeout, limite de tamanho e verificação do arquivo

Baixar imagem em Java com timeout, limite de tamanho e verificação do arquivo

Baixe imagens em Java sem congelar a interface: tempo limite de conexão e leitura, teto de bytes, assinatura do arquivo e gravação atômica que não estraga a foto antiga.

O catálogo desktop da distribuidora baixa a foto de cada produto direto do site do fornecedor. Num dia normal, funciona. Num dia ruim, um servidor responde devagar, a janela congela por dois minutos e, quando volta, o arquivo cafe-500g.jpg é na verdade uma página HTML de erro. A resposta curta: baixar imagem é uma conversa HTTP com prazo e com desconfiança. Defina tempo limite de conexão e de leitura, limite o tamanho, confira os primeiros bytes do arquivo e grave num temporário que só substitui o final quando tudo deu certo. E nunca faça isso na thread da interface.

O código deste guia usa só a biblioteca padrão do Java e cabe numa classe. Ele serve para um aplicativo desktop e, com os cuidados extras do fim, para um servidor.

Por que a linha única dá problema

O exemplo clássico abre a URL e copia o stream para um arquivo. Ele não diz quanto tempo esperar, então um servidor que aceita a conexão e não manda nada segura a thread indefinidamente. Ele não olha o status HTTP, então uma página 404 vira um ".jpg". E ele não limita nada, então um endereço que devolve 2 GB ocupa disco ou memória até acabar.

Há ainda a extensão. O link pode terminar em .png e devolver uma página de login, porque o fornecedor passou a exigir autenticação. A extensão é parte do nome, não prova do conteúdo. O que identifica o formato são os primeiros bytes: PNG começa com 89 50 4E 47, JPEG com FF D8 FF e WebP com RIFF seguido de WEBP a partir do byte 8.

Como baixar com segurança passo a passo

  1. Aceite só https e transforme a string em URI, para que um endereço malformado falhe cedo.
  2. Defina tempo limite de conexão e de leitura.
  3. Recuse status diferente de 200 e tipo de conteúdo que não comece com image/.
  4. Se o servidor declarar o tamanho, recuse acima do teto antes de ler.
  5. Leia em blocos, contando bytes, e pare ao passar do teto.
  6. Confira a assinatura dos primeiros bytes.
  7. Grave num arquivo temporário na mesma pasta e só então mova para o nome final.

A classe completa

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;

public final class BaixadorDeImagem {

    private static final int MAX_BYTES = 5 * 1024 * 1024;

    public static Path baixar(String endereco, Path destino) throws IOException {
        URI uri = URI.create(endereco);
        if (!"https".equalsIgnoreCase(uri.getScheme())) {
            throw new IOException("Só aceito https: " + endereco);
        }

        HttpURLConnection con = (HttpURLConnection) uri.toURL().openConnection();
        con.setConnectTimeout(5_000);
        con.setReadTimeout(10_000);
        con.setRequestProperty("Accept", "image/png,image/jpeg,image/webp");

        try {
            int status = con.getResponseCode();
            if (status != 200) {
                throw new IOException("HTTP " + status + " em " + endereco);
            }
            String tipo = con.getContentType();
            if (tipo == null || !tipo.toLowerCase().startsWith("image/")) {
                throw new IOException("Conteúdo não é imagem: " + tipo);
            }
            long declarado = con.getContentLengthLong();
            if (declarado > MAX_BYTES) {
                throw new IOException("Imagem grande demais: " + declarado + " bytes");
            }

            Path pasta = destino.toAbsolutePath().getParent();
            Path temp = Files.createTempFile(pasta, "img-", ".part");
            try (InputStream in = con.getInputStream();
                 OutputStream out = Files.newOutputStream(temp)) {
                byte[] buf = new byte[8192];
                byte[] cabecalho = new byte[12];
                int lidos = 0;
                int n;
                while ((n = in.read(buf)) != -1) {
                    if (lidos < 12) {
                        System.arraycopy(buf, 0, cabecalho, lidos, Math.min(n, 12 - lidos));
                    }
                    lidos += n;
                    if (lidos > MAX_BYTES) {
                        throw new IOException("Passou de " + MAX_BYTES + " bytes");
                    }
                    out.write(buf, 0, n);
                }
                if (!assinaturaValida(cabecalho)) {
                    throw new IOException("Os bytes não são PNG, JPEG nem WebP");
                }
            } catch (IOException e) {
                Files.deleteIfExists(temp);
                throw e;
            }
            return Files.move(temp, destino,
                    StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
        } finally {
            con.disconnect();
        }
    }

    static boolean assinaturaValida(byte[] h) {
        boolean png = (h[0] & 0xFF) == 0x89 && h[1] == 'P' && h[2] == 'N' && h[3] == 'G';
        boolean jpeg = (h[0] & 0xFF) == 0xFF && (h[1] & 0xFF) == 0xD8 && (h[2] & 0xFF) == 0xFF;
        boolean webp = h[0] == 'R' && h[1] == 'I' && h[2] == 'F' && h[3] == 'F'
                && h[8] == 'W' && h[9] == 'E' && h[10] == 'B' && h[11] == 'P';
        return png || jpeg || webp;
    }
}

Por que HttpURLConnection e não HttpClient

O HttpClient do Java 11 é a API mais moderna e é a escolha certa para a maioria das chamadas. Mas o tempo limite da requisição dele cobre a espera pela resposta, não cada leitura do corpo. Aqui, o risco é o servidor que começa a responder e para no meio. O setReadTimeout do HttpURLConnection resolve exatamente isso: se nenhuma leitura recebe dados em 10 segundos, ela lança SocketTimeoutException, que é um IOException e cai no mesmo tratamento.

Gravação que não estraga a foto boa

O temporário é criado na mesma pasta do destino de propósito. Mover dentro do mesmo sistema de arquivos é uma renomeação, e com ATOMIC_MOVE o arquivo final ou é o antigo inteiro ou é o novo inteiro. Se a rede cair no meio, o cafe-500g.jpg que já estava lá continua intacto, e o .part é apagado no catch.

Como chamar sem congelar a janela

Num aplicativo Swing, a thread de eventos desenha a tela. Se ela espera a rede, a janela para de responder. Rode o download em outra thread e volte para a thread de eventos só para atualizar a interface:

CompletableFuture
    .supplyAsync(() -> {
        try {
            return BaixadorDeImagem.baixar(url, destino);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    })
    .whenComplete((caminho, erro) -> SwingUtilities.invokeLater(() -> {
        if (erro != null) {
            status.setText("Não consegui baixar a foto: " + erro.getCause().getMessage());
        } else {
            mostrarFoto(caminho);
        }
    }));

Em JavaFX, o equivalente de SwingUtilities.invokeLater é Platform.runLater. Se várias telas pedem a mesma imagem, guarde o arquivo baixado com o endereço e a data, e só baixe de novo quando o cache vencer.

Quando quem baixa é o servidor

Se o seu servidor baixa uma URL informada pelo usuário, o risco muda: alguém pode passar o endereço de um serviço interno ou do serviço de metadados da nuvem, e o seu servidor vai buscar. Antes de conectar, resolva o host com InetAddress.getAllByName e recuse endereços em que isLoopbackAddress, isSiteLocalAddress, isLinkLocalAddress ou isAnyLocalAddress sejam verdadeiros. Desligue o redirecionamento automático com setInstanceFollowRedirects(false) e valide cada salto, porque um redirecionamento pode apontar para a rede interna.

Para guardar a imagem, o mais seguro é decodificá-la e gravá-la de novo, descartando o arquivo original, como em arquivo enviado vira imagem de novo. Lembre que o ImageIO padrão do JDK lê PNG e JPEG, mas não WebP sem biblioteca extra. Para gerar miniaturas, veja redimensionar imagem no servidor.

Erros comuns

  • Não definir tempo limite. Um servidor lento segura a thread para sempre e, se for a da interface, o programa parece travado.
  • Confiar na extensão da URL. Uma página de login é salva como ".jpg" e a tela mostra imagem quebrada.
  • Ler tudo para a memória sem teto. Um endereço que devolve um arquivo enorme derruba o programa com falta de memória.
  • Gravar direto no nome final. Um download cortado substitui a foto boa por meio arquivo.
  • Ignorar o status HTTP. Respostas 403 e 404 viram arquivos de imagem inválidos no disco.
  • Baixar URL de usuário no servidor sem filtrar endereços internos. O seu servidor vira ponte para a rede privada.

Checklist

  • Há tempo limite de conexão e de leitura em todo download.
  • Status diferente de 200 e tipo diferente de image/ são recusados.
  • Existe um teto de bytes verificado durante a leitura.
  • Os primeiros bytes são conferidos contra as assinaturas aceitas.
  • O arquivo é gravado em temporário e movido de forma atômica.
  • Nenhum download roda na thread da interface.
  • No servidor, endereços internos e redirecionamentos são validados.

Para o princípio geral de prazos em chamadas externas, veja timeout antes do infinito. E faça o teste que ninguém faz: desligue a rede no meio de um download do seu programa. Ele mostra uma frase de erro e mantém a foto anterior, ou congela e deixa um arquivo pela metade?

Avalie este artigo

Referência: https://pt.stackoverflow.com/questions/2673/como-pegar-imagens-na-internet-atraves-do-java