Browser-Uploads zu Cloudflare R2 mit dem AWS SDK
Cloudflare R2 bietet Entwicklern einen kostengünstigen und performanten Objektspeicher, der mit der S3-API kompatibel ist, aber ohne Egress-Gebühren auskommt. Eine zentrale Fähigkeit für Webanwendungen ist der Umgang mit direkten Uploads aus dem Browser: Sie senken Serverlast, Latenz und Kosten, weil Dateien nicht durch Ihr Backend geleitet werden müssen.
In diesem DevTip zeigen wir, wie Sie das AWS SDK for JavaScript (v3) und Cloudflare Workers nutzen, um sichere Datei-Uploads direkt aus dem Browser eines Nutzers in Ihren Cloudflare-R2-Bucket zu ermöglichen.
Einführung in Cloudflare R2
Cloudflare R2 bietet S3-kompatiblen Objektspeicher, der in das globale Netzwerk von Cloudflare integriert ist. Dank der Kompatibilität mit der AWS-S3-API können Sie bestehende AWS-SDKs und -Tools weiterverwenden und zugleich vom Preismodell von R2 profitieren, insbesondere von den entfallenden Egress-Gebühren.
Das AWS SDK für Browser-Uploads verwenden
Für die sichere Interaktion mit Cloudflare R2 aus dem Browser verwenden wir ein Backend (etwa einen Node.js-Server oder einen Cloudflare Worker), das temporäre, sichere Upload-Links erzeugt, sogenannte vorsignierte URLs. Der Browser lädt die Dateien dann mit diesen URLs direkt zu R2 hoch.
Installieren Sie zunächst die erforderlichen AWS-SDK-v3-Pakete in Ihrem Backend-Projekt:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Vorsignierte URLs für sichere Browser-Uploads einrichten
Vorsignierte URLs gewähren temporär die Berechtigung, eine bestimmte S3-Aktion (etwa PutObject) auf
einem bestimmten Objekt-Key auszuführen, ohne Ihre geheimen R2-Zugangsdaten im Browser offenzulegen.
Hier ist ein Node.js/Express-Handler, den Sie hinter der bestehenden Authentifizierung, Autorisierung und Ratenbegrenzung Ihrer Anwendung einbinden. Es handelt sich nicht um einen öffentlichen Dienst zum Signieren von URLs: Nur autorisierte Nutzer sollten einen Upload anfordern können, und Ihre Anwendung setzt die Kontingente durch. Dieses serverseitige Modul erfordert Node.js 22 oder neuer sowie eine ESM-Konfiguration.
// backend/presigned-url-generator.js
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { randomUUID } from 'node:crypto'
// Ensure environment variables are set:
// CLOUDFLARE_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME
for (const name of ['CLOUDFLARE_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET_NAME']) {
if (!process.env[name]) throw new Error(`Missing configuration: ${name}`)
}
const R2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
requestChecksumCalculation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID, // Use R2_ACCESS_KEY_ID
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, // Use R2_SECRET_ACCESS_KEY
},
})
const BUCKET_NAME = process.env.R2_BUCKET_NAME
// Example function (adapt for your framework, e.g., Express route handler)
async function generateUploadUrl(req, res) {
// It's crucial to sanitize and validate filenames from user input
const unsafeFilename = req.query.filename
const contentType = req.query.contentType || 'application/octet-stream'
if (
typeof unsafeFilename !== 'string' || unsafeFilename.length === 0 || unsafeFilename.length > 255 ||
typeof contentType !== 'string' || contentType.length > 255 ||
!/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(contentType)
) {
return res.status(400).json({ error: 'A valid filename and content type are required' })
}
// Basic sanitization: replace potentially problematic characters
const safeFilename = unsafeFilename.replace(/[^a-zA-Z0-9._-]/g, '_')
const key = `uploads/${randomUUID()}-${safeFilename}`
try {
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key, // Use the sanitized and potentially prefixed key
ContentType: contentType, // Set ContentType for correct handling
})
// Generate the presigned URL, valid for 1 hour (3600 seconds)
const signedUrl = await getSignedUrl(R2, command, {
expiresIn: 3600,
signableHeaders: new Set(['content-type']),
})
res.json({ url: signedUrl, key: key }) // Return the URL and the final key
} catch (error) {
console.error('Unable to generate an upload URL')
res.status(500).json({ error: 'Failed to generate upload URL' })
}
}
// Example usage in an Express app:
// app.get('/api/generate-upload-url', requireSignedInUser, generateUploadUrl);
requireSignedInUser oben steht für Ihre bestehende Anwendungs-Middleware, nicht für eine Implementierung in
dieser Anleitung. R2 unterstützt keine S3-ACLs des Typs public-read. Konfigurieren Sie den
Bucket-Zugriff separat und halten Sie Uploads privat, bis sie validiert sind. Vorsignierte URLs
verwenden den Hostnamen der R2-S3-API, nicht eine eigene Domain.
Konfigurieren Sie CORS für den Bucket für den Ursprung Ihres Frontends:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"]
}
]
Der Header Content-Type muss dem signierten Wert entsprechen. Es handelt sich weiterhin um
Metadaten, die der Client liefert, und nicht um einen Nachweis des tatsächlichen Dateiinhalts. CORS
erlaubt den Zugriff aus dem Browser; es ersetzt keine Autorisierung.
Im Frontend rufen Sie diese vorsignierte URL ab und laden damit die ausgewählte Datei direkt zu R2 hoch:
// frontend/uploader.js
async function uploadFileToR2(file) {
const contentType = file.type || 'application/octet-stream'
try {
// 1. Request a presigned URL from your backend
const response = await fetch(
// Pass filename and content type to backend
`/api/generate-upload-url?filename=${encodeURIComponent(file.name)}&contentType=${encodeURIComponent(contentType)}`,
)
if (!response.ok) {
throw new Error(`Failed to get upload URL: ${response.status}`)
}
const { url, key } = await response.json() // Get URL and the final object key
console.log(`Received presigned URL for key: ${key}`)
// 2. Upload the file directly to R2 using the presigned URL
const uploadResponse = await fetch(url, {
method: 'PUT',
body: file,
headers: {
// Content-Type must match what was used to generate the presigned URL if specified
'Content-Type': contentType,
},
})
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.status}`)
}
console.log(`File uploaded successfully! Object key: ${key}`)
return { success: true, key: key }
} catch (error) {
console.error('R2 upload failed')
return { success: false, error: 'Unable to complete the upload' }
}
}
// Example usage with a file input element
document.getElementById('fileInput').addEventListener('change', async (event) => {
const file = event.target.files[0]
if (file) {
const uploadProgress = document.getElementById('uploadProgress')
uploadProgress.textContent = 'Uploading...'
const result = await uploadFileToR2(file)
if (result.success) {
uploadProgress.textContent = `Upload complete! Key: ${result.key}`
// Optionally display the file URL if the bucket is public or served via Worker
// e.g., `https://your-public-bucket-domain/${result.key}`
// or `https://your-worker-domain/${result.key}`
} else {
uploadProgress.textContent = `Upload failed: ${result.error}`
}
}
})
Große Datei-Uploads mit Multipart-Upload bewältigen
Für größere Dateien oder unzuverlässige Verbindungen bieten sich S3-Multipart-Uploads an. Dabei wird die Datei in kleinere Chunks zerlegt, was parallele Uploads, Retries fehlgeschlagener Teile sowie das Pausieren und Fortsetzen von Uploads ermöglicht.
Multipart-Uploads direkt aus dem Browser umzusetzen, ist komplexer und umfasst häufig:
- Backend-Endpunkt zum Starten des Multipart-Uploads (
CreateMultipartUploadCommand), der eineUploadIdzurückgibt. - Backend-Endpunkt(e) zum Erzeugen vorsignierter URLs für jeden Teil (
UploadPartCommand). - Frontend-Logik, um die Datei zu zerteilen, vorsignierte URLs für die Teile anzufordern, die Teile hochzuladen und den Fortschritt zu verfolgen.
- Backend-Endpunkt zum Abschließen des Uploads (
CompleteMultipartUploadCommand), sobald alle Teile hochgeladen sind.
Die folgenden serverseitigen Primitive verwenden den Client R2 und BUCKET_NAME von oben.
Route-Handler müssen jede Upload-ID und jeden Key an den jeweiligen Eigentümer binden, Teilenummern
und ETags validieren und Kontingente durchsetzen, bevor sie diese Funktionen aufrufen. Diese
Funktionen allein sind keine authentifizierte Multipart-API.
// backend/multipart-handler.js
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand, // Important for cleanup
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
// Assume R2 S3Client is configured as shown previously
// const R2 = new S3Client({...});
// const BUCKET_NAME = process.env.R2_BUCKET_NAME;
async function initiateMultipartUpload(key, contentType) {
const command = new CreateMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: contentType,
})
const response = await R2.send(command)
if (!response.UploadId) throw new Error('R2 did not return an upload ID')
return response.UploadId
}
async function getMultipartPresignedUrl(key, uploadId, partNumber) {
const command = new UploadPartCommand({
Bucket: BUCKET_NAME,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
})
// Generate presigned URL for uploading a specific part
const signedUrl = await getSignedUrl(R2, command, { expiresIn: 3600 }) // 1 hour expiry
return signedUrl
}
async function completeMultipartUpload(key, uploadId, parts) {
// 'parts' should be an array of { ETag: string, PartNumber: number }
// The ETag is returned by R2 in the header of a successful part upload
const command = new CompleteMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.toSorted((a, b) => a.PartNumber - b.PartNumber),
},
})
return await R2.send(command)
}
async function abortMultipartUpload(key, uploadId) {
const command = new AbortMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: key,
UploadId: uploadId,
})
return await R2.send(command)
}
// You would need API endpoints calling these functions
// e.g., POST /api/uploads/initiate, GET /api/uploads/:uploadId/part/:partNumber, POST /api/uploads/:uploadId/complete
Bibliotheken wie Uppy können die Umsetzung von Multipart-Uploads im Frontend vereinfachen, indem sie das Aufteilen der Datei, die Signieranfragen für die Teile und die Upload-Verwaltung übernehmen.
R2-Buckets mit der Wrangler CLI verwalten
Cloudflare stellt das CLI-Tool wrangler zum Verwalten von Cloudflare-Ressourcen bereit, einschließlich R2-Buckets.
Installieren Sie es global:
npm install -g wrangler
Melden Sie sich bei Ihrem Cloudflare-Konto an:
wrangler login
Nun können Sie Ihre R2-Buckets verwalten:
# Create a bucket with a name unique within your account
wrangler r2 bucket create your-unique-bucket-name
# List all buckets associated with your account
wrangler r2 bucket list
# Upload a file from your local machine
wrangler r2 object put your-unique-bucket-name/path/to/object.txt --remote --file ./local-file.txt --content-type "text/plain"
# Download an object
wrangler r2 object get your-unique-bucket-name/path/to/object.txt --remote --file ./downloaded-file.txt
# Delete an object
wrangler r2 object delete your-unique-bucket-name/path/to/object.txt --remote
Objektbefehle in Wrangler 4 verwenden ohne --remote standardmäßig den lokalen Speicher.
Verwenden Sie das R2-Dashboard oder die S3-API ListObjectsV2, um Objekte aufzulisten;
wrangler r2 bucket list listet Buckets auf, nicht Objekte.
Erweiterte Konfigurationen mit Cloudflare Workers
Mit Cloudflare Workers führen Sie JavaScript-Code am Edge aus und ermöglichen so eigene Logik für den Zugriff auf Ihre R2-Buckets, etwa Authentifizierung, Routing oder die Auslieferung privater Inhalte.
Der folgende Worker ist eine separate administrative Schnittstelle für die Server-zu-Server-Kommunikation. Er schützt alle Vorgänge, einschließlich Downloads, mit einem einzigen Secret, das Zugriff auf den gesamten gebundenen Bucket gewährt. Geben Sie dieses Secret niemals in Browser-JavaScript aus. Browser-Anwendungen sollten den oben beschriebenen, eng begrenzten Ablauf mit vorsignierten URLs verwenden oder ihre bestehende Autorisierung über die Benutzersitzung mit dem Worker verbinden. Dieses Beispiel implementiert keinen ursprungsübergreifenden Browser-Zugriff.
// worker/src/index.js
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
// Remove leading slash from pathname to get the object key
const key = url.pathname.slice(1)
// Ensure the R2 bucket binding 'MY_BUCKET' is configured in wrangler.toml
if (!env.MY_BUCKET || !env.ADMIN_TOKEN) {
return new Response('Service unavailable', { status: 503 })
}
if (!isAuthenticated(request, env)) {
return new Response('Unauthorized', { status: 401 })
}
if (!key) {
return new Response('An object key is required', { status: 400 })
}
switch (request.method) {
case 'PUT':
case 'POST': // Handle POST as PUT for simplicity here
// Stream the request body directly to R2
try {
const object = await env.MY_BUCKET.put(key, request.body, {
httpMetadata: request.headers, // Pass client headers (like Content-Type) to R2
})
// Return a success response, potentially with the object details
return new Response(null, {
status: 200,
headers: { ETag: object.httpEtag },
})
} catch (e) {
return new Response('Upload failed', { status: 500 })
}
case 'GET':
// Retrieve the object from R2
const object = await env.MY_BUCKET.get(key)
if (object === null) {
return new Response('Object Not Found', { status: 404 })
}
// Set necessary response headers from the object's metadata
const headers = new Headers()
object.writeHttpMetadata(headers) // Copies Content-Type, etc.
headers.set('etag', object.httpEtag) // Set ETag for caching
headers.set('Cache-Control', 'private, no-store')
headers.set('Content-Disposition', 'attachment')
headers.set('X-Content-Type-Options', 'nosniff')
// Stream the object body back to the client
return new Response(object.body, {
headers,
})
case 'DELETE':
try {
await env.MY_BUCKET.delete(key)
return new Response(null, { status: 204 }) // No Content
} catch (e) {
return new Response('Delete failed', { status: 500 })
}
default:
return new Response('Method Not Allowed', { status: 405 })
}
},
}
function isAuthenticated(request, env) {
const authHeader = request.headers.get('Authorization')
return authHeader === `Bearer ${env.ADMIN_TOKEN}`
}
Um diesen Worker zu deployen, konfigurieren Sie in wrangler.toml die Bindung Ihres R2-Buckets:
# wrangler.toml
name = "r2-file-server-worker"
main = "src/index.js" # Path to your worker script
compatibility_date = "2026-09-11"
# Bind the R2 bucket to MY_BUCKET in the Worker
[[r2_buckets]]
binding = "MY_BUCKET" # Variable name available in the Worker (env.MY_BUCKET)
bucket_name = "your-unique-bucket-name"
Legen Sie mit der Secret-Eingabeaufforderung von Wrangler ein starkes Administrations-Token fest und deployen Sie anschließend:
wrangler secret put ADMIN_TOKEN
wrangler deploy
Eigene Domains einrichten
Sie können Ihre R2-Inhalte über eine eigene Domain (z. B. files.yourdomain.com) ausliefern statt über die
standardmäßigen öffentlichen URLs von Cloudflare Worker oder R2.
- Stellen Sie sicher, dass Ihre Domain (
yourdomain.com) von Cloudflare verwaltet wird. - Deployen Sie einen Cloudflare Worker (wie im Beispiel oben), der Inhalte aus Ihrem R2-Bucket ausliefert.
- Navigieren Sie im Cloudflare-Dashboard zu Ihrem Worker und fügen Sie eine „Custom Domain“ hinzu
oder legen Sie unter „Workers & Pages“ -> Ihre Domain -> „Workers Routes“ eine Route an, die
einen bestimmten Pfad (z. B.
files.yourdomain.com/*) auf Ihren deployten Worker-Service verweist.
Mit diesem Setup steuern Sie den Zugriff, ergänzen Caching-Header und können URLs umschreiben, alles ausgeliefert unter Ihrer eigenen Marken-Domain.
Fehlerbehandlung und Retries
Netzwerkprobleme können Uploads unterbrechen. Der einfache Retry-Wrapper unten fordert bei jedem Versuch einen neuen Key an. Eine verlorene Antwort nach einem erfolgreichen PUT kann daher ein zusätzliches Objekt hinterlassen: Setzen Sie ihn nur zusammen mit einer Bereinigungsrichtlinie in Ihrer Anwendung ein. Ein produktionsreifer Retry-Ablauf sollte einen bereits zugewiesenen Key wiederverwenden und den Abschluss serverseitig verfolgen oder einen etablierten Multipart-Uploader nutzen. Ersetzen Sie den ursprünglichen Change-Listener durch den Listener unten; registrieren Sie nicht beide.
// frontend/uploader.js - (Simplified retry logic for PUT example)
async function uploadFileWithRetry(file, maxRetries = 3) {
let attempt = 0
while (attempt <= maxRetries) {
console.log(`Upload attempt ${attempt + 1} of ${maxRetries + 1}...`)
const result = await uploadFileToR2(file) // Use the function defined earlier
if (result.success) {
return result // Success!
}
console.error(`Attempt ${attempt + 1} failed: ${result.error}`)
attempt++
if (attempt <= maxRetries) {
// Exponential backoff: 1s, 2s, 4s...
const delay = Math.pow(2, attempt - 1) * 1000
console.log(`Retrying in ${delay / 1000} seconds...`)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
console.error(`Upload failed after ${maxRetries + 1} attempts.`)
return { success: false, error: `Upload failed after ${maxRetries + 1} attempts` }
}
// Modify the event listener to use the retry function:
document.getElementById('fileInput').addEventListener('change', async (event) => {
const file = event.target.files[0]
if (file) {
const uploadProgress = document.getElementById('uploadProgress')
uploadProgress.textContent = 'Uploading...'
// Use the retry wrapper
const result = await uploadFileWithRetry(file)
// ... (update UI based on final result) ...
if (result.success) {
uploadProgress.textContent = `Upload complete! Key: ${result.key}`
} else {
uploadProgress.textContent = `Upload failed: ${result.error}`
}
}
})
Praktische Anwendungsfälle
Direkte Browser-Uploads zu Cloudflare R2 sind vorteilhaft für:
- Profilbilder und Avatare von Nutzern.
- Bildergalerien und Plattformen zum Teilen von Medien.
- Formulare zum Einreichen von Dokumenten.
- Websites mit nutzergenerierten Inhalten.
- Hosting statischer Assets, bei dem Nutzer Inhalte direkt hochladen.
Zu den Vorteilen zählen:
- Geringere Serverlast: Ihr Backend erzeugt nur vorsignierte URLs und leitet keine großen Dateien weiter.
- Geringere Latenz: Nutzer laden direkt zum Edge von Cloudflare hoch, das näher bei ihnen liegt.
- Kostenersparnis: Vermeidet Egress-Gebühren bei R2 und senkt Ihre Server-Bandbreitenkosten.
- Vereinfachte Architektur: Weniger bewegliche Teile als beim Weiterleiten von Uploads.
Fazit
Das AWS SDK v3 in Verbindung mit vorsignierten URLs bietet eine sichere und effiziente Methode, um direkte Browser-Uploads zu Cloudflare R2 zu ermöglichen. Kombiniert mit Cloudflare Workers für erweiterte Kontrolle und der Wrangler CLI für die Verwaltung bauen Sie eine robuste, skalierbare Dateiverarbeitung in Ihre Webanwendungen ein und nutzen dabei den kostengünstigen Speicher von R2.
Für komplexere Szenarien mit Dateiverarbeitung nach dem Upload bietet sich ein Dienst wie Transloadit an. Transloadit lässt sich über unseren Robot 🤖 /cloudflare/store nahtlos mit Cloudflare R2 verbinden, sodass Sie Encoding, Größenänderung, Wasserzeichen und mehr auslösen können, sobald Dateien in Ihrem R2-Bucket landen. Unser Uppy SDK bietet zudem ein robustes Upload-Erlebnis im Frontend, einschließlich Unterstützung für Multipart-Uploads an verschiedene Ziele.
