From 571bc8c17c3de8e402f9b13a52ea0c02bcaeb596 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:24:29 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 12 + README.md | 3 + SKILL.md | 385 +++++++++++++++++++++++ plugin.lock.json | 77 +++++ references/common-patterns.md | 469 +++++++++++++++++++++++++++++ references/s3-compatibility.md | 343 +++++++++++++++++++++ references/workers-api.md | 465 ++++++++++++++++++++++++++++ templates/r2-cors-config.json | 133 ++++++++ templates/r2-multipart-upload.ts | 238 +++++++++++++++ templates/r2-presigned-urls.ts | 289 ++++++++++++++++++ templates/r2-simple-upload.ts | 226 ++++++++++++++ templates/wrangler-r2-config.jsonc | 49 +++ 12 files changed, 2689 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 plugin.lock.json create mode 100644 references/common-patterns.md create mode 100644 references/s3-compatibility.md create mode 100644 references/workers-api.md create mode 100644 templates/r2-cors-config.json create mode 100644 templates/r2-multipart-upload.ts create mode 100644 templates/r2-presigned-urls.ts create mode 100644 templates/r2-simple-upload.ts create mode 100644 templates/wrangler-r2-config.jsonc diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..623e466 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-r2", + "description": "Store objects with R2s S3-compatible storage on Cloudflares edge. Use when: uploading/downloading files, configuring CORS, generating presigned URLs, multipart uploads, managing metadata, or troubleshooting R2_ERROR, CORS failures, presigned URL issues, or quota errors.", + "version": "1.0.0", + "author": { + "name": "Jeremy Dawes", + "email": "jeremy@jezweb.net" + }, + "skills": [ + "./" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..498265c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# cloudflare-r2 + +Store objects with R2s S3-compatible storage on Cloudflares edge. Use when: uploading/downloading files, configuring CORS, generating presigned URLs, multipart uploads, managing metadata, or troubleshooting R2_ERROR, CORS failures, presigned URL issues, or quota errors. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..97db31f --- /dev/null +++ b/SKILL.md @@ -0,0 +1,385 @@ +--- +name: cloudflare-r2 +description: | + Store objects with R2's S3-compatible storage on Cloudflare's edge. Use when: uploading/downloading files, configuring CORS, generating presigned URLs, multipart uploads, managing metadata, or troubleshooting R2_ERROR, CORS failures, presigned URL issues, or quota errors. +license: MIT +--- + +# Cloudflare R2 Object Storage + +**Status**: Production Ready ✅ +**Last Updated**: 2025-11-24 +**Dependencies**: cloudflare-worker-base (for Worker setup) +**Latest Versions**: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0, aws4fetch@1.0.20 + +**Recent Updates (2025)**: +- **September 2025**: R2 SQL open beta (serverless query engine for Apache Iceberg), Pipelines GA (real-time stream ingestion), Remote bindings GA (local dev connects to deployed R2) +- **May 2025**: Dashboard redesign (deeplink support, bucket settings centralization), Super Slurper 5x faster (rebuilt with Workers/Queues/Durable Objects) +- **April 2025**: R2 Data Catalog open beta (managed Apache Iceberg catalog), Event Notifications open beta (5,000 msg/s per Queue) +- **2025**: Bucket limits increased (1 million max), CRC-64/NVME checksums, Server-side encryption with customer keys, Infrequent Access storage class (beta), Oceania region, S3 API enhancements (sha256/sha1 checksums, ListParts, conditional CopyObject) + +--- + +## Quick Start (5 Minutes) + +```bash +# 1. Create bucket +npx wrangler r2 bucket create my-bucket + +# 2. Add binding to wrangler.jsonc +# { +# "r2_buckets": [{ +# "binding": "MY_BUCKET", +# "bucket_name": "my-bucket", +# "preview_bucket_name": "my-bucket-preview" // Optional: separate dev/prod +# }] +# } + +# 3. Upload/download from Worker +type Bindings = { MY_BUCKET: R2Bucket }; + +// Upload +await env.MY_BUCKET.put('file.txt', data, { + httpMetadata: { contentType: 'text/plain' } +}); + +// Download +const object = await env.MY_BUCKET.get('file.txt'); +if (!object) return c.json({ error: 'Not found' }, 404); + +return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream', + 'ETag': object.httpEtag, + }, +}); + +# 4. Deploy +npx wrangler deploy +``` + +--- + +## R2 Workers API + +### Core Methods + +```typescript +// put() - Upload objects +await env.MY_BUCKET.put('file.txt', data, { + httpMetadata: { + contentType: 'text/plain', + cacheControl: 'public, max-age=3600', + }, + customMetadata: { userId: '123' }, + md5: await crypto.subtle.digest('MD5', data), // Checksum verification +}); + +// Conditional upload (prevent overwrites) +const object = await env.MY_BUCKET.put('file.txt', data, { + onlyIf: { uploadedBefore: new Date('2020-01-01') } +}); +if (!object) return c.json({ error: 'File already exists' }, 409); + +// get() - Download objects +const object = await env.MY_BUCKET.get('file.txt'); +if (!object) return c.json({ error: 'Not found' }, 404); + +const text = await object.text(); // As string +const json = await object.json(); // As JSON +const buffer = await object.arrayBuffer(); // As ArrayBuffer + +// Range requests (partial downloads) +const partial = await env.MY_BUCKET.get('video.mp4', { + range: { offset: 0, length: 1024 * 1024 } // First 1MB +}); + +// head() - Get metadata only (no body download) +const object = await env.MY_BUCKET.head('file.txt'); +console.log(object.size, object.etag, object.customMetadata); + +// delete() - Delete objects +await env.MY_BUCKET.delete('file.txt'); // Single delete (idempotent) +await env.MY_BUCKET.delete(['file1.txt', 'file2.txt']); // Bulk delete (max 1000) + +// list() - List objects +const listed = await env.MY_BUCKET.list({ + prefix: 'images/', // Filter by prefix + limit: 100, + cursor: cursor, // Pagination + delimiter: '/', // Folder-like listing +}); + +for (const object of listed.objects) { + console.log(`${object.key}: ${object.size} bytes`); +} +``` + +--- + +## Multipart Uploads + +For files >100MB or resumable uploads. Use when: large files, browser uploads, parallelization needed. + +```typescript +// 1. Create multipart upload +const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.zip', { + httpMetadata: { contentType: 'application/zip' } +}); + +// 2. Upload parts (5MB-100MB each, max 10,000 parts) +const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId); +const part1 = await multipart.uploadPart(1, chunk1); +const part2 = await multipart.uploadPart(2, chunk2); + +// 3. Complete upload +const object = await multipart.complete([ + { partNumber: 1, etag: part1.etag }, + { partNumber: 2, etag: part2.etag }, +]); + +// 4. Abort if needed +await multipart.abort(); +``` + +**Limits**: Parts 5MB-100MB, max 10,000 parts per upload. Don't use for files <5MB (overhead). + +--- + +## Presigned URLs + +Allow clients to upload/download directly to/from R2 (bypasses Worker). Use aws4fetch library. + +```typescript +import { AwsClient } from 'aws4fetch'; + +const r2Client = new AwsClient({ + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, +}); + +const url = new URL( + `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}` +); +url.searchParams.set('X-Amz-Expires', '3600'); // 1 hour expiry + +const signed = await r2Client.sign( + new Request(url, { method: 'PUT' }), // or 'GET' for downloads + { aws: { signQuery: true } } +); + +// Client uploads directly to R2 +await fetch(signed.url, { method: 'PUT', body: file }); +``` + +**CRITICAL Security:** +- ❌ **NEVER** expose R2 access keys in client-side code +- ✅ **ALWAYS** generate presigned URLs server-side +- ✅ **ALWAYS** set expiry times (1-24 hours typical) +- ✅ **ALWAYS** add authentication before generating URLs +- ✅ **CONSIDER** scoping to user folders: `users/${userId}/${filename}` + +--- + +## CORS Configuration + +Configure CORS in bucket settings (Dashboard → R2 → Bucket → Settings → CORS Policy) before browser access. + +```json +{ + "CORSRules": [{ + "AllowedOrigins": ["https://app.example.com"], + "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"], + "AllowedHeaders": ["Content-Type", "Content-MD5", "x-amz-meta-*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + }] +} +``` + +**For presigned URLs**: CORS handled by R2 directly (configure on bucket, not Worker). + +--- + +## HTTP Metadata & Custom Metadata + +```typescript +// HTTP metadata (standard headers) +await env.MY_BUCKET.put('file.pdf', data, { + httpMetadata: { + contentType: 'application/pdf', + cacheControl: 'public, max-age=31536000, immutable', + contentDisposition: 'attachment; filename="report.pdf"', + contentEncoding: 'gzip', + }, + customMetadata: { + userId: '12345', + version: '1.0', + } // Max 2KB total, keys/values must be strings +}); + +// Read metadata +const object = await env.MY_BUCKET.head('file.pdf'); +console.log(object.httpMetadata, object.customMetadata); +``` + +--- + +## Error Handling + +### Common R2 Errors + +```typescript +try { + await env.MY_BUCKET.put(key, data); +} catch (error: any) { + const message = error.message; + + if (message.includes('R2_ERROR')) { + // Generic R2 error + } else if (message.includes('exceeded')) { + // Quota exceeded + } else if (message.includes('precondition')) { + // Conditional operation failed + } else if (message.includes('multipart')) { + // Multipart upload error + } + + console.error('R2 Error:', message); + return c.json({ error: 'Storage operation failed' }, 500); +} +``` + +### Retry Logic + +```typescript +async function r2WithRetry( + operation: () => Promise, + maxRetries = 3 +): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation(); + } catch (error: any) { + const message = error.message; + + // Retry on transient errors + const isRetryable = + message.includes('network') || + message.includes('timeout') || + message.includes('temporarily unavailable'); + + if (!isRetryable || attempt === maxRetries - 1) { + throw error; + } + + // Exponential backoff + const delay = Math.min(1000 * Math.pow(2, attempt), 5000); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw new Error('Retry logic failed'); +} + +// Usage +const object = await r2WithRetry(() => + env.MY_BUCKET.get('important-file.txt') +); +``` + +--- + +## Performance Optimization + +```typescript +// Batch delete (up to 1000 keys) +await env.MY_BUCKET.delete(['file1.txt', 'file2.txt', 'file3.txt']); + +// Range requests for large files +const partial = await env.MY_BUCKET.get('video.mp4', { + range: { offset: 0, length: 10 * 1024 * 1024 } // First 10MB +}); + +// Cache headers for immutable assets +await env.MY_BUCKET.put('static/app.abc123.js', jsData, { + httpMetadata: { cacheControl: 'public, max-age=31536000, immutable' } +}); + +// Checksums for data integrity +const md5Hash = await crypto.subtle.digest('MD5', fileData); +await env.MY_BUCKET.put('important.dat', fileData, { md5: md5Hash }); +``` + +--- + +## Best Practices Summary + +**Always Do:** +- Set `contentType` for all uploads +- Use batch delete for multiple objects (up to 1000) +- Set cache headers for static assets +- Use presigned URLs for large client uploads +- Use multipart for files >100MB +- Set CORS before browser uploads +- Set expiry times on presigned URLs (1-24 hours) +- Use `head()` when you only need metadata +- Use conditional operations to prevent overwrites + +**Never Do:** +- Never expose R2 access keys in client-side code +- Never skip `contentType` (files download as binary) +- Never delete in loops (use batch delete) +- Never skip CORS for browser uploads +- Never use multipart for small files (<5MB) +- Never delete >1000 keys in single call +- Never skip presigned URL expiry (security risk) + +--- + +## Known Issues Prevented + +| Issue | Description | How to Avoid | +|-------|-------------|--------------| +| **CORS errors in browser** | Browser can't upload/download due to missing CORS policy | Configure CORS in bucket settings before browser access | +| **Files download as binary** | Missing content-type causes browsers to download files instead of display | Always set `httpMetadata.contentType` on upload | +| **Presigned URL expiry** | URLs never expire, posing security risk | Always set `X-Amz-Expires` (1-24 hours typical) | +| **Multipart upload limits** | Parts exceed 100MB or >10,000 parts | Keep parts 5MB-100MB, max 10,000 parts per upload | +| **Bulk delete limits** | Trying to delete >1000 keys fails | Chunk deletes into batches of 1000 | +| **Custom metadata overflow** | Metadata exceeds 2KB limit | Keep custom metadata under 2KB total | + +--- + +## Wrangler Commands Reference + +```bash +# Bucket management +wrangler r2 bucket create +wrangler r2 bucket list +wrangler r2 bucket delete + +# Object management +wrangler r2 object put / --file= +wrangler r2 object get / --file= +wrangler r2 object delete / + +# List objects +wrangler r2 object list +wrangler r2 object list --prefix="folder/" +``` + +--- + +## Official Documentation + +- **R2 Overview**: https://developers.cloudflare.com/r2/ +- **Get Started**: https://developers.cloudflare.com/r2/get-started/ +- **Workers API**: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ +- **Multipart Upload**: https://developers.cloudflare.com/r2/api/workers/workers-multipart-usage/ +- **Presigned URLs**: https://developers.cloudflare.com/r2/api/s3/presigned-urls/ +- **CORS Configuration**: https://developers.cloudflare.com/r2/buckets/cors/ +- **Public Buckets**: https://developers.cloudflare.com/r2/buckets/public-buckets/ + +--- + +**Ready to store with R2!** 🚀 diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..3174661 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,77 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:jezweb/claude-skills:skills/cloudflare-r2", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "be2a7e22a7360db75b5abca56d1205fc69d236cd", + "treeHash": "761ddf23acde8eaec331b15b7c3aa3956d8ecfa2dcc41b10750ca7624adc45b3", + "generatedAt": "2025-11-28T10:18:56.163002Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "cloudflare-r2", + "description": "Store objects with R2s S3-compatible storage on Cloudflares edge. Use when: uploading/downloading files, configuring CORS, generating presigned URLs, multipart uploads, managing metadata, or troubleshooting R2_ERROR, CORS failures, presigned URL issues, or quota errors.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "b4207461ca05f93fa1eeeebe79b75069fd5c9fb6ba82cab57bb9857ed86c0ff6" + }, + { + "path": "SKILL.md", + "sha256": "abc8b8ba5a549b58859dd7cdd4c5815badb81ccc849c60f6c0a51031cf8ef52e" + }, + { + "path": "references/s3-compatibility.md", + "sha256": "555b9a7164b23e69f1be92a72158bc386051784f05ca7ef2596c5c22ac76e25c" + }, + { + "path": "references/common-patterns.md", + "sha256": "cce79b3888e0b4337e34d387d2a3bfc3e0f042091f23138a63f8e23279cb6d88" + }, + { + "path": "references/workers-api.md", + "sha256": "9525c983661590ff04bafc0ea0bc09f147e9aba4c10820545079de2e36070884" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "7c9c5f8a4390fbabc3999079abbb2684fc87df820352678282f8c5f3315bd1f6" + }, + { + "path": "templates/r2-simple-upload.ts", + "sha256": "81eabe6a55ecf5908eac02db6b07523ae20c5cd20326c7575f29e2b53f6098d9" + }, + { + "path": "templates/r2-presigned-urls.ts", + "sha256": "9477be62fa75bc5a6aafa0fc7d240ea221e2ab97662e39cef375ac8c94720716" + }, + { + "path": "templates/r2-cors-config.json", + "sha256": "503d55e97fe6319b15159fcfb59e0bf2a466fef1082df346e65135cd99470194" + }, + { + "path": "templates/r2-multipart-upload.ts", + "sha256": "c1cb00916c20fbbf026e4abd428e430a4d44efcf03136cb5241d9e638a42e3a5" + }, + { + "path": "templates/wrangler-r2-config.jsonc", + "sha256": "5339f8b4bd0a5f7b991c2029666270cc15ab0a8638509ac6ed077e7931e46134" + } + ], + "dirSha256": "761ddf23acde8eaec331b15b7c3aa3956d8ecfa2dcc41b10750ca7624adc45b3" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/references/common-patterns.md b/references/common-patterns.md new file mode 100644 index 0000000..7f9390f --- /dev/null +++ b/references/common-patterns.md @@ -0,0 +1,469 @@ +# R2 Common Patterns + +**Last Updated**: 2025-10-21 + +--- + +## Image Upload & Serving + +### Upload with Automatic Content-Type Detection + +```typescript +import { Hono } from 'hono'; + +type Bindings = { + IMAGES: R2Bucket; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +app.post('/upload/image', async (c) => { + const formData = await c.req.formData(); + const file = formData.get('image') as File; + + if (!file) { + return c.json({ error: 'No file provided' }, 400); + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + return c.json({ error: 'Invalid file type' }, 400); + } + + // Generate unique filename + const extension = file.name.split('.').pop(); + const filename = `${crypto.randomUUID()}.${extension}`; + const key = `images/${filename}`; + + // Upload to R2 + const arrayBuffer = await file.arrayBuffer(); + const object = await c.env.IMAGES.put(key, arrayBuffer, { + httpMetadata: { + contentType: file.type, + cacheControl: 'public, max-age=31536000, immutable', + }, + customMetadata: { + originalFilename: file.name, + uploadedAt: new Date().toISOString(), + }, + }); + + return c.json({ + success: true, + url: `/images/${filename}`, + key: object.key, + size: object.size, + }); +}); + +// Serve image +app.get('/images/:filename', async (c) => { + const filename = c.req.param('filename'); + const key = `images/${filename}`; + + const object = await c.env.IMAGES.get(key); + + if (!object) { + return c.json({ error: 'Image not found' }, 404); + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType || 'image/jpeg', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': object.httpEtag, + }, + }); +}); + +export default app; +``` + +--- + +## User File Storage with Folder Organization + +```typescript +app.post('/users/:userId/files', async (c) => { + const userId = c.req.param('userId'); + const formData = await c.req.formData(); + const file = formData.get('file') as File; + + if (!file) { + return c.json({ error: 'No file provided' }, 400); + } + + // Organize by user ID and date + const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const filename = file.name; + const key = `users/${userId}/${date}/${filename}`; + + const arrayBuffer = await file.arrayBuffer(); + const object = await c.env.MY_BUCKET.put(key, arrayBuffer, { + httpMetadata: { + contentType: file.type, + contentDisposition: `attachment; filename="${filename}"`, + }, + customMetadata: { + userId, + uploadDate: date, + originalSize: file.size.toString(), + }, + }); + + return c.json({ + success: true, + fileId: object.key, + size: object.size, + }); +}); + +// List user's files +app.get('/users/:userId/files', async (c) => { + const userId = c.req.param('userId'); + const cursor = c.req.query('cursor'); + + const listed = await c.env.MY_BUCKET.list({ + prefix: `users/${userId}/`, + limit: 100, + cursor: cursor || undefined, + }); + + return c.json({ + files: listed.objects.map(obj => ({ + key: obj.key, + filename: obj.key.split('/').pop(), + size: obj.size, + uploaded: obj.uploaded, + metadata: obj.customMetadata, + })), + hasMore: listed.truncated, + cursor: listed.cursor, + }); +}); +``` + +--- + +## Thumbnail Generation & Caching + +```typescript +app.get('/thumbnails/:filename', async (c) => { + const filename = c.req.param('filename'); + const width = parseInt(c.req.query('w') || '200'); + const height = parseInt(c.req.query('h') || '200'); + + const thumbnailKey = `thumbnails/${width}x${height}/${filename}`; + + // Check if thumbnail already exists + let thumbnail = await c.env.IMAGES.get(thumbnailKey); + + if (!thumbnail) { + // Get original image + const original = await c.env.IMAGES.get(`images/${filename}`); + + if (!original) { + return c.json({ error: 'Image not found' }, 404); + } + + // Generate thumbnail (using Cloudflare Images or external service) + // This is a placeholder - use actual image processing + const thumbnailData = await generateThumbnail( + await original.arrayBuffer(), + width, + height + ); + + // Store thumbnail for future requests + await c.env.IMAGES.put(thumbnailKey, thumbnailData, { + httpMetadata: { + contentType: 'image/jpeg', + cacheControl: 'public, max-age=31536000, immutable', + }, + }); + + thumbnail = await c.env.IMAGES.get(thumbnailKey); + } + + return new Response(thumbnail!.body, { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +}); + +async function generateThumbnail( + imageData: ArrayBuffer, + width: number, + height: number +): Promise { + // Use Cloudflare Images API, sharp, or other image processing library + // This is a placeholder + return imageData; +} +``` + +--- + +## Versioned File Storage + +```typescript +app.put('/files/:filename', async (c) => { + const filename = c.req.param('filename'); + const body = await c.req.arrayBuffer(); + + // Get current version number + const versionKey = `versions/${filename}/latest`; + const currentVersion = await c.env.MY_BUCKET.head(versionKey); + + let version = 1; + if (currentVersion?.customMetadata?.version) { + version = parseInt(currentVersion.customMetadata.version) + 1; + } + + // Store new version + const versionedKey = `versions/${filename}/v${version}`; + await c.env.MY_BUCKET.put(versionedKey, body, { + httpMetadata: { + contentType: c.req.header('content-type') || 'application/octet-stream', + }, + customMetadata: { + version: version.toString(), + createdAt: new Date().toISOString(), + }, + }); + + // Update "latest" pointer + await c.env.MY_BUCKET.put(versionKey, body, { + httpMetadata: { + contentType: c.req.header('content-type') || 'application/octet-stream', + }, + customMetadata: { + version: version.toString(), + latestVersion: 'true', + }, + }); + + return c.json({ + success: true, + version, + key: versionedKey, + }); +}); + +// Get specific version +app.get('/files/:filename/v/:version', async (c) => { + const filename = c.req.param('filename'); + const version = c.req.param('version'); + + const key = `versions/${filename}/v${version}`; + const object = await c.env.MY_BUCKET.get(key); + + if (!object) { + return c.json({ error: 'Version not found' }, 404); + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream', + }, + }); +}); +``` + +--- + +## Backup & Archive Pattern + +```typescript +// Daily database backup to R2 +async function backupDatabase(env: Bindings) { + const date = new Date().toISOString().split('T')[0]; + const key = `backups/database/${date}/dump.sql.gz`; + + // Generate backup (placeholder) + const backupData = await generateDatabaseDump(); + + await env.BACKUPS.put(key, backupData, { + httpMetadata: { + contentType: 'application/gzip', + contentEncoding: 'gzip', + }, + customMetadata: { + backupDate: date, + backupType: 'full', + database: 'production', + }, + }); + + // Delete backups older than 30 days + await cleanupOldBackups(env, 30); +} + +async function cleanupOldBackups(env: Bindings, retentionDays: number) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + const listed = await env.BACKUPS.list({ + prefix: 'backups/database/', + }); + + const oldBackups = listed.objects.filter( + obj => obj.uploaded < cutoffDate + ); + + if (oldBackups.length > 0) { + const keysToDelete = oldBackups.map(obj => obj.key); + await env.BACKUPS.delete(keysToDelete); + } +} +``` + +--- + +## Static Site Hosting with SPA Fallback + +```typescript +app.get('/*', async (c) => { + const url = new URL(c.req.url); + let key = url.pathname.slice(1); // Remove leading slash + + if (key === '' || key.endsWith('/')) { + key += 'index.html'; + } + + let object = await c.env.STATIC.get(key); + + // SPA fallback: if file not found, try index.html + if (!object && !key.includes('.')) { + object = await c.env.STATIC.get('index.html'); + } + + if (!object) { + return c.json({ error: 'Not found' }, 404); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + + // Set appropriate cache headers + if (key.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$/)) { + headers.set('Cache-Control', 'public, max-age=31536000, immutable'); + } else { + headers.set('Cache-Control', 'public, max-age=3600, must-revalidate'); + } + + return new Response(object.body, { headers }); +}); +``` + +--- + +## CDN with Origin Fallback + +```typescript +// Use R2 as CDN with external origin fallback +app.get('/cdn/*', async (c) => { + const url = new URL(c.req.url); + const key = url.pathname.replace('/cdn/', ''); + + // Check R2 cache first + let object = await c.env.CDN_CACHE.get(key); + + if (!object) { + // Fetch from origin + const originUrl = `https://origin.example.com/${key}`; + const response = await fetch(originUrl); + + if (!response.ok) { + return c.json({ error: 'Not found on origin' }, 404); + } + + const data = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + + // Cache in R2 + await c.env.CDN_CACHE.put(key, data, { + httpMetadata: { + contentType, + cacheControl: 'public, max-age=31536000', + }, + }); + + object = await c.env.CDN_CACHE.get(key); + } + + return new Response(object!.body, { + headers: { + 'Content-Type': object!.httpMetadata?.contentType || 'application/octet-stream', + 'Cache-Control': 'public, max-age=31536000', + 'X-Cache': object ? 'HIT' : 'MISS', + }, + }); +}); +``` + +--- + +## Signed Upload with Quota Limits + +```typescript +app.post('/request-upload', async (c) => { + const { userId, filename, fileSize } = await c.req.json(); + + // Check user's quota + const quota = await getUserQuota(userId); + + if (quota.used + fileSize > quota.total) { + return c.json({ error: 'Quota exceeded' }, 403); + } + + // Generate presigned URL + const r2Client = new AwsClient({ + accessKeyId: c.env.R2_ACCESS_KEY_ID, + secretAccessKey: c.env.R2_SECRET_ACCESS_KEY, + }); + + const key = `users/${userId}/${filename}`; + const url = new URL( + `https://my-bucket.${c.env.ACCOUNT_ID}.r2.cloudflarestorage.com/${key}` + ); + + url.searchParams.set('X-Amz-Expires', '3600'); + + const signed = await r2Client.sign( + new Request(url, { method: 'PUT' }), + { aws: { signQuery: true } } + ); + + return c.json({ + uploadUrl: signed.url, + expiresIn: 3600, + }); +}); + +async function getUserQuota(userId: string) { + // Query database for user quota + return { + used: 1024 * 1024 * 100, // 100MB used + total: 1024 * 1024 * 1024, // 1GB total + }; +} +``` + +--- + +## Best Practices Summary + +1. **Use meaningful key prefixes** for organization (`users/{id}/`, `images/`, `backups/`) +2. **Set appropriate cache headers** for static assets +3. **Store metadata** for tracking and filtering +4. **Use bulk delete** instead of loops +5. **Implement cleanup** for old/temporary files +6. **Add authentication** before presigned URL generation +7. **Validate file types** before uploading +8. **Use UUIDs** for unique filenames +9. **Set expiry times** on presigned URLs +10. **Monitor quota** to prevent overages diff --git a/references/s3-compatibility.md b/references/s3-compatibility.md new file mode 100644 index 0000000..96f742c --- /dev/null +++ b/references/s3-compatibility.md @@ -0,0 +1,343 @@ +# R2 S3 API Compatibility + +**Last Updated**: 2025-10-21 +**Official Docs**: https://developers.cloudflare.com/r2/api/s3/api/ + +--- + +## Overview + +R2 implements a large portion of the Amazon S3 API, allowing you to use existing S3 SDKs and tools. + +**S3 Endpoint Format:** +``` +https://.r2.cloudflarestorage.com +``` + +--- + +## Supported S3 Operations + +### Bucket Operations +- ✅ ListBuckets +- ❌ CreateBucket (use Cloudflare Dashboard or Wrangler) +- ❌ DeleteBucket (use Cloudflare Dashboard or Wrangler) + +### Object Operations +- ✅ GetObject +- ✅ PutObject +- ✅ DeleteObject +- ✅ DeleteObjects (bulk delete, max 1000) +- ✅ HeadObject +- ✅ ListObjectsV2 +- ✅ CopyObject +- ✅ UploadPart +- ✅ CreateMultipartUpload +- ✅ CompleteMultipartUpload +- ✅ AbortMultipartUpload +- ✅ ListMultipartUploads +- ✅ ListParts + +### Presigned URLs +- ✅ GetObject (download) +- ✅ PutObject (upload) +- ✅ UploadPart (multipart) + +### Not Supported +- ❌ Versioning +- ❌ Object Lock +- ❌ ACLs (use CORS instead) +- ❌ Bucket policies +- ❌ Object tagging (use custom metadata) +- ❌ Server-side encryption config (use SSE-C instead) + +--- + +## Using AWS SDK for JavaScript + +### Installation + +```bash +npm install @aws-sdk/client-s3 +npm install @aws-sdk/s3-request-presigner +``` + +### Basic Usage + +```typescript +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; + +// Create S3 client for R2 +const s3Client = new S3Client({ + region: 'auto', + endpoint: `https://.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: '', + secretAccessKey: '', + }, +}); + +// Upload object +const uploadParams = { + Bucket: 'my-bucket', + Key: 'path/to/file.txt', + Body: 'Hello, R2!', + ContentType: 'text/plain', +}; + +await s3Client.send(new PutObjectCommand(uploadParams)); + +// Download object +const downloadParams = { + Bucket: 'my-bucket', + Key: 'path/to/file.txt', +}; + +const response = await s3Client.send(new GetObjectCommand(downloadParams)); +const text = await response.Body.transformToString(); +``` + +### Presigned URLs with AWS SDK + +```typescript +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; + +// Generate presigned upload URL +const uploadCommand = new PutObjectCommand({ + Bucket: 'my-bucket', + Key: 'uploads/file.jpg', +}); + +const uploadUrl = await getSignedUrl(s3Client, uploadCommand, { + expiresIn: 3600, // 1 hour +}); + +// Generate presigned download URL +const downloadCommand = new GetObjectCommand({ + Bucket: 'my-bucket', + Key: 'uploads/file.jpg', +}); + +const downloadUrl = await getSignedUrl(s3Client, downloadCommand, { + expiresIn: 3600, +}); +``` + +--- + +## Using aws4fetch (Lightweight Alternative) + +### Installation + +```bash +npm install aws4fetch +``` + +### Usage + +```typescript +import { AwsClient } from 'aws4fetch'; + +const r2Client = new AwsClient({ + accessKeyId: '', + secretAccessKey: '', +}); + +const endpoint = `https://.r2.cloudflarestorage.com`; + +// Upload object +await r2Client.fetch(`${endpoint}/my-bucket/file.txt`, { + method: 'PUT', + body: 'Hello, R2!', + headers: { + 'Content-Type': 'text/plain', + }, +}); + +// Download object +const response = await r2Client.fetch(`${endpoint}/my-bucket/file.txt`); +const text = await response.text(); + +// Delete object +await r2Client.fetch(`${endpoint}/my-bucket/file.txt`, { + method: 'DELETE', +}); + +// List objects +const listResponse = await r2Client.fetch( + `${endpoint}/my-bucket?list-type=2&max-keys=100` +); +const xml = await listResponse.text(); +``` + +### Presigned URLs with aws4fetch + +```typescript +import { AwsClient } from 'aws4fetch'; + +const r2Client = new AwsClient({ + accessKeyId: '', + secretAccessKey: '', +}); + +const url = new URL( + `https://.r2.cloudflarestorage.com/my-bucket/file.txt` +); + +// Set expiry (in seconds) +url.searchParams.set('X-Amz-Expires', '3600'); + +// Sign for PUT (upload) +const signedUpload = await r2Client.sign( + new Request(url, { method: 'PUT' }), + { aws: { signQuery: true } } +); + +console.log(signedUpload.url); + +// Sign for GET (download) +const signedDownload = await r2Client.sign( + new Request(url, { method: 'GET' }), + { aws: { signQuery: true } } +); + +console.log(signedDownload.url); +``` + +--- + +## S3 vs R2 Workers API Comparison + +| Feature | S3 API | R2 Workers API | +|---------|--------|----------------| +| **Performance** | External network call | Native binding (faster) | +| **Authentication** | Access keys required | Automatic via binding | +| **Presigned URLs** | Supported | Requires S3 API + access keys | +| **Multipart Upload** | Full S3 API | Simplified Workers API | +| **Custom Metadata** | `x-amz-meta-*` headers | `customMetadata` object | +| **Conditional Ops** | S3 headers | `onlyIf` object | +| **Size Limits** | 5GB per PUT | 100MB per PUT (200MB Business, 500MB Enterprise) | + +--- + +## When to Use S3 API vs Workers API + +### Use S3 API when: +- ✅ Migrating from AWS S3 +- ✅ Using existing S3 tools (aws-cli, s3cmd) +- ✅ Generating presigned URLs +- ✅ Need S3 compatibility for external systems + +### Use Workers API when: +- ✅ Building new applications on Cloudflare +- ✅ Need better performance (native binding) +- ✅ Don't want to manage access keys +- ✅ Using R2 from Workers + +--- + +## R2-Specific Extensions + +R2 adds some extensions to the S3 API: + +### Conditional Operations + +```typescript +// Only upload if file doesn't exist +await s3Client.send(new PutObjectCommand({ + Bucket: 'my-bucket', + Key: 'file.txt', + Body: data, + IfUnmodifiedSince: new Date('2020-01-01'), // Before R2 existed +})); +``` + +### Storage Class + +R2 currently only supports 'Standard' storage class. + +```typescript +await s3Client.send(new PutObjectCommand({ + Bucket: 'my-bucket', + Key: 'file.txt', + Body: data, + StorageClass: 'STANDARD', +})); +``` + +--- + +## Migration from S3 + +### 1. Update Endpoint + +```diff +const s3Client = new S3Client({ + region: 'auto', +- endpoint: 'https://s3.amazonaws.com', ++ endpoint: 'https://.r2.cloudflarestorage.com', + credentials: { +- accessKeyId: process.env.AWS_ACCESS_KEY_ID, +- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, ++ accessKeyId: process.env.R2_ACCESS_KEY_ID, ++ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, + }, +}); +``` + +### 2. Remove Unsupported Features + +```diff +await s3Client.send(new PutObjectCommand({ + Bucket: 'my-bucket', + Key: 'file.txt', + Body: data, +- ACL: 'public-read', // ❌ Not supported +- Tagging: 'key=value', // ❌ Not supported (use custom metadata) ++ Metadata: { // ✅ Use custom metadata instead ++ visibility: 'public', ++ }, +})); +``` + +### 3. Use CORS Instead of ACLs + +R2 doesn't support S3 ACLs. Use CORS policies instead for browser access. + +--- + +## Common Issues + +### Issue: SignatureDoesNotMatch + +**Cause:** Incorrect access keys or endpoint URL + +**Fix:** +- Verify access key ID and secret +- Ensure endpoint includes your account ID +- Check region is set to 'auto' + +### Issue: Presigned URLs Don't Work with Custom Domains + +**Cause:** Presigned URLs only work with R2 S3 endpoint + +**Fix:** +- Use `.r2.cloudflarestorage.com` endpoint +- Or use Worker with R2 binding for custom domains + +### Issue: Upload Size Exceeds Limit + +**Cause:** S3 API PUT has 5GB limit, but R2 Workers has 100-500MB limit + +**Fix:** +- Use multipart upload for large files +- Or use S3 API directly (not through Worker) + +--- + +## Official Resources + +- **S3 API Compatibility**: https://developers.cloudflare.com/r2/api/s3/api/ +- **AWS SDK Examples**: https://developers.cloudflare.com/r2/examples/aws/ +- **Presigned URLs**: https://developers.cloudflare.com/r2/api/s3/presigned-urls/ diff --git a/references/workers-api.md b/references/workers-api.md new file mode 100644 index 0000000..f241f94 --- /dev/null +++ b/references/workers-api.md @@ -0,0 +1,465 @@ +# R2 Workers API Complete Reference + +**Last Updated**: 2025-10-21 +**Official Docs**: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ + +--- + +## R2Bucket Methods + +### put() + +Upload an object to R2. + +```typescript +put( + key: string, + value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, + options?: R2PutOptions +): Promise +``` + +**Parameters:** +- `key` - Object key (path) in the bucket +- `value` - Object data +- `options` - Optional upload options + +**Returns:** +- `R2Object` - Metadata of uploaded object +- `null` - If precondition failed (onlyIf clause) + +**Options (R2PutOptions):** +```typescript +interface R2PutOptions { + httpMetadata?: R2HTTPMetadata; + customMetadata?: Record; + md5?: ArrayBuffer; + sha1?: ArrayBuffer; + sha256?: ArrayBuffer; + sha384?: ArrayBuffer; + sha512?: ArrayBuffer; + onlyIf?: R2Conditional; + storageClass?: 'Standard'; +} +``` + +--- + +### get() + +Download an object from R2. + +```typescript +get( + key: string, + options?: R2GetOptions +): Promise +``` + +**Parameters:** +- `key` - Object key (path) in the bucket +- `options` - Optional download options + +**Returns:** +- `R2ObjectBody` - Object with metadata and body stream +- `null` - If object doesn't exist or precondition failed + +**Options (R2GetOptions):** +```typescript +interface R2GetOptions { + onlyIf?: R2Conditional | Headers; + range?: R2Range; +} +``` + +--- + +### head() + +Get object metadata without downloading body. + +```typescript +head(key: string): Promise +``` + +**Parameters:** +- `key` - Object key (path) in the bucket + +**Returns:** +- `R2Object` - Object metadata only +- `null` - If object doesn't exist + +**Use Cases:** +- Check if file exists +- Get file size +- Get last modified date +- Validate etag + +--- + +### delete() + +Delete one or more objects. + +```typescript +delete(key: string | string[]): Promise +``` + +**Parameters:** +- `key` - Single key or array of keys (max 1000) + +**Returns:** +- `void` - Always succeeds (idempotent) + +**Notes:** +- No error if object doesn't exist +- Can delete up to 1000 objects at once +- Deletes are strongly consistent + +--- + +### list() + +List objects in the bucket. + +```typescript +list(options?: R2ListOptions): Promise +``` + +**Parameters:** +- `options` - Optional listing options + +**Returns:** +- `R2Objects` - List of objects and metadata + +**Options (R2ListOptions):** +```typescript +interface R2ListOptions { + limit?: number; // Max 1000, default 1000 + prefix?: string; // Filter by prefix + cursor?: string; // Pagination cursor + delimiter?: string; // Folder delimiter (usually '/') + include?: ('httpMetadata' | 'customMetadata')[]; +} +``` + +**Response (R2Objects):** +```typescript +interface R2Objects { + objects: R2Object[]; // Array of objects + truncated: boolean; // true if more results exist + cursor?: string; // Cursor for next page + delimitedPrefixes: string[]; // "Folder" names (if delimiter used) +} +``` + +--- + +### createMultipartUpload() + +Create a new multipart upload. + +```typescript +createMultipartUpload( + key: string, + options?: R2MultipartOptions +): Promise +``` + +**Parameters:** +- `key` - Object key for the upload +- `options` - Optional metadata + +**Returns:** +- `R2MultipartUpload` - Object for managing the upload + +**Options (R2MultipartOptions):** +```typescript +interface R2MultipartOptions { + httpMetadata?: R2HTTPMetadata; + customMetadata?: Record; +} +``` + +--- + +### resumeMultipartUpload() + +Resume an existing multipart upload. + +```typescript +resumeMultipartUpload( + key: string, + uploadId: string +): R2MultipartUpload +``` + +**Parameters:** +- `key` - Object key for the upload +- `uploadId` - Upload ID from createMultipartUpload() + +**Returns:** +- `R2MultipartUpload` - Object for managing the upload + +**Notes:** +- Does NOT validate uploadId or key +- No network request made +- Use to continue an upload after Worker restart + +--- + +## R2Object Interface + +Metadata for an R2 object. + +```typescript +interface R2Object { + key: string; // Object key + version: string; // Version ID + size: number; // Size in bytes + etag: string; // ETag (without quotes) + httpEtag: string; // ETag with quotes (RFC 9110) + uploaded: Date; // Upload timestamp + httpMetadata?: R2HTTPMetadata; // HTTP metadata + customMetadata?: Record; // Custom metadata + range?: R2Range; // Range (if partial) + checksums?: R2Checksums; // Checksums + storageClass: 'Standard'; // Storage class + ssecKeyMd5?: string; // SSE-C key hash + + writeHttpMetadata(headers: Headers): void; // Apply metadata to headers +} +``` + +--- + +## R2ObjectBody Interface + +Extends R2Object with body stream and read methods. + +```typescript +interface R2ObjectBody extends R2Object { + body: ReadableStream; // Object body stream + bodyUsed: boolean; // Whether body consumed + + arrayBuffer(): Promise; // Read as ArrayBuffer + text(): Promise; // Read as text + json(): Promise; // Read as JSON + blob(): Promise; // Read as Blob +} +``` + +--- + +## R2MultipartUpload Interface + +Manage a multipart upload. + +```typescript +interface R2MultipartUpload { + key: string; // Object key + uploadId: string; // Upload ID + + uploadPart( + partNumber: number, + value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, + options?: R2MultipartOptions + ): Promise; + + abort(): Promise; + complete(uploadedParts: R2UploadedPart[]): Promise; +} +``` + +**Methods:** + +- **uploadPart()** - Upload a single part (1-10,000) +- **abort()** - Cancel the multipart upload +- **complete()** - Finish upload with list of parts + +--- + +## R2UploadedPart Interface + +Metadata for an uploaded part. + +```typescript +interface R2UploadedPart { + partNumber: number; // Part number (1-10,000) + etag: string; // Part ETag +} +``` + +--- + +## R2HTTPMetadata Interface + +HTTP headers for object. + +```typescript +interface R2HTTPMetadata { + contentType?: string; // Content-Type header + contentLanguage?: string; // Content-Language header + contentDisposition?: string; // Content-Disposition header + contentEncoding?: string; // Content-Encoding header + cacheControl?: string; // Cache-Control header + cacheExpiry?: Date; // Expires header +} +``` + +--- + +## R2Conditional Interface + +Conditional operations (onlyIf clause). + +```typescript +interface R2Conditional { + etagMatches?: string; // If-Match + etagDoesNotMatch?: string; // If-None-Match + uploadedBefore?: Date; // If-Unmodified-Since + uploadedAfter?: Date; // If-Modified-Since +} +``` + +**Alternatively, pass a Headers object with:** +- `If-Match` +- `If-None-Match` +- `If-Modified-Since` +- `If-Unmodified-Since` + +--- + +## R2Range Interface + +Byte range for partial downloads. + +```typescript +interface R2Range { + offset?: number; // Start byte + length?: number; // Number of bytes + suffix?: number; // Last N bytes +} +``` + +**Examples:** +```typescript +// First 1000 bytes +{ offset: 0, length: 1000 } + +// Bytes 100-200 +{ offset: 100, length: 100 } + +// From byte 1000 to end +{ offset: 1000 } + +// Last 500 bytes +{ suffix: 500 } +``` + +--- + +## R2Checksums Interface + +Stored checksums for object. + +```typescript +interface R2Checksums { + md5?: ArrayBuffer; + sha1?: ArrayBuffer; + sha256?: ArrayBuffer; + sha384?: ArrayBuffer; + sha512?: ArrayBuffer; +} +``` + +--- + +## Complete Example + +```typescript +import { Hono } from 'hono'; + +type Bindings = { + MY_BUCKET: R2Bucket; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// Upload with all metadata +app.put('/files/:key', async (c) => { + const key = c.req.param('key'); + const body = await c.req.arrayBuffer(); + + const object = await c.env.MY_BUCKET.put(key, body, { + httpMetadata: { + contentType: c.req.header('content-type') || 'application/octet-stream', + cacheControl: 'public, max-age=3600', + contentDisposition: `attachment; filename="${key}"`, + }, + customMetadata: { + uploadedBy: 'api', + uploadedAt: new Date().toISOString(), + }, + onlyIf: { + // Only upload if file doesn't exist + uploadedBefore: new Date('2020-01-01'), + }, + }); + + if (!object) { + return c.json({ error: 'File already exists' }, 409); + } + + return c.json({ + key: object.key, + size: object.size, + etag: object.etag, + }); +}); + +// Download with range support +app.get('/files/:key', async (c) => { + const key = c.req.param('key'); + const rangeHeader = c.req.header('range'); + + let options: R2GetOptions | undefined; + + if (rangeHeader) { + // Parse range header: bytes=0-1000 + const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); + if (match) { + const start = parseInt(match[1]); + const end = match[2] ? parseInt(match[2]) : undefined; + options = { + range: { + offset: start, + length: end ? end - start + 1 : undefined, + }, + }; + } + } + + const object = await c.env.MY_BUCKET.get(key, options); + + if (!object) { + return c.json({ error: 'Not found' }, 404); + } + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + + if (object.range) { + headers.set('content-range', `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`); + return new Response(object.body, { + status: 206, + headers, + }); + } + + return new Response(object.body, { headers }); +}); + +export default app; +``` diff --git a/templates/r2-cors-config.json b/templates/r2-cors-config.json new file mode 100644 index 0000000..afee416 --- /dev/null +++ b/templates/r2-cors-config.json @@ -0,0 +1,133 @@ +{ + "_comment": "R2 CORS Policy Examples - Apply via Cloudflare Dashboard", + "_instructions": [ + "1. Go to Cloudflare Dashboard → R2", + "2. Select your bucket", + "3. Go to Settings tab", + "4. Under CORS Policy → Add CORS policy", + "5. Paste one of the configurations below", + "6. Save" + ], + + "public_assets_all_origins": { + "CORSRules": [ + { + "AllowedOrigins": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["Range"], + "MaxAgeSeconds": 3600 + } + ] + }, + + "public_assets_specific_origin": { + "CORSRules": [ + { + "AllowedOrigins": ["https://example.com", "https://www.example.com"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["Range"], + "MaxAgeSeconds": 3600 + } + ] + }, + + "file_uploads": { + "CORSRules": [ + { + "AllowedOrigins": ["https://app.example.com"], + "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"], + "AllowedHeaders": [ + "Content-Type", + "Content-MD5", + "Content-Disposition", + "x-amz-meta-*" + ], + "ExposeHeaders": ["ETag", "x-amz-version-id"], + "MaxAgeSeconds": 3600 + } + ] + }, + + "presigned_urls": { + "_comment": "For presigned URL uploads from browser", + "CORSRules": [ + { + "AllowedOrigins": ["https://app.example.com"], + "AllowedMethods": ["PUT", "POST"], + "AllowedHeaders": [ + "Content-Type", + "Content-MD5", + "x-amz-*" + ], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } + ] + }, + + "multiple_domains": { + "CORSRules": [ + { + "AllowedOrigins": [ + "https://app.example.com", + "https://admin.example.com", + "https://staging.example.com" + ], + "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["ETag", "Content-Length"], + "MaxAgeSeconds": 86400 + } + ] + }, + + "development_localhost": { + "_comment": "For local development only - DO NOT USE IN PRODUCTION", + "CORSRules": [ + { + "AllowedOrigins": ["http://localhost:3000", "http://localhost:5173"], + "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } + ] + }, + + "strict_security": { + "_comment": "Minimal CORS for maximum security", + "CORSRules": [ + { + "AllowedOrigins": ["https://app.example.com"], + "AllowedMethods": ["GET"], + "AllowedHeaders": ["Range"], + "MaxAgeSeconds": 3600 + } + ] + }, + + "cdn_and_api": { + "_comment": "Separate rules for CDN assets and API uploads", + "CORSRules": [ + { + "_comment": "Rule for CDN/static assets", + "AllowedOrigins": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["Range"], + "MaxAgeSeconds": 86400 + }, + { + "_comment": "Rule for authenticated API uploads", + "AllowedOrigins": ["https://app.example.com"], + "AllowedMethods": ["PUT", "POST", "DELETE"], + "AllowedHeaders": [ + "Content-Type", + "Authorization", + "x-amz-meta-*" + ], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } + ] + } +} diff --git a/templates/r2-multipart-upload.ts b/templates/r2-multipart-upload.ts new file mode 100644 index 0000000..b24c4c5 --- /dev/null +++ b/templates/r2-multipart-upload.ts @@ -0,0 +1,238 @@ +/** + * R2 Multipart Upload Worker + * + * Enables large file uploads (>100MB) with: + * - Resumable uploads + * - Parallel part uploads + * - Progress tracking + * - Abort capability + * + * Flow: + * 1. POST /mpu/create - Create multipart upload + * 2. PUT /mpu/upload-part - Upload individual parts + * 3. POST /mpu/complete - Complete the upload + * 4. DELETE /mpu/abort - Abort the upload (optional) + */ + +import { Hono } from 'hono'; + +type Bindings = { + MY_BUCKET: R2Bucket; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// Create multipart upload +app.post('/mpu/create', async (c) => { + const { key, contentType } = await c.req.json<{ + key: string; + contentType?: string; + }>(); + + if (!key) { + return c.json({ + success: false, + error: 'Missing required field: key', + }, 400); + } + + try { + const multipart = await c.env.MY_BUCKET.createMultipartUpload(key, { + httpMetadata: { + contentType: contentType || 'application/octet-stream', + }, + }); + + return c.json({ + success: true, + key: multipart.key, + uploadId: multipart.uploadId, + }); + } catch (error: any) { + console.error('Create multipart error:', error.message); + return c.json({ + success: false, + error: 'Failed to create multipart upload', + }, 500); + } +}); + +// Upload a part +app.put('/mpu/upload-part', async (c) => { + const key = c.req.query('key'); + const uploadId = c.req.query('uploadId'); + const partNumber = parseInt(c.req.query('partNumber') || '0'); + + if (!key || !uploadId || !partNumber) { + return c.json({ + success: false, + error: 'Missing required parameters: key, uploadId, partNumber', + }, 400); + } + + if (partNumber < 1 || partNumber > 10000) { + return c.json({ + success: false, + error: 'Part number must be between 1 and 10000', + }, 400); + } + + try { + const body = await c.req.arrayBuffer(); + + // Resume the multipart upload + const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId); + + // Upload the part + const uploadedPart = await multipart.uploadPart(partNumber, body); + + return c.json({ + success: true, + partNumber: uploadedPart.partNumber, + etag: uploadedPart.etag, + }); + } catch (error: any) { + console.error('Upload part error:', error.message); + return c.json({ + success: false, + error: 'Failed to upload part', + details: error.message, + }, 500); + } +}); + +// Complete multipart upload +app.post('/mpu/complete', async (c) => { + const { key, uploadId, parts } = await c.req.json<{ + key: string; + uploadId: string; + parts: Array<{ partNumber: number; etag: string }>; + }>(); + + if (!key || !uploadId || !parts || !Array.isArray(parts)) { + return c.json({ + success: false, + error: 'Missing required fields: key, uploadId, parts', + }, 400); + } + + try { + const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId); + + // Complete the upload + const object = await multipart.complete(parts); + + return c.json({ + success: true, + key: object.key, + size: object.size, + etag: object.etag, + uploaded: object.uploaded, + }); + } catch (error: any) { + console.error('Complete multipart error:', error.message); + return c.json({ + success: false, + error: 'Failed to complete multipart upload', + details: error.message, + }, 500); + } +}); + +// Abort multipart upload +app.delete('/mpu/abort', async (c) => { + const key = c.req.query('key'); + const uploadId = c.req.query('uploadId'); + + if (!key || !uploadId) { + return c.json({ + success: false, + error: 'Missing required parameters: key, uploadId', + }, 400); + } + + try { + const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId); + await multipart.abort(); + + return c.json({ + success: true, + message: 'Multipart upload aborted', + key, + uploadId, + }); + } catch (error: any) { + console.error('Abort multipart error:', error.message); + return c.json({ + success: false, + error: 'Failed to abort multipart upload', + }, 500); + } +}); + +// Health check +app.get('/health', (c) => { + return c.json({ + status: 'healthy', + service: 'r2-multipart-worker', + timestamp: new Date().toISOString(), + }); +}); + +export default app; + +/** + * Example Python client for multipart upload: + * + * import requests + * from concurrent.futures import ThreadPoolExecutor + * + * WORKER_URL = "https://my-worker.workers.dev" + * FILE_PATH = "large-file.mp4" + * PART_SIZE = 10 * 1024 * 1024 # 10MB parts + * + * # 1. Create multipart upload + * response = requests.post(f"{WORKER_URL}/mpu/create", json={ + * "key": "uploads/large-file.mp4", + * "contentType": "video/mp4" + * }) + * data = response.json() + * upload_id = data["uploadId"] + * key = data["key"] + * + * # 2. Upload parts in parallel + * def upload_part(part_number, data): + * response = requests.put( + * f"{WORKER_URL}/mpu/upload-part", + * params={ + * "key": key, + * "uploadId": upload_id, + * "partNumber": part_number + * }, + * data=data + * ) + * return response.json() + * + * with open(FILE_PATH, 'rb') as f: + * part_number = 1 + * uploaded_parts = [] + * + * with ThreadPoolExecutor(max_workers=4) as executor: + * while True: + * chunk = f.read(PART_SIZE) + * if not chunk: + * break + * + * result = executor.submit(upload_part, part_number, chunk) + * uploaded_parts.append(result.result()) + * part_number += 1 + * + * # 3. Complete upload + * response = requests.post(f"{WORKER_URL}/mpu/complete", json={ + * "key": key, + * "uploadId": upload_id, + * "parts": uploaded_parts + * }) + * + * print(response.json()) + */ diff --git a/templates/r2-presigned-urls.ts b/templates/r2-presigned-urls.ts new file mode 100644 index 0000000..590a709 --- /dev/null +++ b/templates/r2-presigned-urls.ts @@ -0,0 +1,289 @@ +/** + * R2 Presigned URL Generator Worker + * + * Generates presigned URLs for: + * - Direct client uploads to R2 (bypasses Worker) + * - Temporary download links with expiry + * + * IMPORTANT: + * - Never expose R2 access keys in client code + * - Always generate presigned URLs server-side + * - Set appropriate expiry times (1-24 hours) + * - Add authentication before generating URLs + * + * Setup: + * 1. Create R2 API token in Cloudflare dashboard + * 2. Add secrets to wrangler: + * wrangler secret put R2_ACCESS_KEY_ID + * wrangler secret put R2_SECRET_ACCESS_KEY + * wrangler secret put ACCOUNT_ID + */ + +import { Hono } from 'hono'; +import { AwsClient } from 'aws4fetch'; + +type Bindings = { + R2_ACCESS_KEY_ID: string; + R2_SECRET_ACCESS_KEY: string; + ACCOUNT_ID: string; + MY_BUCKET: R2Bucket; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// Generate presigned upload URL +app.post('/presigned/upload', async (c) => { + // TODO: Add authentication here + // const authHeader = c.req.header('Authorization'); + // if (!authHeader) { + // return c.json({ error: 'Unauthorized' }, 401); + // } + + const { filename, expiresIn = 3600 } = await c.req.json<{ + filename: string; + expiresIn?: number; + }>(); + + if (!filename) { + return c.json({ + success: false, + error: 'Missing required field: filename', + }, 400); + } + + // Validate expiry (max 7 days) + const maxExpiry = 7 * 24 * 60 * 60; // 7 days + const validExpiry = Math.min(expiresIn, maxExpiry); + + try { + const r2Client = new AwsClient({ + accessKeyId: c.env.R2_ACCESS_KEY_ID, + secretAccessKey: c.env.R2_SECRET_ACCESS_KEY, + }); + + const bucketName = 'my-bucket'; // Replace with your bucket name + const accountId = c.env.ACCOUNT_ID; + + const url = new URL( + `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}` + ); + + // Set expiry + url.searchParams.set('X-Amz-Expires', validExpiry.toString()); + + // Sign the URL for PUT + const signed = await r2Client.sign( + new Request(url, { method: 'PUT' }), + { aws: { signQuery: true } } + ); + + return c.json({ + success: true, + uploadUrl: signed.url, + filename, + expiresIn: validExpiry, + expiresAt: new Date(Date.now() + validExpiry * 1000).toISOString(), + }); + } catch (error: any) { + console.error('Presigned upload URL error:', error.message); + return c.json({ + success: false, + error: 'Failed to generate presigned upload URL', + }, 500); + } +}); + +// Generate presigned download URL +app.post('/presigned/download', async (c) => { + // TODO: Add authentication here + // const authHeader = c.req.header('Authorization'); + // if (!authHeader) { + // return c.json({ error: 'Unauthorized' }, 401); + // } + + const { filename, expiresIn = 3600 } = await c.req.json<{ + filename: string; + expiresIn?: number; + }>(); + + if (!filename) { + return c.json({ + success: false, + error: 'Missing required field: filename', + }, 400); + } + + // Validate expiry (max 7 days) + const maxExpiry = 7 * 24 * 60 * 60; + const validExpiry = Math.min(expiresIn, maxExpiry); + + try { + // Check if file exists first + const exists = await c.env.MY_BUCKET.head(filename); + if (!exists) { + return c.json({ + success: false, + error: 'File not found', + }, 404); + } + + const r2Client = new AwsClient({ + accessKeyId: c.env.R2_ACCESS_KEY_ID, + secretAccessKey: c.env.R2_SECRET_ACCESS_KEY, + }); + + const bucketName = 'my-bucket'; // Replace with your bucket name + const accountId = c.env.ACCOUNT_ID; + + const url = new URL( + `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}` + ); + + url.searchParams.set('X-Amz-Expires', validExpiry.toString()); + + // Sign the URL for GET + const signed = await r2Client.sign( + new Request(url, { method: 'GET' }), + { aws: { signQuery: true } } + ); + + return c.json({ + success: true, + downloadUrl: signed.url, + filename, + size: exists.size, + expiresIn: validExpiry, + expiresAt: new Date(Date.now() + validExpiry * 1000).toISOString(), + }); + } catch (error: any) { + console.error('Presigned download URL error:', error.message); + return c.json({ + success: false, + error: 'Failed to generate presigned download URL', + }, 500); + } +}); + +// Generate batch presigned URLs (upload) +app.post('/presigned/upload/batch', async (c) => { + const { filenames, expiresIn = 3600 } = await c.req.json<{ + filenames: string[]; + expiresIn?: number; + }>(); + + if (!filenames || !Array.isArray(filenames)) { + return c.json({ + success: false, + error: 'Invalid request: filenames must be an array', + }, 400); + } + + const maxExpiry = 7 * 24 * 60 * 60; + const validExpiry = Math.min(expiresIn, maxExpiry); + + try { + const r2Client = new AwsClient({ + accessKeyId: c.env.R2_ACCESS_KEY_ID, + secretAccessKey: c.env.R2_SECRET_ACCESS_KEY, + }); + + const bucketName = 'my-bucket'; + const accountId = c.env.ACCOUNT_ID; + + const urls = await Promise.all( + filenames.map(async (filename) => { + const url = new URL( + `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}` + ); + url.searchParams.set('X-Amz-Expires', validExpiry.toString()); + + const signed = await r2Client.sign( + new Request(url, { method: 'PUT' }), + { aws: { signQuery: true } } + ); + + return { + filename, + uploadUrl: signed.url, + }; + }) + ); + + return c.json({ + success: true, + urls, + expiresIn: validExpiry, + expiresAt: new Date(Date.now() + validExpiry * 1000).toISOString(), + }); + } catch (error: any) { + console.error('Batch presigned URLs error:', error.message); + return c.json({ + success: false, + error: 'Failed to generate presigned URLs', + }, 500); + } +}); + +// Health check +app.get('/health', (c) => { + return c.json({ + status: 'healthy', + service: 'r2-presigned-urls', + timestamp: new Date().toISOString(), + }); +}); + +export default app; + +/** + * Example client-side upload with presigned URL: + * + * // 1. Get presigned URL from your Worker + * const response = await fetch('https://my-worker.workers.dev/presigned/upload', { + * method: 'POST', + * headers: { + * 'Content-Type': 'application/json', + * 'Authorization': 'Bearer YOUR_TOKEN' + * }, + * body: JSON.stringify({ + * filename: 'uploads/photo.jpg', + * expiresIn: 3600 + * }) + * }); + * + * const { uploadUrl } = await response.json(); + * + * // 2. Upload file directly to R2 + * const file = document.querySelector('input[type="file"]').files[0]; + * + * await fetch(uploadUrl, { + * method: 'PUT', + * body: file, + * headers: { + * 'Content-Type': file.type + * } + * }); + * + * console.log('Upload complete!'); + */ + +/** + * Wrangler setup for secrets: + * + * # Add R2 access key ID + * wrangler secret put R2_ACCESS_KEY_ID + * + * # Add R2 secret access key + * wrangler secret put R2_SECRET_ACCESS_KEY + * + * # Add account ID + * wrangler secret put ACCOUNT_ID + * + * # Create R2 API token: + * 1. Go to Cloudflare Dashboard → R2 + * 2. Click "Manage R2 API Tokens" + * 3. Create API Token with: + * - Permissions: Object Read & Write + * - Buckets: Specific bucket or all buckets + * 4. Save the Access Key ID and Secret Access Key + */ diff --git a/templates/r2-simple-upload.ts b/templates/r2-simple-upload.ts new file mode 100644 index 0000000..255a135 --- /dev/null +++ b/templates/r2-simple-upload.ts @@ -0,0 +1,226 @@ +/** + * Simple R2 Upload/Download Worker + * + * Features: + * - Upload files with PUT requests + * - Download files with GET requests + * - Delete files with DELETE requests + * - List all files + * - Proper content-type handling + * - Error handling + */ + +import { Hono } from 'hono'; + +type Bindings = { + MY_BUCKET: R2Bucket; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// Upload a file +app.put('/files/:filename', async (c) => { + const filename = c.req.param('filename'); + const body = await c.req.arrayBuffer(); + const contentType = c.req.header('content-type') || 'application/octet-stream'; + + try { + const object = await c.env.MY_BUCKET.put(filename, body, { + httpMetadata: { + contentType: contentType, + cacheControl: 'public, max-age=3600', + }, + customMetadata: { + uploadedAt: new Date().toISOString(), + uploadedBy: 'api', + }, + }); + + return c.json({ + success: true, + key: object.key, + size: object.size, + etag: object.etag, + uploaded: object.uploaded, + }); + } catch (error: any) { + console.error('Upload error:', error.message); + return c.json({ + success: false, + error: 'Failed to upload file', + }, 500); + } +}); + +// Download a file +app.get('/files/:filename', async (c) => { + const filename = c.req.param('filename'); + + try { + const object = await c.env.MY_BUCKET.get(filename); + + if (!object) { + return c.json({ + success: false, + error: 'File not found', + }, 404); + } + + // Apply http metadata from R2 + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + + return new Response(object.body, { headers }); + } catch (error: any) { + console.error('Download error:', error.message); + return c.json({ + success: false, + error: 'Failed to download file', + }, 500); + } +}); + +// Get file metadata (without downloading body) +app.head('/files/:filename', async (c) => { + const filename = c.req.param('filename'); + + try { + const object = await c.env.MY_BUCKET.head(filename); + + if (!object) { + return c.json({ + success: false, + error: 'File not found', + }, 404); + } + + return c.json({ + success: true, + key: object.key, + size: object.size, + etag: object.etag, + uploaded: object.uploaded, + contentType: object.httpMetadata?.contentType, + customMetadata: object.customMetadata, + }); + } catch (error: any) { + console.error('Head error:', error.message); + return c.json({ + success: false, + error: 'Failed to get file metadata', + }, 500); + } +}); + +// Delete a file +app.delete('/files/:filename', async (c) => { + const filename = c.req.param('filename'); + + try { + // Check if file exists first + const exists = await c.env.MY_BUCKET.head(filename); + + if (!exists) { + return c.json({ + success: false, + error: 'File not found', + }, 404); + } + + await c.env.MY_BUCKET.delete(filename); + + return c.json({ + success: true, + message: 'File deleted successfully', + key: filename, + }); + } catch (error: any) { + console.error('Delete error:', error.message); + return c.json({ + success: false, + error: 'Failed to delete file', + }, 500); + } +}); + +// List all files (with pagination) +app.get('/files', async (c) => { + const cursor = c.req.query('cursor'); + const limit = parseInt(c.req.query('limit') || '100'); + const prefix = c.req.query('prefix') || ''; + + try { + const listed = await c.env.MY_BUCKET.list({ + limit: Math.min(limit, 1000), // Max 1000 + cursor: cursor || undefined, + prefix: prefix || undefined, + }); + + return c.json({ + success: true, + files: listed.objects.map(obj => ({ + key: obj.key, + size: obj.size, + etag: obj.etag, + uploaded: obj.uploaded, + contentType: obj.httpMetadata?.contentType, + })), + truncated: listed.truncated, + cursor: listed.cursor, + count: listed.objects.length, + }); + } catch (error: any) { + console.error('List error:', error.message); + return c.json({ + success: false, + error: 'Failed to list files', + }, 500); + } +}); + +// Bulk delete (up to 1000 files) +app.post('/files/bulk-delete', async (c) => { + const { keys } = await c.req.json<{ keys: string[] }>(); + + if (!keys || !Array.isArray(keys)) { + return c.json({ + success: false, + error: 'Invalid request: keys must be an array', + }, 400); + } + + if (keys.length > 1000) { + return c.json({ + success: false, + error: 'Cannot delete more than 1000 keys at once', + }, 400); + } + + try { + await c.env.MY_BUCKET.delete(keys); + + return c.json({ + success: true, + message: `Deleted ${keys.length} files`, + count: keys.length, + }); + } catch (error: any) { + console.error('Bulk delete error:', error.message); + return c.json({ + success: false, + error: 'Failed to delete files', + }, 500); + } +}); + +// Health check +app.get('/health', (c) => { + return c.json({ + status: 'healthy', + service: 'r2-worker', + timestamp: new Date().toISOString(), + }); +}); + +export default app; diff --git a/templates/wrangler-r2-config.jsonc b/templates/wrangler-r2-config.jsonc new file mode 100644 index 0000000..dc7b908 --- /dev/null +++ b/templates/wrangler-r2-config.jsonc @@ -0,0 +1,49 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "my-r2-worker", + "main": "src/index.ts", + "account_id": "YOUR_ACCOUNT_ID", + "compatibility_date": "2025-10-11", + + // R2 Bucket Bindings + "r2_buckets": [ + { + // The binding name - accessible as env.MY_BUCKET in your Worker + "binding": "MY_BUCKET", + + // The actual bucket name in R2 (must exist) + "bucket_name": "my-bucket", + + // Optional: Use a different bucket for local development + // This prevents dev/test data from polluting production bucket + "preview_bucket_name": "my-bucket-preview" + } + ], + + // Multiple buckets example + // "r2_buckets": [ + // { + // "binding": "UPLOADS", + // "bucket_name": "user-uploads" + // }, + // { + // "binding": "ASSETS", + // "bucket_name": "static-assets" + // }, + // { + // "binding": "BACKUPS", + // "bucket_name": "database-backups" + // } + // ], + + // Optional: Enable observability + "observability": { + "enabled": true + }, + + // Optional: Workers Static Assets (if serving frontend) + "assets": { + "directory": "./public/", + "binding": "ASSETS" + } +}