Browser-Uploads zu Cloudflare R2 mit AWS SDK
Ein Browser kann direkt in einen privaten Cloudflare-R2-Bucket hochladen, ohne Ihren geheimen R2-Schlüssel zu erhalten. Führen Sie die kleine lokale App unten aus: Sie authentifiziert die Person, die einen Upload anfordert, signiert einen einzelnen Objektschlüssel und bestätigt die gespeicherten Bytes, bevor sie den Erfolg anzeigt.
Berechtigungen auf dem Server verwalten
Das AWS SDK läuft auf Ihrem Node.js-Server. Der Browser erhält eine vorsignierte PUT-URL für ein einzelnes Objekt, sendet die Datei an R2 und fordert den Server auf, sie wieder auszulesen. Der Vergleich der gespeicherten Größe und des SHA-256-Hashwerts mit der ausgewählten Datei erkennt auch einen erfolgreichen HTTP-Transfer, bei dem die falschen Bytes gespeichert wurden.
Dies ist eine lokale Demonstration für einen einzelnen Benutzer mit Dateien von null Bytes bis 10 MiB. Ihr Zugangscode identifiziert einen lokalen Benutzer; der Server wählt dessen Schlüssel und hält höchstens acht Zuweisungen im Arbeitsspeicher. Bei einem Neustart gehen diese Datensätze verloren, die Objekte werden jedoch nicht gelöscht. Eine bereitgestellte App benötigt eigene Benutzersitzungen und dauerhafte Eigentumszuordnungen anstelle dieses lokalen Zugangscodes.
Eine vorsignierte URL berechtigt allein durch ihren Besitz zum Zugriff und ist bis zu ihrem Ablauf wiederverwendbar. Wer sie erhält, kann sie ohne den Zugangscode der App nutzen. Halten Sie URLs aus Protokollen, Analysedaten und geteilten Nachrichten heraus; das Beispiel zeigt sie weder an noch leitet es den Autorisierungsheader der App an R2 weiter.
Privaten Bucket und lokales Projekt vorbereiten
Verwenden Sie einen privaten R2-Bucket unter Ihrer Kontrolle mit Standardjurisdiktion und deaktiviertem öffentlichem Zugriff. Sie benötigen seinen Namen, Ihre Cloudflare-Konto-ID sowie eine R2-S3-Zugriffsschlüssel-ID und einen geheimen Zugriffsschlüssel mit der Berechtigung Object Read & Write, die auf diesen Bucket beschränkt ist. Folgen Sie der R2-Authentifizierungsanleitung von Cloudflare, um diese Werte zu erhalten. Eine Wrangler-Anmeldung oder eine Bucket-Bindung für einen Worker stellt kein S3-Schlüsselpaar bereit.
Die Befehle verwenden Bash unter Linux, Node.js 26.12.0 (die getestete Current-Version) und separat installiertes Corepack 0.36.0. Node.js 26 enthält Corepack nicht. Verwenden Sie für Neuinstallationen eine gepflegte Patchversion von Node.js 26; die exakten Paketversionen unten machen dieses Beispiel reproduzierbar. Die festgelegten SDK-Versionen waren beim Test die neuesten Versionen, die die zweitägige Paketalter-Sperrfrist des Projekts zuließ. Der Browser benötigt die File API und Web Crypto, die Chromium auf diesem Loopback-Ursprung bereitstellt.
Fügen Sie diesen Block aus einem beschreibbaren Verzeichnis ein. Er erstellt ein neues Projekt; Ihre Shell bleibt im ursprünglichen Verzeichnis. Falls das Verzeichnis bereits existiert, bricht der Block ab, ohne es zu ersetzen. Bei einem Installationsfehler bleibt das neue Verzeichnis zur Untersuchung erhalten; wählen Sie für einen neuen Versuch einen anderen, leeren Speicherort.
(
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
)
Speichern Sie die nächsten drei Dateien in r2-browser-demo. Die lokale
Yarn-Konfiguration wählt explizit gewöhnliches node_modules und einen Projektcache,
auch wenn ein übergeordnetes Projekt Plug’n’Play verwendet.
CORS für den Browser konfigurieren
Tragen Sie diese Richtlinie in den Einstellungen Ihres Buckets mit dem JSON-Editor des Dashboards ein. Ersetzen Sie eine vorhandene Richtlinie nur bei einem dedizierten Demo-Bucket; andernfalls führen Sie diese Regel mit den Regeln zusammen, die Ihre Anwendung bereits benötigt. Sie erlaubt den exakten Ursprung, den der lokale Server ausgibt, nicht jeden localhost-Namen.
[
{
"AllowedOrigins": ["http://127.0.0.1:8080"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"]
}
]
Bucket-CORS erlaubt die ursprungsübergreifende PUT-Anfrage des Browsers. Es authentifiziert nicht die Person, die Ihre App um Erlaubnis bittet. Die signierte URL funktioniert weiterhin in einem HTTP-Client, der CORS nicht durchsetzt. Die Beschränkung der Ursprünge kann daher die serverseitige Autorisierung nicht ersetzen.
Signierer und Byte-Prüfung speichern
Speichern Sie dies als storage.mts. Der Client verwendet den standardmäßigen
R2-S3-Endpunkt und explizit angegebene Zugangsdaten, sodass er Ihre persönliche AWS-Konfiguration
nicht durchsucht. WHEN_REQUIRED deaktiviert hier die optionale Berechnung von
Anfrageprüfsummen durch das SDK; dieses Beispiel prüft den vollständigen gespeicherten Inhalt
selbst, statt einen ETag als SHA-256-Hashwert zu behandeln. AWS dokumentiert die
Prüfsummeneinstellung.
// 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',
})
Speichern Sie dies als server.mts. Nur eine authentifizierte Anfrage von der
lokalen Seite kann einen Upload zuweisen. Weder der Schlüssel noch der Eigentümer stammen aus
Browsereingaben. Die Prüfung löst die opake ID über dieselbe Eigentumszuordnung auf. Aufrufende
können den Server daher nicht dazu bringen, ein beliebiges Objekt aus dem Bucket zu lesen.
// 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()
})
}
Die PUT-URL gilt 120 Sekunden und signiert Content-Type: application/octet-stream. Der Browser muss exakt
diesen Wert senden. Damit werden bewusst beliebige Datei-Bytes mit einem generischen Medientyp
gespeichert; der Header enthält Metadaten und belegt weder das Format noch die Sicherheit der Datei.
Die Prüfungen auf 10 MiB begrenzen den normalen Ablauf der App und die Byte-Prüfung, nicht die
Größe, die jemand mit böswilliger Absicht über eine geleakte URL per PUT hochladen könnte.
Dateiuploader für den Browser speichern
Speichern Sie dies als uploader.ts. Die Datei verwendet JavaScript-Syntax, damit
der Server sie direkt als Modul ausliefern kann, ohne Build-Schritt. Die Bedienelemente bleiben bis
zum Abschluss des erneuten Auslesens deaktiviert. Ein zweites Absenden während der laufenden
Verarbeitung wird ignoriert.
// 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
}
})
App starten und eine Datei hochladen
Fügen Sie diesen Block in einem Terminal aus demselben übergeordneten Verzeichnis ein, das Sie für die Einrichtung verwendet haben. Geheime Eingaben werden ausgeblendet, und die Subshell hält sie aus der Umgebung Ihrer übergeordneten Shell heraus. Wählen Sie einen langen lokalen Zugangscode, den Sie auf der Seite erneut eingeben können; er ist von Ihren R2-Zugangsdaten getrennt.
(
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
)
Öffnen Sie http://127.0.0.1:8080, geben Sie den Code in
Local access code ein,
wählen Sie eine Datei über File
und anschließend Upload.
Der abschließende Status beginnt mit
Verified und nennt die Byte-Anzahl sowie den
Objektschlüssel. Diese Meldung erscheint erst, nachdem der Server R2 ausgelesen hat und beide Seiten
die erwartete Größe und den Hashwert geprüft haben. Probieren Sie eine Textdatei, eine Binärdatei
und eine leere Datei aus; Dateinamen werden nicht als Objektschlüssel verwendet.
Jeder Klick nach Abschluss weist einen neuen UUID-Schlüssel zu. Er ersetzt keinen früheren Upload. Der Server gibt zugewiesene Schlüssel aus, damit Sie auch einen unterbrochenen Upload finden können. Warten Sie nach dem Test mindestens 120 Sekunden ab der letzten Zuweisung, stoppen Sie den Server mit Ctrl+C und entfernen Sie genau diese Objekte aus Ihrem Bucket. Das Stoppen des Servers widerruft bereits ausgegebene URLs nicht und entfernt keine Dateien.
Fehlgeschlagenen Upload diagnostizieren
Wird die Berechtigung mit 401 verweigert, prüfen Sie den lokalen Zugangscode. Ein 400 bedeutet, dass der Signierer keine gültige Größe und keinen gültigen Hashwert erhalten hat; 429 bedeutet, dass dieser Prozess seine acht Zuweisungen aufgebraucht hat. Ein Neustart ermöglicht eine neue Sitzung. Dieses Limit dient daher einer begrenzten lokalen Demonstration und ist keine Quote für den Produktivbetrieb.
Untersuchen Sie bei einem Netzwerkfehler im Browser die fehlgeschlagene Phase mit den
Netzwerkwerkzeugen Ihres Browsers. Ein fehlgeschlagener OPTIONS-Preflight deutet auf die erlaubten
Ursprünge, Methoden und Header des Buckets hin. Prüfen Sie den Port und die Schreibweise von
127.0.0.1, bevor Sie die Richtlinie ändern. Eine abgelehnte Signatur ist ein
separater R2-Autorisierungsfehler; eine Änderung des signierten Inhaltstyps macht die Berechtigung
ungültig. Abgelaufene URLs werden ebenfalls abgelehnt. Ihre Antworten können CORS-Header auslassen,
sodass JavaScript im Browser möglicherweise nur einen Netzwerkfehler erkennt. Cloudflare erläutert
diesen Unterschied in seiner CORS-Anleitung.
Eine fehlgeschlagene Prüfung bedeutet, dass die App die ausgewählten Bytes nicht bestätigt hat. Untersuchen Sie den zugewiesenen Schlüssel vor einem erneuten Versuch: Bei einer fehlgeschlagenen oder verlorenen Antwort kann ein gespeichertes Objekt zurückbleiben. Ein erneuter Versuch erstellt einen neuen Schlüssel und sendet die gesamte Datei noch einmal. Dieses Beispiel verspricht weder automatische Wiederholungsversuche noch eine Fortsetzung unterbrochener Uploads.
Nächsten Schritt für Ihre App wählen
Verwenden Sie diese Demo mit ihrer Grenze von 10 MiB nicht für größere Dateien. Multipart-Uploads benötigen einen separaten authentifizierten Lebenszyklus zum Starten von Uploads, Signieren von Teilen sowie zum Abschließen und Abbrechen von Uploads; er ist hier nicht implementiert. Ein Worker kann Anwendungslogik hosten, doch der ausführbare Ablauf dieser Anleitung verwendet den lokalen Node.js-Signierer. Vorsignierte URLs verwenden den S3-Hostnamen von R2 und können keine eigenen Domains verwenden.
Entdecken Sie Uppy für eine umfangreichere Upload-Oberfläche. Auch wenn Sie einen Dateiuploader an Ihre App anbinden, müssen Sie die hier gezeigte Trennung der Zuständigkeiten für Eigentumszuordnung und Signierung beibehalten.
