Uploads pelo navegador para Cloudflare R2 com o AWS SDK
Um navegador pode fazer upload diretamente para um bucket privado do Cloudflare R2 sem receber sua chave secreta do R2. Execute o pequeno aplicativo local abaixo: ele autentica a pessoa que solicita um upload, assina uma única chave de objeto e confirma os bytes armazenados antes de exibir uma mensagem de sucesso.
Manter a permissão no servidor
O AWS SDK é executado no seu servidor Node.js. O navegador recebe uma URL PUT pré-assinada para um objeto, envia o arquivo ao R2 e pede ao servidor que leia o objeto de volta. Comparar o tamanho e o resumo SHA-256 armazenados com os do arquivo selecionado detecta uma transferência HTTP bem-sucedida que armazenou bytes incorretos.
Esta é uma demonstração local para um único usuário, com arquivos de zero bytes a 10 MiB. Seu código de acesso identifica um usuário local; o servidor escolhe as chaves desse usuário e mantém no máximo oito alocações na memória. Reiniciar o servidor perde esses registros, mas não exclui os objetos. Um aplicativo implantado precisa de sessões de usuário próprias e registros persistentes de propriedade no lugar desse código de acesso local.
Uma URL pré-assinada concede permissão a quem a possui e pode ser reutilizada até expirar. Quem a obtiver poderá usá-la sem o código de acesso do aplicativo. Mantenha as URLs fora de logs, ferramentas de análise e mensagens compartilhadas; o exemplo não as exibe nem encaminha o cabeçalho de autorização do aplicativo ao R2.
Preparar um bucket privado e um projeto local
Use um bucket R2 privado sob seu controle, na jurisdição padrão e com acesso público desativado. Você precisa do nome dele, do ID da conta Cloudflare e de um ID de chave de acesso S3 do R2 e sua chave de acesso secreta, com a permissão Object Read & Write limitada a esse bucket. Siga o guia de autenticação do R2 da Cloudflare para obter esses valores. Fazer login no Wrangler ou vincular um bucket a um Worker não fornece um par de chaves S3.
Os comandos usam Bash no Linux, Node.js 26.12.0 (a versão testada do canal Current) e Corepack 0.36.0 instalado separadamente. O Node.js 26 não inclui o Corepack. Para novas instalações, use uma versão de correção do Node.js 26 que ainda receba manutenção; as versões exatas dos pacotes abaixo tornam este exemplo reproduzível. As versões fixadas do SDK eram as mais recentes permitidas pela restrição de idade dos pacotes de dois dias do projeto na época do teste. O navegador precisa da File API e do Web Crypto, disponíveis nessa origem de loopback no Chromium.
Em um diretório com permissão de escrita, cole este bloco. Ele cria um projeto e mantém seu shell no diretório original. Se o diretório já existir, a execução será interrompida sem substituí-lo. Uma falha na instalação deixa o novo diretório disponível para inspeção; escolha outro local vazio para uma nova tentativa.
(
set -eu
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
node -e 'if (Number(process.versions.node.split(".")[0]) !== 26) process.exit(1)'
mkdir r2-browser-demo
cd r2-browser-demo
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.18.1"}' > package.json
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'ignorePath: true' 'npmMinimalAgeGate: 2d' > .yarnrc.yml
touch yarn.lock
corepack yarn add --exact @aws-sdk/client-s3@3.1150.0 @aws-sdk/s3-request-presigner@3.1150.0 zod@4.6.7
)
Salve os próximos três arquivos em r2-browser-demo. A configuração local do Yarn seleciona
explicitamente o uso convencional de node_modules e um cache do projeto, mesmo quando um projeto
que contém esse diretório usa Plug’n’Play.
Configurar o CORS do navegador
Nas configurações do bucket, insira esta política usando o editor JSON do painel. Substitua uma política existente apenas em um bucket dedicado à demonstração; caso contrário, combine esta regra com as regras de que seu aplicativo já precisa. Isso permite a origem exata exibida pelo servidor local, sem abranger todos os nomes de localhost.
[
{
"AllowedOrigins": ["http://127.0.0.1:8080"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"]
}
]
O CORS do bucket permite o PUT entre origens feito pelo navegador. Ele não autentica a pessoa que pede permissão ao seu aplicativo. A URL assinada continua funcionando em um cliente HTTP que não aplica CORS, portanto restringir origens não substitui a autorização no servidor.
Salvar o assinador e o verificador de bytes
Salve este conteúdo como storage.mts. O cliente usa o endpoint S3 padrão do R2 e credenciais
explícitas, portanto não consulta sua configuração pessoal da AWS. WHEN_REQUIRED desativa aqui
o cálculo opcional de checksum das requisições pelo SDK; este exemplo verifica por conta própria
todo o corpo armazenado, em vez de tratar um ETag como um resumo SHA-256. A AWS documenta essa
configuração de checksum.
// storage.mts
import { randomUUID } from 'node:crypto'
import { S3Client } from '@aws-sdk/client-s3'
import { z } from 'zod'
const parsed = z.object({
CLOUDFLARE_ACCOUNT_ID: z.string().regex(/^[a-f0-9]{32}$/),
R2_BUCKET_NAME: z.string().min(3).max(63),
R2_ACCESS_KEY_ID: z.string().min(1),
R2_SECRET_ACCESS_KEY: z.string().min(1),
UPLOAD_TOKEN: z.string().min(32).max(256),
PORT: z.coerce.number().int().min(1024).max(65535).default(8080),
}).safeParse(process.env)
if (!parsed.success) throw new Error('Set all five configuration values; use a 32+ character access code.')
export const config = parsed.data
export const origin = `http://127.0.0.1:${config.PORT}`
export const prefix = `browser-demo/${randomUUID()}/`
export const maxBytes = 10 * 1024 * 1024
export const r2 = new S3Client({
region: 'auto',
endpoint: `https://${config.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
})
Salve este conteúdo como server.mts. Somente uma requisição autenticada da página local pode
alocar um upload. Nem a chave nem o proprietário vêm de dados fornecidos pelo navegador. A
verificação resolve o ID opaco por meio desse mesmo registro de propriedade, de modo que quem faz a
requisição não possa induzir o servidor a ler um objeto arbitrário do bucket.
// server.mts
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { z } from 'zod'
import { config, maxBytes, origin, prefix, r2 } from './storage.mts'
const inputSchema = z.object({
size: z.number().int().min(0).max(maxBytes),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
}).strict()
const allocations = new Map<string, { owner: string; key: string; size: number; sha256: string }>()
const page = `<!doctype html><html lang="en"><meta charset="utf-8">
<title>R2 browser upload</title><main><h1>R2 browser upload</h1>
<form><fieldset>
<label>Local access code <input id="accessCode" type="password" autocomplete="off" required></label>
<label>File <input id="file" type="file" required></label>
<button>Upload</button>
</fieldset></form><p role="status" aria-live="polite">Choose a file, including an empty file.</p>
<script type="module" src="/uploader.js"></script></main></html>`
function reply(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function json(req: IncomingMessage): Promise<unknown> {
const parts: Buffer[] = []
let length = 0
for await (const part of req) {
length += part.length
if (length > 4096) throw new Error('Request too large')
parts.push(part)
}
return JSON.parse(Buffer.concat(parts).toString('utf8'))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Referrer-Policy', 'no-referrer')
if (req.headers.host !== new URL(origin).host) return reply(res, 403, { error: 'Use the printed local address.' })
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(page)
return
}
if (req.method === 'GET' && req.url === '/uploader.js') {
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' })
res.end(await readFile(new URL('./uploader.ts', import.meta.url)))
return
}
if (!req.url?.startsWith('/api/')) return reply(res, 404, { error: 'Not found.' })
const supplied = Buffer.from(req.headers.authorization ?? '')
const expected = Buffer.from(`Bearer ${config.UPLOAD_TOKEN}`)
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
return reply(res, 401, { error: 'Access code required.' })
}
if (req.headers.origin !== origin) return reply(res, 403, { error: 'Origin refused.' })
const owner = 'local-user'
if (req.method === 'POST' && req.url === '/api/uploads') {
const input = inputSchema.safeParse(await json(req))
if (!input.success) return reply(res, 400, { error: 'Expected size 0–10 MiB and a SHA-256 digest.' })
if (allocations.size >= 8) return reply(res, 429, { error: 'Eight allocations used. Restart for a new session.' })
const id = randomUUID()
const key = `${prefix}${owner}/${id}`
allocations.set(id, { owner, key, ...input.data })
// Log only the opaque key, so failed uploads can be found and removed.
console.info(`Allocated key: ${key}`)
const url = await getSignedUrl(r2, new PutObjectCommand({
Bucket: config.R2_BUCKET_NAME, Key: key, ContentType: 'application/octet-stream',
}), { expiresIn: 120, signableHeaders: new Set(['content-type']) })
return reply(res, 200, { id, key, url })
}
const match = /^\/api\/uploads\/([a-f0-9-]{36})\/verify$/.exec(req.url)
const allocation = match ? allocations.get(match[1]) : undefined
if (req.method !== 'POST' || !allocation || allocation.owner !== owner) {
return reply(res, 404, { error: 'Upload not found in this session.' })
}
const object = await r2.send(new GetObjectCommand({
Bucket: config.R2_BUCKET_NAME, Key: allocation.key,
}))
if (!object.Body) throw new Error('Missing stored body')
const reader = object.Body.transformToWebStream().getReader()
const hash = createHash('sha256')
let size = 0
try {
for (;;) {
const chunk = await reader.read()
if (chunk.done) break
size += chunk.value.length
if (size > maxBytes) return reply(res, 409, { error: 'Stored object exceeds 10 MiB.' })
hash.update(chunk.value)
}
} finally {
await reader.cancel()
reader.releaseLock()
}
const sha256 = hash.digest('hex')
if (size !== allocation.size || sha256 !== allocation.sha256) {
return reply(res, 409, { error: 'Stored bytes differ from the selected file.' })
}
reply(res, 200, { key: allocation.key, bytes: size, sha256 })
}
const server = createServer((req, res) => {
handle(req, res).catch(() => {
console.error('Upload request failed. Check local configuration and R2 access.')
if (!res.headersSent) reply(res, 500, { error: 'Upload request failed.' })
else res.end()
})
})
server.on('error', () => {
console.error('Cannot start the upload server. Check whether the port is already in use.')
r2.destroy()
process.exitCode = 1
})
server.listen(config.PORT, '127.0.0.1', () => console.info(`Open ${origin}`))
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
server.close()
server.closeAllConnections()
r2.destroy()
})
}
A URL PUT tem validade de 120 segundos e assina Content-Type: application/octet-stream. O navegador deve
enviar exatamente esse valor. Isso armazena deliberadamente bytes de arquivos arbitrários com um
tipo de mídia genérico; o cabeçalho é um metadado, não uma evidência do formato ou da segurança do
arquivo. As verificações de 10 MiB restringem o fluxo normal do aplicativo e o verificador, não o
tamanho que alguém mal-intencionado poderia enviar por PUT usando uma URL vazada.
Salvar o componente de upload do navegador
Salve este conteúdo como uploader.ts. Ele usa sintaxe JavaScript para que o servidor possa
entregá-lo diretamente como módulo, sem uma etapa de compilação. Os controles permanecem desativados
até a leitura final de verificação, e um segundo envio enquanto a operação está em andamento é
ignorado.
// uploader.ts: JavaScript syntax, served directly to the browser.
const form = document.querySelector('form')
const fields = document.querySelector('fieldset')
const accessCode = document.querySelector('#accessCode')
const input = document.querySelector('#file')
const status = document.querySelector('[role="status"]')
if (!(form instanceof HTMLFormElement) || !(fields instanceof HTMLFieldSetElement) ||
!(accessCode instanceof HTMLInputElement) || !(input instanceof HTMLInputElement) ||
!(status instanceof HTMLElement)) throw new Error('Upload controls are missing')
let busy = false
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (busy) return
const file = input.files?.[0]
if (!file) return
if (file.size > 10 * 1024 * 1024) {
status.textContent = 'Choose a file of at most 10 MiB.'
return
}
busy = true
fields.disabled = true
let key = ''
const authorization = `Bearer ${accessCode.value}`
try {
status.textContent = 'Preparing upload…'
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
const permission = await fetch('/api/uploads', {
method: 'POST',
headers: { Authorization: authorization, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: file.size, sha256 }),
})
if (!permission.ok) throw new Error(`Permission refused (${permission.status}).`)
const allocation = await permission.json()
key = allocation.key
status.textContent = 'Uploading…'
const uploaded = await fetch(allocation.url, {
method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' },
credentials: 'omit', referrerPolicy: 'no-referrer',
})
if (!uploaded.ok) throw new Error(`R2 refused the upload (${uploaded.status}).`)
status.textContent = 'Verifying stored bytes…'
const verification = await fetch(`/api/uploads/${allocation.id}/verify`, {
method: 'POST', headers: { Authorization: authorization },
})
if (!verification.ok) throw new Error('Stored bytes could not be verified.')
const receipt = await verification.json()
if (receipt.key !== key || receipt.bytes !== file.size || receipt.sha256 !== sha256) {
throw new Error('Stored bytes could not be verified.')
}
status.textContent = `Verified ${receipt.bytes.toLocaleString()} bytes. Key: ${key}`
} catch (error) {
const message = error instanceof Error && /^(Permission refused|R2 refused|Stored bytes)/.test(error.message)
? error.message : 'Upload did not complete. Check the connection and bucket CORS.'
status.textContent = `${message}${key ? ` Key: ${key}` : ''}`
} finally {
busy = false
fields.disabled = false
}
})
Iniciar o aplicativo e fazer upload de um arquivo
No mesmo diretório pai usado na configuração, cole este bloco em um terminal. Os valores secretos digitados ficam ocultos, e o subshell os mantém fora do ambiente do shell pai. Escolha um código de acesso local longo que você consiga inserir novamente na página; ele é separado das suas credenciais do R2.
(
set -eu
cd r2-browser-demo
read -r -p 'Cloudflare account ID: ' CLOUDFLARE_ACCOUNT_ID
read -r -p 'R2 bucket name: ' R2_BUCKET_NAME
read -r -s -p 'R2 access key ID: ' R2_ACCESS_KEY_ID; printf '\n'
read -r -s -p 'R2 secret access key: ' R2_SECRET_ACCESS_KEY; printf '\n'
read -r -s -p 'Local access code (32+ characters): ' UPLOAD_TOKEN; printf '\n'
export CLOUDFLARE_ACCOUNT_ID R2_BUCKET_NAME R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY UPLOAD_TOKEN
node server.mts
)
Abra http://127.0.0.1:8080, insira o código em Local access code,
escolha um File e selecione Upload.
O status final começa com Verified e informa a quantidade de bytes e
a chave do objeto. Essa mensagem aparece somente depois que o servidor lê os dados do R2 e ambos os
lados verificam o tamanho e o resumo esperados. Experimente um arquivo de texto, um arquivo binário
e um arquivo vazio; os nomes não são usados como chaves de objetos.
Cada clique após a conclusão aloca uma nova chave UUID. Ele não substitui um upload anterior. O servidor imprime as chaves alocadas para que você possa encontrar até mesmo um upload interrompido. Após os testes, aguarde pelo menos 120 segundos depois da última alocação, pare o servidor com Ctrl+C e remova esses objetos exatos do bucket. Parar o servidor não revoga URLs já emitidas nem remove arquivos.
Diagnosticar uma falha no upload
Se a permissão for recusada com 401, confira o código de acesso local. Um 400 significa que o assinador não recebeu um tamanho e um resumo válidos; 429 significa que este processo já usou suas oito alocações. Reiniciar permite uma nova sessão, portanto esse limite serve para uma demonstração local com escopo limitado, não como cota de produção.
Se houver um erro de rede no navegador, inspecione a fase que falhou nas ferramentas de rede do
navegador. Uma falha na verificação preliminar OPTIONS aponta para a origem, o método e os
cabeçalhos permitidos pelo bucket. Confira a porta e a grafia de
127.0.0.1 antes de alterar a política. Uma recusa da assinatura é uma falha distinta de
autorização do R2; alterar o tipo de conteúdo assinado invalida a permissão. URLs expiradas também
são recusadas, e suas respostas podem omitir cabeçalhos CORS, de modo que o JavaScript do navegador
pode receber apenas um erro de rede. A Cloudflare descreve essa distinção no seu
guia de CORS.
Uma falha na verificação significa que o aplicativo não confirmou os bytes selecionados. Inspecione a chave alocada antes de tentar novamente: uma resposta com falha ou perdida pode deixar um objeto armazenado. Uma nova tentativa cria uma nova chave e envia o arquivo inteiro novamente. Este exemplo não promete novas tentativas automáticas nem retomada de uploads.
Escolher o próximo passo para seu aplicativo
Mantenha arquivos maiores fora da política de 10 MiB desta demonstração. O upload em múltiplas partes precisa de um ciclo de vida autenticado separado para iniciar uploads, assinar partes, concluir uploads e abortá-los; isso não está implementado aqui. Um Worker pode hospedar a lógica do aplicativo, mas o fluxo executável deste guia usa o assinador local em Node.js. As URLs pré-assinadas usam o nome de host S3 do R2 e não podem usar domínios personalizados.
Para uma interface de upload com mais recursos, explore o Uppy. Conectar um componente de upload ao seu aplicativo ainda exige a delimitação de propriedade e assinatura demonstrada aqui.
