Programação Por Equipe DevBras

Capa do artigo: Upload que some no deploy: guarde o arquivo no blob, não na pasta do site

Upload que some no deploy: guarde o arquivo no blob, não na pasta do site

Mova os uploads do disco da aplicação para um armazenamento de objetos, com chave gerada por você, metadados no banco e download que confere o dono.

Terça, 10h: o cliente reclama que os contratos em PDF enviados ontem sumiram. Na segunda à noite houve deploy. A pasta wwwroot/uploads morava dentro do container, e o container novo nasceu vazio. A resposta curta: arquivo de usuário não pode morar no disco da aplicação. Envie para um armazenamento de objetos (Azure Blob Storage, Amazon S3 ou equivalente) com um nome que você gera, guarde a chave e o dono no banco e entregue o download só depois de conferir quem está pedindo.

Isso resolve três problemas de uma vez: o deploy deixa de apagar dados, duas instâncias da aplicação passam a enxergar os mesmos arquivos e o disco do servidor para de encher com PDF de cliente.

Por que a pasta local perde o arquivo

A pasta do site faz parte do ciclo de vida da aplicação. Em container, cada deploy cria um sistema de arquivos novo a partir da imagem, e o que foi gravado em tempo de execução vai embora junto com o container antigo. Em serviços gerenciados de aplicação, o disco local costuma ser temporário pelo mesmo motivo. Mesmo numa VM tradicional, um script de deploy que apaga e recria a pasta publicada leva os uploads junto.

Escalar piora. Com duas instâncias atrás de um balanceador, o arquivo enviado para a instância A não existe na instância B. Metade dos downloads dá 404, dependendo de para onde o balanceador mandou a requisição. Ninguém consegue reproduzir com consistência, e o bug parece aleatório.

O armazenamento de objetos separa as duas coisas. A aplicação é descartável e o arquivo é durável. O serviço cuida de redundância, e você define retenção e backup como política, não como um zip esquecido na área de trabalho.

O contrato entre aplicação, banco e blob

Antes do código, combine quem guarda o quê. O blob guarda os bytes. O banco guarda a chave do blob, o nome original, o tamanho, o tipo, o dono e a data. A aplicação é a única porta de entrada e de saída.

  1. Receba o arquivo e confira tamanho e tipo pelo conteúdo, não pela extensão.
  2. Gere uma chave sua, como 2026/10/3f9c...pdf. Nunca use o nome que a pessoa enviou como caminho.
  3. Envie para um container privado, sem acesso público.
  4. Grave no banco a chave, o nome original e o dono.
  5. No download, busque pelo id e pelo dono juntos. Se não achar, responda 404.
  6. Transmita o conteúdo pela aplicação ou gere um link assinado que vence em poucos minutos.

Exemplo com ASP.NET Core e Azure Blob Storage

O exemplo usa o pacote Azure.Storage.Blobs. Em desenvolvimento, o emulador Azurite aceita a connection string UseDevelopmentStorage=true, então você testa sem conta na nuvem. Em produção, a connection string vem de variável de ambiente ou cofre de segredos, nunca do repositório.

// Program.cs
builder.Services.AddSingleton(_ =>
    new BlobServiceClient(builder.Configuration.GetConnectionString("Blob")));

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var blobs = scope.ServiceProvider.GetRequiredService<BlobServiceClient>();
    await blobs.GetBlobContainerClient("documentos").CreateIfNotExistsAsync();
}

O CreateIfNotExistsAsync sem parâmetro de acesso cria o container privado. É o que você quer: ninguém baixa nada direto pela URL do blob.

[ApiController]
[Route("documentos")]
[Authorize]
public class DocumentosController : ControllerBase
{
    private const long Limite = 10_000_000;
    private readonly BlobContainerClient _container;
    private readonly AppDb _db;

    public DocumentosController(BlobServiceClient blobs, AppDb db)
    {
        _container = blobs.GetBlobContainerClient("documentos");
        _db = db;
    }

    [HttpPost]
    [RequestSizeLimit(Limite)]
    public async Task<IActionResult> Enviar(IFormFile arquivo, CancellationToken ct)
    {
        if (arquivo is null || arquivo.Length == 0 || arquivo.Length > Limite)
            return BadRequest("Envie um PDF de até 10 MB.");

        await using var entrada = arquivo.OpenReadStream();
        var cabecalho = new byte[5];
        if (await entrada.ReadAsync(cabecalho, ct) < 5 ||
            Encoding.ASCII.GetString(cabecalho) != "%PDF-")
            return BadRequest("O arquivo não é um PDF.");
        entrada.Position = 0;

        var chave = $"{DateTime.UtcNow:yyyy/MM}/{Guid.NewGuid():N}.pdf";
        await _container.GetBlobClient(chave).UploadAsync(entrada, new BlobUploadOptions
        {
            HttpHeaders = new BlobHttpHeaders { ContentType = "application/pdf" }
        }, ct);

        var doc = new Documento
        {
            Chave = chave,
            NomeOriginal = Path.GetFileName(arquivo.FileName),
            DonoId = User.FindFirstValue(ClaimTypes.NameIdentifier)!,
            Tamanho = arquivo.Length,
            CriadoEm = DateTime.UtcNow
        };
        _db.Documentos.Add(doc);
        await _db.SaveChangesAsync(ct);
        return Ok(new { doc.Id });
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> Baixar(int id, CancellationToken ct)
    {
        var dono = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var doc = await _db.Documentos
            .FirstOrDefaultAsync(d => d.Id == id && d.DonoId == dono, ct);
        if (doc is null) return NotFound();

        var resposta = await _container.GetBlobClient(doc.Chave)
            .DownloadStreamingAsync(cancellationToken: ct);
        return File(resposta.Value.Content, "application/pdf", doc.NomeOriginal);
    }
}

Repare em três detalhes. A checagem do tipo lê os primeiros bytes do arquivo (todo PDF começa com %PDF-), como explica MIME type: a extensão não diz o que tem dentro. O nome original só aparece no download, como sugestão de nome para o navegador. E a consulta de download filtra por id e dono na mesma condição, então pedir o id de outra pessoa dá 404, igual a um id inexistente.

Transmitir pela aplicação ou entregar link assinado

Transmitir pela aplicação, como no exemplo, é simples e mantém todo controle no seu código, mas cada download passa pelo seu servidor. Para arquivos grandes ou muito acessados, o caminho é um link assinado de curta duração: SAS no Azure, URL pré-assinada no S3. A aplicação confere o dono e devolve um endereço que vale por poucos minutos. Link público permanente para documento de cliente é o erro que blob storage não fica público porque o upload funcionou detalha.

Migrar os arquivos que já estão na pasta

Se o sistema já tem uploads no disco, faça a migração em duas etapas. Primeiro, todo upload novo vai para o blob, e o download procura no blob e, se não achar, na pasta antiga. Depois, um script copia os arquivos antigos, grava a chave no banco e confere a contagem. Só quando a contagem bater você remove a leitura da pasta e apaga os arquivos locais.

No caso dos contratos, a migração levou uma tarde: 3.200 arquivos, 4,1 GB, um script rodando por lote de 200 com log de cada chave copiada. O deploy seguinte não apagou nada, porque não havia mais nada para apagar.

Erros comuns

  • Usar o nome original como caminho: espaço, acento e ../ viram problema de segurança e de URL, e dois arquivos com o mesmo nome se sobrescrevem.
  • Container com acesso público "só para facilitar": qualquer pessoa que adivinhar ou receber a URL baixa o documento, sem login.
  • Gravar no blob e na pasta "por segurança": em pouco tempo ninguém sabe qual cópia é a verdadeira, e o disco enche do mesmo jeito.
  • Conferir só a extensão: um executável renomeado para .pdf passa e fica servido pelo seu domínio.
  • Connection string do storage no appsettings versionado: quem tem acesso ao repositório tem acesso a todos os arquivos dos clientes.
  • Apagar o registro no banco e esquecer o blob: o arquivo fica órfão, pago todo mês, com dado pessoal que você disse ter excluído.

Checklist

  • Nenhum upload é gravado dentro da pasta publicada da aplicação.
  • O container de documentos é privado.
  • A chave do blob é gerada pela aplicação, sem o nome original.
  • O banco guarda chave, nome original, dono, tamanho e data.
  • O download filtra por id e dono, e devolve 404 para os dois casos de falha.
  • Links diretos, quando existem, vencem em minutos.
  • A exclusão remove o registro e o blob.
  • Um teste sobe um arquivo, faz deploy e baixa o mesmo arquivo depois.

Suba um arquivo no seu sistema, faça um deploy e tente baixá-lo de novo. Ele ainda está lá, e quem mais, além do dono, conseguiria montar a URL dele?

Avalie este artigo

Referência: https://medium.com/volosoft/file-upload-download-with-blob-storage-system-in-asp-net-core-abp-framework-e