Como fazer upload e servir imagens em um app sem S3

Uma página web envia uma foto com um POST, recebe uma URL pública de CDN e a referencia de uma linha com dono. Medimos todos os tamanhos de 10 KB a 50 MB e descobrimos que as permissões da linha protegem a linha, não os bytes.

Tempos de upload medidos em 24 de setembro de 2026 para imagens de 10 KB, 100 KB, 1 MB, 5 MB, 10 MB e 25 MB enviadas com um POST do curl a um backend gerenciado, ao lado das três respostas que a URL pública do arquivo deu: 200 sem headers, 200 depois de a linha ser apagada e 200 do cache da CDN depois de o próprio arquivo ser apagado

Tradução do original em inglês, testado em 24 de setembro de 2026. Código, números e datas são os do original. Read in English

Seu app precisa de uma foto de perfil, ou de uma foto em cada anúncio, e o primeiro resultado da busca diz: crie um bucket, escreva uma política de IAM, gere uma URL pré-assinada num servidor, depois faça o upload do navegador para o bucket e grave a chave de volta no seu banco de dados. Quatro peças móveis para um <input type="file">.

Um backend gerenciado tem um endpoint de arquivos: você faz um POST dos bytes brutos para /files/<nome> com um Content-Type, e a resposta é uma URL pública servida por uma CDN; uma linha no seu banco referencia o arquivo pelo nome. Sem bucket, sem política, sem URL assinada, sem serviço de upload próprio e nada para rodar sem gerenciar um servidor.

Construímos isso como uma página HTML com 120 linhas de JavaScript, mais um script curl que mede tudo o que a página não consegue dizer: quão grande, quão rápido, quem pode fazer upload, quem pode ler e o que acontece com os bytes quando a linha some. Tudo abaixo foi medido em 24 de setembro de 2026, incluindo o arquivo apagado que a CDN continuou servindo.

Pegue o código funcionando: o repositório companion está em github.com/templates-back4app/image-upload-starter — web/, cloud/main.js, upload-check.sh, hook-check.sh e web-check.mjs. Rode o script contra o seu backend e você obtém todas as tabelas deste post com os seus próprios números.

O que é um upload de arquivo num backend gerenciado, e como é a URL?

Um upload de arquivo é uma requisição HTTP: POST /files/<nome> com os bytes do arquivo como corpo, o tipo MIME em Content-Type, os mesmos dois headers de app que toda outra chamada leva, mais o session token do usuário. O backend guarda os bytes num object store atrás de uma CDN e responde 201 com duas coisas, um name e uma url.

Arquitetura: o navegador envia os bytes da imagem com um session token ao endpoint de arquivos do backend, que guarda o arquivo num object store atrás de uma CDN e devolve uma URL pública; o navegador então cria uma linha Photo com um pointer de dono e um campo de arquivo que referencia o nome armazenado; qualquer um com a URL recebe os bytes da CDN, com ou sem permissões na linha

Esta é a troca inteira, de upload-check.sh, com o App ID substituído:

# Stack: bash + curl | File: upload-check.sh (step 1)
curl -s -w '  %{http_code}\n' -H "X-Parse-Application-Id: $APP_ID" -H "X-Parse-JavaScript-Key: $JS_KEY" \
  -H "X-Parse-Session-Token: $SESSION_TOKEN" -H "Content-Type: image/png" \
  -X POST "https://parseapi.back4app.com/files/user.png" --data-binary @user.png
{"url":"https://parsefiles.back4app.com/<APP_ID>/70294a8c55322f2b8fa059066317d0a3_user.png","name":"70294a8c55322f2b8fa059066317d0a3_user.png"}  201

Três coisas a ler nessa linha. O name é o nome do seu arquivo atrás de um hash de 32 caracteres: dois usuários enviando avatar.png nunca colidem, e ninguém adivinha uma URL a partir de um nome.

A url fica num host diferente do da API, parsefiles.back4app.com, e seu primeiro segmento de caminho é o App ID, que é uma chave de cliente e já vai na sua página de qualquer jeito.

E não há expiração, assinatura nem token nela: quem tem a URL tem o arquivo.

A URL é a credencial. Não há mais nada a verificar, então não há mais nada a vazar.

Buscamos um upload de 1 MB sem header nenhum. 200, content-type: image/png, content-length: 999762, server: AmazonS3, via: … cloudfront.net, x-cache: Miss from cloudfront nas duas primeiras requisições, de edge nodes diferentes, e Hit from cloudfront da terceira em diante.

Com um header Origin a resposta acrescentou access-control-allow-origin: *, então fetch() e <canvas> funcionam cross-origin, e uma requisição Range: bytes=0-99 recebeu 206. Nenhum header cache-control, o que importa mais adiante.

Como fazer upload de uma imagem de uma página web em JavaScript puro?

Com fetch, enviando o próprio objeto File como corpo. O navegador define o tamanho; você define o Content-Type a partir de file.type. Não há SDK na página nem codificação de formulário: o corpo são os bytes. Aqui estão o helper e a função de upload de web/app.js:

// Stack: vanilla JavaScript (browser) | File: web/app.js
const BASE = window.BACKEND_URL ?? "https://parseapi.back4app.com";
const HEADERS = {
  "X-Parse-Application-Id": window.APP_ID,
  "X-Parse-JavaScript-Key": window.JS_KEY,      // a client key: identifies the app, not the user
  "X-Parse-Revocable-Session": "1",
};

// One helper for every call. `body` is a plain object (sent as JSON) or a File/Blob (sent as raw bytes with its type).
async function api(method, path, body) {
  const headers = { ...HEADERS };
  const token = session.get();                                    // sessionStorage; uploads without it get code 130
  if (token) headers["X-Parse-Session-Token"] = token;
  let payload;
  if (body instanceof Blob) { headers["Content-Type"] = body.type || "application/octet-stream"; payload = body; }
  else if (body) { headers["Content-Type"] = "application/json"; payload = JSON.stringify(body); }
  const r = await fetch(BASE + path, { method, headers, body: payload });
  const data = await r.json().catch(() => ({}));
  if (!r.ok) throw Object.assign(new Error(data.error ?? r.statusText), { code: data.code, status: r.status });
  return data;
}

// Step 1: the bytes. POST /files/<name> answers 201 {url, name}.
async function uploadFile(file) {
  const safeName = file.name.replace(/[^\w.]+/g, "-");            // the path segment; the served name gets a hash prefix
  return api("POST", `/files/${encodeURIComponent(safeName)}`, file);
}

A página tem um formulário de cadastro/login acima do input de arquivo por um motivo que não planejamos: o primeiro upload que tentamos, só com o App ID e a JavaScript key, foi recusado. 400, código 130, File upload by public is disabled.

Os mesmos bytes com um session token: 201. Com a master key: 201. Então a página faz o login do usuário, guarda o token em sessionStorage e só então mostra o input.

A página do image upload starter antes de qualquer requisição: um formulário Log in first com campos de usuário e senha e os botões Create account e Log in, acima de uma barra de status vazia

O handler de submit envolve as duas chamadas em performance.now() e imprime os dois tempos na barra de status, que é de onde vêm os números de navegador deste post:

// Stack: vanilla JavaScript (browser) | File: web/app.js (upload handler)
$("#upload").addEventListener("submit", async (e) => {
  e.preventDefault();
  const file = e.target.file.files[0];
  const caption = e.target.caption.value.trim();
  if (!file) return;
  try {
    status(`uploading ${file.name} (${fmt(file.size)} bytes)…`);
    const t0 = performance.now();
    const stored = await uploadFile(file);                    // 201 {url, name}
    const t1 = performance.now();
    const photo = await createPhoto(stored, caption, file);   // 201 {objectId, createdAt}
    const t2 = performance.now();
    status(`uploaded ${fmt(file.size)} bytes in ${Math.round(t1 - t0)} ms, row ${photo.objectId} created in ${Math.round(t2 - t1)} ms\n${stored.url}`, "ok");
    e.target.reset(); await listPhotos();
  } catch (err) { status(`${err.code ?? err.status} ${err.message}`, "err"); }
});

Dirigimos a página em Chrome headless com web-check.mjs, que cria a conta, coloca um arquivo real no input pelo protocolo do DevTools e clica no botão, então os tempos são da própria página.

Os números da página acompanham os do curl com menos de um segundo de diferença em todo tamanho: 1,21 s para 10 KB, 1,68 s para 100 KB, 6,84 s para 1 MB, 28,6 s para 5 MB e 54,8 s para 10 MB, com o POST da linha depois entre 0,7 s e 2,2 s. A barra de status imprime os dois e a URL completa.

A página do image upload starter depois dos uploads: a barra de status verde diz uploaded 32,281 bytes in 1351 ms, row IRNqQCosyk created in 1046 ms, seguido da URL do arquivo com o App ID borrado, e a lista Photos abaixo mostra as fotos enviadas com legenda, quem enviou, tamanho, tempo e a URL completa de cada uma

Como vincular o arquivo a uma linha Photo e ao seu dono?

Com um segundo POST, para /classes/Photo, cujo campo image é um valor File: { "__type": "File", "name": "<o name do passo 1>", "url": "<a url do passo 1>" }. O backend resolve o nome para a URL sempre que você lê a linha de volta; a url na requisição está lá porque o backend precisa dela assim que um trigger roda na classe, como medido abaixo.

owner é um pointer para o usuário logado, e a ACL diz quem pode alterar a linha:

// Stack: vanilla JavaScript (browser) | File: web/app.js (the row)
// Step 2: the row. A File field references the stored file by name. Send the url from step 1 as well (see the finding
// in the deletion section: with a trigger on the class, name alone answered 502).
async function createPhoto({ name, url }, caption, file) {
  return api("POST", "/classes/Photo", {
    caption,
    image: { __type: "File", name, url },
    contentType: file.type,
    size: file.size,
    owner: { __type: "Pointer", className: "_User", objectId: me.objectId },
    ACL: { "*": { read: true }, [me.objectId]: { read: true, write: true } },
  });
}

Listar é um GET /classes/Photo?order=-createdAt&include=owner, e cada resultado traz image: { __type: "File", name, url }. A página coloca a url num <img src>; a CDN faz o resto.

Não definimos a classe nem nenhuma coluna à mão: o primeiro POST criou Photo com caption, image, contentType, size e owner, tipados a partir dos valores que enviamos.

Uma coisa que a captura mostra e o código não pretendia: quem enviou aparece como ? nas fotos de outros usuários. include=owner expande o pointer só quando quem chama pode ler a linha daquele usuário, e neste backend um usuário lendo a linha de outro recebeu 404 / 101, com session token ou sem.

Se o nome importa, guarde-o na linha Photo como string na hora do upload.

Duas decisões estão embutidas nessa ACL, e as duas pertencem ao backend, não à página. Um comando curl pode enviar qualquer owner e qualquer ACL que quiser com a mesma chave de cliente.

O cloud/main.js do repositório tem um beforeSave("Photo") que sobrescreve os dois a partir de request.user, exatamente como a classe Note faz no post sobre travar o backend. A seção sobre exclusão, abaixo, diz o que ele faz e o que aconteceu quando fizemos o deploy.

Qual o tamanho máximo de um arquivo, e quanto tempo leva um upload?

Maior do que esperávamos, e mais lento que a conexão. Geramos PNGs reais de seis tamanhos (pixels aleatórios, armazenados sem compressão, para que a contagem de bytes fosse a que pedimos) e enviamos cada um com um POST do curl, session token, Content-Type: image/png. Todos devolveram 201:

Tamanho (bytes enviados)HTTPTempo, curl (mediana de 3, intervalo)Tempo, página webPOST da linha depois
10 KB (9.872)2011,33 s (1,21–1,38)1,21 s1,10 s
100 KB (99.649)2012,33 s (2,06–3,08)1,68 s2,05 s
1 MB (999.762)2016,78 s (6,77–7,18)6,84 s0,72 s
5 MB (4.995.285)20129,5 s (27,8–31,5)28,6 s0,71 s
10 MB (9.997.112)20156,6 s (53,2–65,5)54,8 s2,17 s
25 MB (24.998.119)201130 s (uma rodada)––
50 MB (50.008.266)nenhum200–329 s, depois a conexão fechou, 3 de 3––
Medido · 24 de setembro de 2026 · um POST /files por arquivo, session token, plano Free, USA East
6,8 s1 MB do curl
→
6,8 s1 MB da página web
50 MBsem resposta, 3 de 3 tentativas

Como medimos: curl -w '%{time_total} %{speed_upload}' em volta de cada POST, do primeiro byte da requisição ao último byte do 201, a mediana de três rodadas por tamanho de 10 KB a 10 MB (iniciadas às 13:08, 13:39 e 13:41 UTC) e uma rodada para 25 MB (13:09 UTC), de um laptop para o backend em USA East, plano Free, em 24 de setembro de 2026.

A coluna do navegador é performance.now() em volta de cada fetch em web/app.js, uma rodada por tamanho às 13:31–13:33 UTC, lida da barra de status da página por web-check.mjs em Chrome 154 headless.

Os tempos absolutos são do nosso uplink. Nos mesmos minutos, o mesmo arquivo de 10 MB foi para dois endpoints de teste de banda sem relação a 0,49 MB/s e 0,38 MB/s, contra 0,15 a 0,19 MB/s para o backend.

O arquivo de 50 MB é a única linha sem status. Duas vezes sobre HTTP/2 o curl reportou Error in the HTTP2 framing layer após 321 s e 329 s; uma vez sobre HTTP/1.1 recebeu 100 Continue, enviou tudo e após 200 s recebeu Empty reply from server. Todos os 50.008.266 bytes saíram do laptop a cada vez.

Isso é um limite entre 25 e 50 MB ou um timeout de requisição de alguns minutos; o backend não disse qual, então nós também não.

Então, nesta conexão, o caminho de upload rodou a cerca de metade do link bruto, o que é consistente com o backend receber o corpo inteiro antes de gravá-lo no store e responder. Reserve mais ou menos o dobro do que um upload simples dos mesmos bytes levaria, e mostre progresso.

Quem pode ler a URL do arquivo quando a linha é só do dono?

Todo mundo. Essa era a medição que mais queríamos, e a resposta é simples: a ACL protege a linha, e a URL não é a linha. Criamos um Photo cuja ACL concedia leitura e escrita só ao dono, referenciando o arquivo de 1 MB, e pedimos por ele de três jeitos:

RequisiçãoResposta
anônimo (App ID + JavaScript key) → GET /classes/Photo/2VU6ud9qUs404 {"code":101,"error":"Object not found."}
dono (session token) → GET /classes/Photo/2VU6ud9qUs200
qualquer um, sem header nenhum → GET <a URL do arquivo>200 image/png 999762 bytes

A linha se esconde. O arquivo não, porque a requisição pelo arquivo nunca chega ao backend: ela vai para a CDN, que não sabe nada de _User, sessões ou ACLs. Nada nesse caminho pode ser instruído a verificar coisa alguma.

Isso também decide o que vai no arquivo. Enviamos um .txt, um .svg, um .js e um blob sem nome e sem Content-Type nenhum; os quatro voltaram 201, e cada URL serviu exatamente o tipo que enviamos. Um PNG enviado como image/jpeg foi servido como image/jpeg. Nada inspecionou os bytes.

A única recusa foi .html: 130 File upload of extension html is disabled., que é o backend impedindo que uma página com o seu App ID na URL hospede o script de outra pessoa.

O que acontece com o arquivo quando você apaga a linha?

Nada. Esse é o achado que moldou o resto do repositório. O dono apagou a linha Photo, 200 {}, e dois segundos depois a URL do arquivo ainda respondia 200 image/png 999762 bytes. A linha e o arquivo são dois objetos com duas vidas, e o campo File é uma referência, não posse.

Então tentamos apagar o arquivo. DELETE /files/<nome> com o session token: 403 {"error":"unauthorized: master key is required"}. Com a JavaScript key: o mesmo 403. Com a master key: 200. Depois a URL de novo, dois segundos mais tarde: 200, o arquivo inteiro. Dez segundos depois: 200.

O caminho de origem no host da API, GET /files/<APP_ID>/<nome>, dizia 404; a URL pública dizia x-cache: Hit from cloudfront e um age que não parava de subir. Continuamos consultando a cada dois minutos.

Às 13:44 UTC, 32 minutos após o delete, a URL ainda devolvia 200 com age: 1953, e continuava assim quando paramos de observar.

PassoResposta
dono → DELETE /classes/Photo/<id>200 {}
GET <URL do arquivo> 2 s depois200 image/png, todos os bytes
session token → DELETE /files/<nome>403 unauthorized: master key is required
JavaScript key → DELETE /files/<nome>403 unauthorized: master key is required
master key → DELETE /files/<nome>200
GET /files/<APP_ID>/<nome> no host da API404
GET <URL do arquivo> (CDN), 2 s / 10 s / 32 min depois200, x-cache: Hit from cloudfront, age subindo
GET <URL do arquivo>?v=2200, a mesma cópia em cache

Como medimos: upload-check.sh, passos 7 e 8, uma rodada às 13:12 UTC em 24 de setembro de 2026, depois um poller buscando a URL apagada a cada dois minutos e registrando o status e os headers x-cache e age, de um laptop por um edge da CDN (o header x-amz-cf-pop dizia MIA50).

Duas consequências. Um cliente não consegue apagar um arquivo, então “remover minha foto” tem que rodar no backend. E “apagado” significa que a origem esqueceu o objeto enquanto o edge continua servindo, sem header cache-control para encurtar isso e sem query string para invalidar: a requisição ?v=2 foi um hit na mesma cópia.

Se o arquivo foi enviado por engano, conte com ele continuar acessível a partir desse edge por um tempo depois de você apagá-lo.

Como fazer uma linha apagada levar o arquivo junto?

Com um hook beforeDelete em Photo no Cloud Code, que é o único lugar que pode guardar a master key. Ele roda no backend para todo delete, seja qual for o cliente que pediu, lê o campo image e destrói o arquivo antes de a linha sumir:

// Stack: Node.js 22.x | Parse Server 7.x (Cloud Code) | File: cloud/main.js
// Rule 1: a Photo belongs to whoever uploaded it, whatever the client claimed. Anyone may read the row; only the owner
// may change or delete it. Set here, on the backend, so a curl command cannot skip it.
Parse.Cloud.beforeSave("Photo", (request) => {
  const photo = request.object;
  if (!photo.isNew()) return;
  const owner = request.user;
  if (!owner) throw new Parse.Error(Parse.Error.SESSION_MISSING, "log in to upload a photo.");
  if (!photo.get("image")) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "a Photo needs an image.");
  photo.set("owner", owner);
  const acl = new Parse.ACL();
  acl.setPublicReadAccess(true);
  acl.setWriteAccess(owner, true);
  photo.setACL(acl);
});

// Rule 2: deleting the row deletes the file. DELETE /files/<name> needs the master key, which lives here and nowhere else.
Parse.Cloud.beforeDelete("Photo", async (request) => {
  const file = request.object.get("image");
  if (!file) return;
  try {
    await file.destroy({ useMasterKey: true });
  } catch (err) {
    // A file that is already gone must not block the row delete; anything else should.
    if (err.code !== Parse.Error.FILE_DELETE_ERROR && err.code !== Parse.Error.OBJECT_NOT_FOUND) throw err;
  }
});

file.destroy({ useMasterKey: true }) é o mesmo DELETE /files/<nome> que enviamos do terminal, emitido de dentro do backend com a chave que o cliente nunca teve.

O beforeSave acima dele é a outra metade: owner e a ACL vêm de request.user, então os valores da própria página são sobrescritos e um comando curl não consegue reivindicar a conta de outra pessoa.

O deploy do Cloud Code é feito pela dashboard (Cloud Code → main.js → Deploy, duas vezes num backend novo: o primeiro deploy não envia nada, um comportamento que já vimos em sete backends antes deste).

O hook-check.sh do repositório é a prova: dois usuários, um upload, uma linha criada de propósito com o owner errado e uma ACL aberta, depois o delete pelo dono e um GET do caminho de origem do arquivo, que precisa ser 404.

Esta é a rodada, às 14:38 UTC de 24 de setembro de 2026, com o arquivo no ar. ana fez o upload, depois criou uma linha que alegava owner = bob e uma ACL de escrita pública.

A linha voltou com owner = ana e ACL {"*":{"read":true},"<ana>":{"write":true}}: a alegação do cliente foi sobrescrita. Depois ela a apagou, segurando nada além do seu session token:

PassoResposta
anônimo → POST /classes/Photo com uma imagem400 {"code":206,"error":"log in to upload a photo."}
ana → POST /classes/Photo, corpo alega owner: bob, ACL: {"*": read+write}201, gravado owner: ana, ACL {"*":{"read":true},"ana":{"write":true}}
bob → PUT / DELETE nessa linha404 / 101 Object not found. / Object not found for delete.
ana → POST /classes/Photo sem imagem400 / 142 a Photo needs an image.
antes do delete: caminho de origem / URL da CDN200 / 200 image/png
ana → DELETE /classes/Photo/sSo3LhjDmY200 {}
2 s e 12 s depois: caminho de origem GET /files/<APP_ID>/<nome>404
2 s e 12 s depois: URL da CDN403, x-cache: Error from cloudfront

Como medimos: hook-check.sh, uma rodada às 14:38:05–14:38:39 UTC em 24 de setembro de 2026, dois usuários novos, curl de um laptop; depois um delete com a master key às 14:39 de uma linha que a página tinha criado antes de o hook existir, que também devolveu 200 e deixou o caminho de origem em 404.

A linha da CDN difere do achado anterior, e a diferença é instrutiva. Este arquivo tinha sido buscado uma vez pela CDN antes do delete (x-cache: Miss), e depois do delete o edge respondeu 403 de imediato.

O arquivo de 1 MB da manhã tinha sido buscado várias vezes (Hit), e sua cópia em cache continuou servindo por 32 minutos depois do delete. O que o edge já guarda, ele mantém; o que ele não armazenou, ele vai buscar na origem.

A mesma requisição com a url da resposta do upload respondeu 400 / 206 ou 201 como deveria, e o mesmo valeu para um corpo sem image nenhum.

Antes de o trigger existir, name sozinho tinha funcionado a manhã inteira: a página e o script criaram toda linha desse jeito. Envie name e url, exatamente como o upload respondeu; a página e os dois scripts agora fazem isso.

O achado explica um trecho feio que temos de assumir. Por cerca de dez minutos depois do deploy do Cloud Code (14:11–14:24 UTC) o app reiniciou repetidamente e toda escrita em Photo respondeu 502.

Enquanto isso, o System log da dashboard mostrava os restarts e o Error log não mostrava nada além das nossas próprias entradas 142.

Nosso primeiro script de prova enviou name sozinho, trinta vezes, e cada tentativa reiniciou o app; se foi o deploy que começou ou as nossas repetições, não sabemos dizer.

Quais erros de upload você vai encontrar, e o que dizem seus corpos?

Toda recusa que encontramos em 24 de setembro de 2026 veio como um status HTTP mais um corpo JSON; decida pelo code, e pelo texto de error nos dois que compartilham 130:

O que você fezHTTPcodeerror
POST /files/<nome> só com o App ID e uma chave de cliente400130File upload by public is disabled.
POST /files/probe.html (session token)400130File upload of extension html is disabled.
DELETE /files/<nome> com um session token ou chave de cliente403–unauthorized: master key is required
GET /classes/Photo/<id> de uma linha que a sua ACL não pode ler404101Object not found.
GET /files/<APP_ID>/<nome> no host da API depois de o arquivo ser apagado404–Not Found (texto puro)
GET /files com a master key (não existe endpoint de listagem)404–{"message":"Not Found","error":{}}
POST /classes/Photo com image: {"__type":"File","name":…} e sem url, quando já existe um trigger na classe502–Bad Gateway (texto puro), depois /health 502 por um segundo
POST /classes/Photo sem sessão, hook no ar400206log in to upload a photo.
POST /classes/Photo sem image, hook no ar400142a Photo needs an image.

130 File upload by public is disabled. é o que o seu formulário vai encontrar primeiro. A correção é uma sessão, não uma chave: crie a conta do usuário ou faça o login, envie X-Parse-Session-Token com o POST e mantenha a master key fora da página. O starter só mostra o input de arquivo depois que GET /users/me aceitou o token.

130 File upload of extension html is disabled. é por nome, não por tipo: os mesmos bytes como probe.txt com Content-Type: text/html foram aceitos. Renomeie ou recuse na página, e não espere que o backend rejeite um script pelo conteúdo: probe.js passou como text/javascript.

502 Bad Gateway num save de Photo, com o app brevemente sem saúde depois, é o caso do File sem url da seção de exclusão. Envie {"__type":"File","name","url"} exatamente como o upload respondeu, e nunca repita um 502 em loop: cada um reinicia o app.

Um 201 com o Content-Type errado é o erro que você não vai ver. A URL serve o tipo que você enviou, então leia file.type do objeto File e recuse qualquer coisa que não seja image/* na página antes do upload, que é o que o accept="image/*" do input faz para um humano e uma verificação de duas linhas faz para um script.

Quando você quer object storage, afinal?

Quando o arquivo é o produto, ou a URL precisa expirar. Três casos em que um bucket que você administra, ou um serviço de arquivos dedicado, vence o endpoint de arquivos:

  • Arquivos muito grandes. Nosso upload de 25 MB levou 130 s numa única requisição, num link lento, sem retomada, e 50 MB nunca recebeu resposta. Vídeo, fotos raw e exportações pedem uploads multipart, retomáveis, com progresso por parte, que a API de um bucket e uma biblioteca de cliente no navegador dão e um único POST não dá.
  • URLs assinadas, com expiração. Um arquivo aqui é público até ser apagado, e fica em cache além disso. Downloads pagos, imagens médicas, contratos, qualquer coisa em que a URL precise parar de funcionar num momento que você escolhe, precisam de URLs assinadas com expiração, o que significa um object store e um servidor ou função que assine.
  • Transformações na hora. Thumbnails, redimensionamento, conversão de formato, marcas d’água: uma CDN de imagens faz isso a partir de um parâmetro na URL. Aqui você envia os tamanhos de que precisa (a página pode redimensionar num <canvas> antes do POST) e referencia cada um a partir da linha.

Para avatares, fotos de anúncios, anexos e qualquer coisa que um usuário aceite ter numa URL pública, as duas chamadas acima são o recurso inteiro, e a parte que é sua são 32 linhas de Cloud Code.

O que você acrescentaria ao image upload starter em seguida?

  • Redimensionar no navegador. Desenhe a imagem num <canvas> a 1600 px, toBlob("image/webp", 0.85), e envie isso: nossa foto de celular de 5 MB vira um upload de 300 KB, e a tabela de tempos acima encolhe junto.
  • Progresso. fetch não consegue reportar progresso de upload; XMLHttpRequest consegue. Troque uploadFile por uma versão com xhr.upload.onprogress e mostre uma barra, que os 57 s para 10 MB justificam.
  • Um job de limpeza. Arquivos enviados cuja criação de linha falhou (o segundo POST pode falhar depois de o primeiro ter sucesso) são órfãos que ninguém referencia. Um Cloud Job agendado que lista as linhas Photo e apaga os arquivos não referenciados com a master key mantém o 1 GB honesto.
  • Arquivos privados. Uma classe Document com ACL só do dono e uma Cloud Function download(id) que verifica request.user e devolve os bytes com a master key: o padrão do contra-caso, neste backend.
  • Usuários primeiro, permissões em seguida. A página precisa de contas porque uploads exigem uma sessão: esse é o post sobre autenticação. Quem pode ler e escrever as linhas Photo, com uma sonda que você pode rodar de novo, é o post sobre travar o backend.

Tagsfilesuploadsimagescdnrest-apijavascriptcloud-codeparse-server

Perguntas frequentes

Como deixo usuários enviarem imagens para o meu app sem configurar S3 ou outro bucket?

Use um backend gerenciado com um endpoint de arquivos. Do navegador, faça um POST dos bytes brutos para /files/<nome> com o Content-Type do arquivo, o App ID, uma chave de cliente e o session token do usuário; a resposta é 201 com uma URL pública servida por uma CDN. Depois crie uma linha (por exemplo Photo) que referencia o arquivo pelo nome, com um pointer de dono e uma legenda. Medimos uma imagem de 1 MB em 6,8 s do curl e 6,8 s de uma página web em 24 de setembro de 2026, num backend Back4app, sem bucket, URL assinada ou servidor de upload próprio.

Por que meu upload devolve o código 130 File upload by public is disabled?

Porque a requisição levava só o App ID e uma chave de cliente. No backend que testamos (24 de setembro de 2026) uploads são recusados para chamadas anônimas por padrão; a mesma requisição com o X-Parse-Session-Token de um usuário logado, ou com a master key a partir de um servidor, devolveu 201. Faça o login do usuário antes de mostrar o input de arquivo. Um segundo 130, File upload of extension html is disabled, significa que o nome do arquivo termina em .html; .svg, .txt, .js e bytes sem tipo foram aceitos.

A URL do arquivo é privada se a linha que a referencia tem uma ACL restritiva?

Não. A ACL protege a linha, não os bytes. Criamos uma linha Photo com uma ACL só do dono: todo mundo menos o dono recebeu 404 / 101 Object not found para a linha, mas um GET da URL do arquivo sem header nenhum devolveu 200 e a imagem. Trate toda URL de arquivo como pública enquanto ela existir, e coloque qualquer coisa privada atrás de uma Cloud Function que faça o stream com a master key, ou não faça o upload.

Apagar a linha apaga o arquivo?

Não por si só. DELETE /classes/Photo/<id> devolveu 200 e a URL do arquivo continuou devolvendo 200. Apagar um arquivo é DELETE /files/<nome> e exige a master key (um session token ou chave de cliente recebe 403 unauthorized: master key is required), então o lugar para isso é um hook beforeDelete em Photo no Cloud Code que chama file.destroy({ useMasterKey: true }). Com esse hook no ar, o DELETE da linha pelo dono devolveu 200 e o caminho de origem do arquivo devolveu 404 dois segundos depois (medido às 14:38 UTC, 24 de setembro de 2026). Mesmo assim, a CDN continuou servindo a cópia em cache do arquivo apagado pelo resto da nossa sessão; o objeto não traz header cache-control e uma query string não invalida o cache.

Qual o tamanho máximo de arquivo que posso enviar, e quanto tempo leva?

Em 24 de setembro de 2026, de um laptop com uplink lento (cerca de 0,4 MB/s para dois endpoints sem relação no mesmo minuto), um POST por arquivo a um backend Back4app no plano Free levou 1,3 s para 10 KB, 2,3 s para 100 KB, 6,8 s para 1 MB, 30 s para 5 MB, 57 s para 10 MB (medianas de três rodadas) e 130 s para 25 MB, todos 201. Um arquivo de 50 MB nunca recebeu resposta: em três tentativas (duas sobre HTTP/2, uma sobre HTTP/1.1) todos os bytes foram enviados e a conexão foi fechada sem status após 200 a 329 s, então o teto fica entre 25 e 50 MB, ou é um timeout de requisição; não conseguimos dizer qual. O throughput para o backend foi de 0,15 a 0,19 MB/s, mais ou menos metade do que o mesmo laptop empurrou para um teste de banda, então planeje o upload levando cerca do dobro do que a conexão bruta sugere.

Leitura complementar

Revisado e verificado por Back4app Engineering, Editorial review na Back4app

Testado em 24 de setembro de 2026 — Backend: Back4app, plano Free, USA East · Web: uma página HTML + JavaScript puro · Terminal: curl no macOS · Rodadas no navegador: Chrome 154 headless.

Mais da Back4app Engineering

Backend · tutorial

Como adicionar autenticação a um app web ou Flutter sem escrever um servidor de auth

Backend · tutorial

Seu frontend fala direto com o banco de dados. Veja como proteger o backend

Backend · tutorial

Como criar e fazer o deploy de uma API REST em Node.js em 15 minutos, sem administrar um servidor