O servidor tem cinco responsabilidades: criar a sessão de upload e registrá-la no banco, assinar URLs para partes específicas, informar quais partes já chegaram, concluir conferindo o que está no S3 e abortar quando o usuário desiste. A sessão no banco guarda o UploadId do S3, a chave do objeto, o tamanho declarado e o tamanho de parte calculado, e é isso que permite retomar de outro dispositivo, de outra aba ou depois de dias.
import express from 'express';
import pg from 'pg';
import { randomUUID } from 'node:crypto';
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
ListPartsCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
HeadObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// WHEN_REQUIRED evita que o SDK coloque na URL assinada um checksum calculado
// sobre um corpo vazio, o que faz o S3 recusar o PUT que vem do navegador.
const s3 = new S3Client({ requestChecksumCalculation: 'WHEN_REQUIRED' });
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const BUCKET = process.env.UPLOADS_BUCKET;
const MiB = 1024 * 1024;
const TAMANHO_MAX = 20 * 1024 * MiB; // 20 GiB, regra do produto
const MAX_PARTES = 10000; // limite do S3
const VALIDADE_URL = 15 * 60; // segundos
export function tamanhoDaParte(tamanhoTotal) {
// 8 MiB por padrao; arquivos enormes pedem partes maiores para caber em
// 10.000 partes. O S3 exige no minimo 5 MiB em todas, menos na ultima.
const minimo = Math.ceil(tamanhoTotal / MAX_PARTES);
return Math.max(8 * MiB, Math.ceil(minimo / MiB) * MiB);
}
const app = express(); // Express 5: erros de handlers async chegam ao middleware de erro
app.use(express.json());
// A autenticacao roda antes destas rotas e preenche req.user.
async function carregar(req, res) {
const { rows } = await pool.query(
"SELECT * FROM uploads WHERE id = $1 AND usuario_id = $2 AND status <> 'abortado'",
[req.params.id, req.user.id],
);
if (!rows[0]) {
res.sendStatus(404);
return null;
}
return { ...rows[0], tamanho: Number(rows[0].tamanho) }; // bigint chega como string
}
// A fonte da verdade sobre o que ja foi enviado e o S3, nao o navegador.
async function partesEnviadas(upload) {
const partes = [];
let marcador;
do {
const r = await s3.send(new ListPartsCommand({
Bucket: BUCKET,
Key: upload.chave,
UploadId: upload.upload_id,
PartNumberMarker: marcador,
}));
for (const p of r.Parts ?? []) {
partes.push({ numero: p.PartNumber, etag: p.ETag, tamanho: p.Size });
}
marcador = r.IsTruncated ? r.NextPartNumberMarker : undefined; // 1.000 por pagina
} while (marcador);
return partes;
}
app.post('/uploads', async (req, res) => {
const tamanho = Number(req.body.tamanho);
if (!Number.isSafeInteger(tamanho) || tamanho <= 0 || tamanho > TAMANHO_MAX) {
return res.status(422).json({ erro: 'tamanho_invalido' });
}
// A chave e decidida pelo servidor: o nome do arquivo nunca vira caminho.
const chave = `uploads/${req.user.id}/${randomUUID()}`;
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: BUCKET,
Key: chave,
ContentType: 'application/octet-stream',
Metadata: { 'nome-original': encodeURIComponent(String(req.body.nome).slice(0, 200)) },
}));
const tamanhoParte = tamanhoDaParte(tamanho);
const { rows } = await pool.query(
`INSERT INTO uploads (usuario_id, chave, upload_id, tamanho, tamanho_parte, status)
VALUES ($1, $2, $3, $4, $5, 'enviando') RETURNING id`,
[req.user.id, chave, UploadId, tamanho, tamanhoParte],
);
res.status(201).json({
id: rows[0].id,
tamanhoParte,
totalPartes: Math.ceil(tamanho / tamanhoParte),
});
});
// Assina so as partes pedidas agora, com validade curta.
app.post('/uploads/:id/partes', async (req, res) => {
const upload = await carregar(req, res);
if (!upload) return;
const total = Math.ceil(upload.tamanho / upload.tamanho_parte);
const numeros = [...new Set(req.body.numeros)].slice(0, 50);
if (!numeros.every((n) => Number.isInteger(n) && n >= 1 && n <= total)) {
return res.status(422).json({ erro: 'parte_invalida' });
}
const urls = {};
for (const n of numeros) {
urls[n] = await getSignedUrl(
s3,
new UploadPartCommand({ Bucket: BUCKET, Key: upload.chave, UploadId: upload.upload_id, PartNumber: n }),
{ expiresIn: VALIDADE_URL },
);
}
res.json({ urls });
});
app.get('/uploads/:id/partes', async (req, res) => {
const upload = await carregar(req, res);
if (!upload) return;
if (upload.status === 'concluido') return res.status(409).json({ erro: 'ja_concluido' });
res.json(await partesEnviadas(upload));
});
app.post('/uploads/:id/concluir', async (req, res) => {
const upload = await carregar(req, res);
if (!upload) return;
if (upload.status === 'concluido') return res.json({ chave: upload.chave }); // idempotente
const partes = await partesEnviadas(upload);
const total = Math.ceil(upload.tamanho / upload.tamanho_parte);
const recebido = partes.reduce((soma, p) => soma + p.tamanho, 0);
if (partes.length !== total || recebido !== upload.tamanho) {
return res.status(409).json({ erro: 'partes_incompletas', recebidas: partes.map((p) => p.numero) });
}
await s3.send(new CompleteMultipartUploadCommand({
Bucket: BUCKET,
Key: upload.chave,
UploadId: upload.upload_id,
MultipartUpload: { Parts: partes.map((p) => ({ PartNumber: p.numero, ETag: p.etag })) },
}));
const { ContentLength } = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: upload.chave }));
if (ContentLength !== upload.tamanho) throw new Error(`upload ${upload.id}: tamanho divergente`);
await pool.query("UPDATE uploads SET status = 'concluido', concluido_em = now() WHERE id = $1", [upload.id]);
// Daqui em diante, validacao de conteudo e processamento rodam em um job.
res.json({ chave: upload.chave });
});
app.delete('/uploads/:id', async (req, res) => {
const upload = await carregar(req, res);
if (!upload) return;
await s3.send(new AbortMultipartUploadCommand({
Bucket: BUCKET,
Key: upload.chave,
UploadId: upload.upload_id,
}));
await pool.query("UPDATE uploads SET status = 'abortado' WHERE id = $1", [upload.id]);
res.sendStatus(204);
});
Três decisões desse código importam mais do que parecem. A primeira é que a conclusão usa ListParts e não os ETags enviados pelo navegador. O cliente não é fonte confiável sobre o que foi armazenado, e a conferência de quantidade de partes e soma dos tamanhos garante que o objeto final tem exatamente o tamanho declarado na criação, o que impede que alguém crie uma sessão de 10 MB para passar pela quota e envie 10 GB. A segunda é que a conclusão é idempotente: se a resposta se perder no caminho e o navegador repetir a chamada, a segunda devolve o mesmo resultado em vez de falhar porque o UploadId já não existe.
A terceira é que as URLs são assinadas sob demanda, para poucas partes por vez e com validade de 15 minutos. Assinar todas as 512 partes na criação parece mais eficiente, mas produz URLs que expiram antes de serem usadas em conexões lentas e entrega ao navegador autorização para escrever por horas. A opção requestChecksumCalculation: WHEN_REQUIRED também merece atenção: versões recentes do AWS SDK para JavaScript passaram a incluir parâmetros de checksum nas URLs assinadas de UploadPart, calculados sobre um corpo vazio, e o S3 recusa o PUT do navegador com o conteúdo real. É o tipo de quebra que aparece depois de uma atualização de dependência, sem nenhuma mudança no seu código.