/** * 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 */