Files
gh-jezweb-claude-skills-ski…/references/common-patterns.md
2025-11-30 08:24:29 +08:00

11 KiB

R2 Common Patterns

Last Updated: 2025-10-21


Image Upload & Serving

Upload with Automatic Content-Type Detection

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

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

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<ArrayBuffer> {
  // Use Cloudflare Images API, sharp, or other image processing library
  // This is a placeholder
  return imageData;
}

Versioned File Storage

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

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

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

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

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