Upload de arquivos, feito certo da primeira vez
Nunca deixe um arquivo passar pelo seu servidor. Assine, envie direto, verifique depois e trate todo nome de arquivo como hostil.
Upload de arquivo é a feature que todo produto precisa e quase todo produto erra da primeira vez. Sei porque já auditei a segunda: o servidor que ficou sem memória com um PDF grande, o bucket com leitura pública em documentos médicos, o nome de arquivo com path traversal, a imagem que na verdade era um executável.
O design correto é o mesmo há uma década, e começa com uma regra: o arquivo nunca toca o seu servidor de aplicação.
Envie direto para o storage
O cliente pede ao seu servidor permissão para enviar. Seu servidor checa a sessão, decide se este usuário pode enviar este tipo de arquivo deste tamanho, e retorna uma URL pré-assinada do provedor de object storage, válida por alguns minutos, presa a uma chave que o seu servidor escolheu e a um limite de tamanho. O navegador então envia direto para o storage. Seu servidor nunca vê os bytes.
Isso resolve o problema de memória, porque nenhum processo Node.js bufferiza um gigabyte. Resolve o problema de timeout, porque uma Server Action com o limite padrão de um megabyte de corpo nunca ia carregar um vídeo mesmo. E resolve o problema de escala, porque provedores de storage são feitos para absorver uploads e a sua camada web não é.
A chave é gerada pelo seu servidor: um identificador aleatório, nunca o nome de arquivo do usuário. O nome original é guardado como metadado, escapado, só para exibição. Nome de arquivo é entrada do usuário, e já vi pontos e barras dentro dele fazerem coisas que ninguém pretendia.
Registre primeiro, verifique depois
Antes de retornar a URL pré-assinada, seu servidor grava uma linha no banco para o upload em estado pendente, com a chave, o dono, o tipo e o tamanho declarados e um timestamp. O upload não é real até um segundo passo confirmar.
Esse segundo passo roda depois que o cliente reporta a conclusão, ou depois que uma notificação de evento do storage chega, e roda de forma assíncrona. Ele lê os primeiros bytes do objeto e confere o magic number contra o tipo declarado, porque a extensão e o header de content-type são ambos afirmações que o cliente fez. Confere o tamanho real. Roda uma verificação de malware se o produto lida com documentos de desconhecidos. Depois muda a linha para pronto, ou apaga o objeto e marca a linha como rejeitada.
Até a linha dizer pronto, nada no produto referencia o arquivo. Essa única regra evita a classe inteira de bugs em que um arquivo meio enviado ou malicioso aparece em uma lista porque o cliente disse que terminou.
Sirva por uma porta que você controla
O bucket é privado. Sempre. Um arquivo é servido ou por uma URL assinada de download de vida curta, que o seu servidor emite depois de uma checagem de autorização, ou por uma rota que o transmite com os headers certos. Buckets públicos são como documentos privados acabam indexados por um buscador.
Toda resposta carrega um header Content-Disposition que define o nome do download como algo que você escolheu, e um content type que você verificou em vez do que o cliente declarou. Para HTML ou SVG enviados por usuários, o conteúdo é servido de uma origem separada ou forçado a download, porque um arquivo que renderiza no navegador sob o seu domínio é um vetor de cross-site scripting.
Imagens são processadas em formatos e tamanhos seus por um worker, e o produto serve as versões processadas. O original é guardado para reprocessamento e nunca é servido diretamente.
Os detalhes que mordem depois
Objetos órfãos são o vazamento lento. Um usuário pede um upload, fecha a aba, e a linha pendente e a chave vazia ficam ali para sempre. Uma regra de ciclo de vida no bucket que apaga objetos sem linha confirmada depois de um dia mantém a conta honesta.
Arquivos grandes usam multipart upload, que permite ao navegador retomar depois de uma conexão cair em vez de recomeçar. Acima de cem megabytes, essa é a diferença entre uma feature que funciona no celular e uma que não funciona.
Cotas são impostas na etapa de permissão, antes de a URL ser emitida, por usuário e por tenant. Impor depois do upload significa pagar por armazenamento que você está prestes a rejeitar.
Nada disso é difícil. É uma sequência de decisões que precisam ser tomadas na ordem certa, e a ordem errada é a que parece mais simples no primeiro dia: aceita o arquivo, salva, resolve o resto depois. Depois é a auditoria.