Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "cloudflare-images",
|
||||
"description": "Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Jeremy Dawes",
|
||||
"email": "jeremy@jezweb.net"
|
||||
},
|
||||
"skills": [
|
||||
"./"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# cloudflare-images
|
||||
|
||||
Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413.
|
||||
563
SKILL.md
Normal file
563
SKILL.md
Normal file
@@ -0,0 +1,563 @@
|
||||
---
|
||||
name: cloudflare-images
|
||||
description: |
|
||||
Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Cloudflare Images
|
||||
|
||||
**Status**: Production Ready ✅
|
||||
**Last Updated**: 2025-11-23
|
||||
**Dependencies**: Cloudflare account with Images enabled
|
||||
**Latest Versions**: Cloudflare Images API v2, @cloudflare/workers-types@4.20251121.0
|
||||
|
||||
**Recent Updates (2025)**:
|
||||
- **August 2025**: AI Face Cropping GA (`gravity=face` with `zoom` control, GPU-based RetinaFace, 99.4% precision)
|
||||
- **May 2025**: Media Transformations origin restrictions (default: same-domain only, configurable via dashboard)
|
||||
- **Upcoming**: Background removal, generative upscale (planned features)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Two features: **Images API** (upload/store with variants) and **Image Transformations** (resize any image via URL or Workers).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Enable**: Dashboard → Images → Get Account ID + API token (Cloudflare Images: Edit permission)
|
||||
|
||||
**2. Upload**:
|
||||
```bash
|
||||
curl -X POST https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
|
||||
-H 'Authorization: Bearer <API_TOKEN>' \
|
||||
-H 'Content-Type: multipart/form-data' \
|
||||
-F 'file=@./image.jpg'
|
||||
```
|
||||
|
||||
**3. Serve**: `https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/public`
|
||||
|
||||
**4. Transform** (optional): Dashboard → Images → Transformations → Enable for zone
|
||||
```html
|
||||
<img src="/cdn-cgi/image/width=800,quality=85/uploads/photo.jpg" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upload Methods
|
||||
|
||||
**1. File Upload**: POST to `/images/v1` with `file` (multipart/form-data), optional `id`, `requireSignedURLs`, `metadata`
|
||||
|
||||
**2. Upload via URL**: POST with `url=https://example.com/image.jpg` (supports HTTP basic auth)
|
||||
|
||||
**3. Direct Creator Upload** (one-time URLs, no API key exposure):
|
||||
|
||||
Backend: POST to `/images/v2/direct_upload` → returns `uploadURL`
|
||||
Frontend: POST file to `uploadURL` with FormData
|
||||
|
||||
**CRITICAL CORS FIX**:
|
||||
- ✅ Use `multipart/form-data` (let browser set header)
|
||||
- ✅ Name field `file` (NOT `image`)
|
||||
- ✅ Call `/direct_upload` from backend only
|
||||
- ❌ Don't set `Content-Type: application/json`
|
||||
- ❌ Don't call `/direct_upload` from browser
|
||||
|
||||
---
|
||||
|
||||
## Image Transformations
|
||||
|
||||
**URL**: `/cdn-cgi/image/<OPTIONS>/<SOURCE>`
|
||||
- Sizing: `width=800,height=600,fit=cover`
|
||||
- Quality: `quality=85` (1-100)
|
||||
- Format: `format=auto` (WebP/AVIF auto-detection)
|
||||
- **Cropping**: `gravity=auto` (smart crop), **`gravity=face`** (AI face detection, Aug 2025 GA), `gravity=center`, **`zoom=0.5`** (0-1 range, face crop tightness)
|
||||
- Effects: `blur=10,sharpen=3,brightness=1.2`
|
||||
- Fit: `scale-down`, `contain`, `cover`, `crop`, `pad`
|
||||
|
||||
**Workers**: Use `cf.image` object in fetch
|
||||
```typescript
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: { width: 800, quality: 85, format: 'auto', gravity: 'face', zoom: 0.8 }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variants
|
||||
|
||||
**Named Variants** (up to 100): Predefined transformations (e.g., `avatar`, `thumbnail`)
|
||||
- Create: POST to `/images/v1/variants` with `id`, `options`
|
||||
- Use: `imagedelivery.net/<HASH>/<ID>/avatar`
|
||||
- Works with signed URLs
|
||||
|
||||
**Flexible Variants**: Dynamic params in URL (`w=400,sharpen=3`)
|
||||
- Enable: PATCH `/images/v1/config` with `{"flexible_variants": true}`
|
||||
- ❌ **Cannot use with signed URLs** (use named variants instead)
|
||||
|
||||
---
|
||||
|
||||
## Signed URLs
|
||||
|
||||
Generate HMAC-SHA256 tokens for private images (URL format: `?exp=<TIMESTAMP>&sig=<HMAC>`).
|
||||
|
||||
**Algorithm**: `HMAC-SHA256(signingKey, imageId + variant + expiry)` → hex signature
|
||||
|
||||
**See**: `templates/signed-urls-generation.ts` for Workers implementation
|
||||
|
||||
---
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Always Do
|
||||
|
||||
✅ Use `multipart/form-data` for Direct Creator Upload
|
||||
✅ Name the file field `file` (not `image` or other names)
|
||||
✅ Call `/direct_upload` API from backend only (NOT browser)
|
||||
✅ Use HTTPS URLs for transformations (HTTP not supported)
|
||||
✅ URL-encode special characters in image paths
|
||||
✅ Enable transformations on zone before using `/cdn-cgi/image/`
|
||||
✅ Use named variants for private images (signed URLs)
|
||||
✅ Check `Cf-Resized` header for transformation errors
|
||||
✅ Set `format=auto` for automatic WebP/AVIF conversion
|
||||
✅ Use `fit=scale-down` to prevent unwanted enlargement
|
||||
|
||||
### Never Do
|
||||
|
||||
❌ Use `application/json` Content-Type for file uploads
|
||||
❌ Call `/direct_upload` from browser (CORS will fail)
|
||||
❌ Use flexible variants with `requireSignedURLs=true`
|
||||
❌ Resize SVG files (they're inherently scalable)
|
||||
❌ Use HTTP URLs for transformations (HTTPS only)
|
||||
❌ Put spaces or unescaped Unicode in URLs
|
||||
❌ Transform the same image multiple times in Workers (causes 9403 loop)
|
||||
❌ Exceed 100 megapixels image size
|
||||
❌ Use `/cdn-cgi/image/` endpoint in Workers (use `cf.image` instead)
|
||||
❌ Forget to enable transformations on zone before use
|
||||
|
||||
---
|
||||
|
||||
## Known Issues Prevention
|
||||
|
||||
This skill prevents **13+** documented issues.
|
||||
|
||||
### Issue #1: Direct Creator Upload CORS Error
|
||||
|
||||
**Error**: `Access to XMLHttpRequest blocked by CORS policy: Request header field content-type is not allowed`
|
||||
|
||||
**Source**: [Cloudflare Community #345739](https://community.cloudflare.com/t/direct-image-upload-cors-error/345739), [#368114](https://community.cloudflare.com/t/cloudflare-images-direct-upload-cors-problem/368114)
|
||||
|
||||
**Why It Happens**: Server CORS settings only allow `multipart/form-data` for Content-Type header
|
||||
|
||||
**Prevention**:
|
||||
```javascript
|
||||
// ✅ CORRECT
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // Browser sets multipart/form-data automatically
|
||||
});
|
||||
|
||||
// ❌ WRONG
|
||||
await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, // CORS error
|
||||
body: JSON.stringify({ file: base64Image })
|
||||
});
|
||||
```
|
||||
|
||||
### Issue #2: Error 5408 - Upload Timeout
|
||||
|
||||
**Error**: `Error 5408` after ~15 seconds of upload
|
||||
|
||||
**Source**: [Cloudflare Community #571336](https://community.cloudflare.com/t/images-direct-creator-upload-error-5408/571336)
|
||||
|
||||
**Why It Happens**: Cloudflare has 30-second request timeout; slow uploads or large files exceed limit
|
||||
|
||||
**Prevention**:
|
||||
- Compress images before upload (client-side with Canvas API)
|
||||
- Use reasonable file size limits (e.g., max 10MB)
|
||||
- Show upload progress to user
|
||||
- Handle timeout errors gracefully
|
||||
|
||||
```javascript
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert('File too large. Please select an image under 10MB.');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Issue #3: Error 400 - Invalid File Parameter
|
||||
|
||||
**Error**: `400 Bad Request` with unhelpful error message
|
||||
|
||||
**Source**: [Cloudflare Community #487629](https://community.cloudflare.com/t/direct-creator-upload-returning-400/487629)
|
||||
|
||||
**Why It Happens**: File field must be named `file` (not `image`, `photo`, etc.)
|
||||
|
||||
**Prevention**:
|
||||
```javascript
|
||||
// ✅ CORRECT
|
||||
formData.append('file', imageFile);
|
||||
|
||||
// ❌ WRONG
|
||||
formData.append('image', imageFile); // 400 error
|
||||
formData.append('photo', imageFile); // 400 error
|
||||
```
|
||||
|
||||
### Issue #4: CORS Preflight Failures
|
||||
|
||||
**Error**: Preflight OPTIONS request blocked
|
||||
|
||||
**Source**: [Cloudflare Community #306805](https://community.cloudflare.com/t/cors-error-when-using-direct-creator-upload/306805)
|
||||
|
||||
**Why It Happens**: Calling `/direct_upload` API directly from browser (should be backend-only)
|
||||
|
||||
**Prevention**:
|
||||
```
|
||||
ARCHITECTURE:
|
||||
Browser → Backend API → POST /direct_upload → Returns uploadURL → Browser uploads to uploadURL
|
||||
```
|
||||
|
||||
Never expose API token to browser. Generate upload URL on backend, return to frontend.
|
||||
|
||||
### Issue #5: Error 9401 - Invalid Arguments
|
||||
|
||||
**Error**: `Cf-Resized: err=9401` - Required cf.image options missing or invalid
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**: Missing required transformation parameters or invalid values
|
||||
|
||||
**Prevention**:
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ❌ WRONG
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 'large', // Must be number
|
||||
quality: 150 // Max 100
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Issue #6: Error 9402 - Image Too Large
|
||||
|
||||
**Error**: `Cf-Resized: err=9402` - Image too large or connection interrupted
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**: Image exceeds maximum area (100 megapixels) or download fails
|
||||
|
||||
**Prevention**:
|
||||
- Validate image dimensions before transforming
|
||||
- Use reasonable source images (max 10000x10000px)
|
||||
- Handle network errors gracefully
|
||||
|
||||
### Issue #7: Error 9403 - Request Loop
|
||||
|
||||
**Error**: `Cf-Resized: err=9403` - Worker fetching its own URL or already-resized image
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**: Transformation applied to already-transformed image, or Worker fetches itself
|
||||
|
||||
**Prevention**:
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
const originalPath = url.pathname.replace('/images/', '');
|
||||
const originURL = `https://storage.example.com/${originalPath}`;
|
||||
return fetch(originURL, { cf: { image: { width: 800 } } });
|
||||
}
|
||||
|
||||
// ❌ WRONG
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
// Fetches worker's own URL, causes loop
|
||||
return fetch(request, { cf: { image: { width: 800 } } });
|
||||
}
|
||||
```
|
||||
|
||||
### Issue #8: Error 9406/9419 - Invalid URL Format
|
||||
|
||||
**Error**: `Cf-Resized: err=9406` or `err=9419` - Non-HTTPS URL or URL has spaces/unescaped Unicode
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**: Image URL uses HTTP (not HTTPS) or contains invalid characters
|
||||
|
||||
**Prevention**:
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const imageURL = "https://example.com/images/photo%20name.jpg";
|
||||
|
||||
// ❌ WRONG
|
||||
const imageURL = "http://example.com/images/photo.jpg"; // HTTP not allowed
|
||||
const imageURL = "https://example.com/images/photo name.jpg"; // Space not encoded
|
||||
```
|
||||
|
||||
Always use `encodeURIComponent()` for URL paths:
|
||||
```typescript
|
||||
const filename = "photo name.jpg";
|
||||
const imageURL = `https://example.com/images/${encodeURIComponent(filename)}`;
|
||||
```
|
||||
|
||||
### Issue #9: Error 9412 - Non-Image Response
|
||||
|
||||
**Error**: `Cf-Resized: err=9412` - Origin returned HTML instead of image
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**: Origin server returns 404 page or error page (HTML) instead of image
|
||||
|
||||
**Prevention**:
|
||||
```typescript
|
||||
// Verify URL before transforming
|
||||
const originResponse = await fetch(imageURL, { method: 'HEAD' });
|
||||
const contentType = originResponse.headers.get('content-type');
|
||||
|
||||
if (!contentType?.startsWith('image/')) {
|
||||
return new Response('Not an image', { status: 400 });
|
||||
}
|
||||
|
||||
return fetch(imageURL, { cf: { image: { width: 800 } } });
|
||||
```
|
||||
|
||||
### Issue #10: Error 9413 - Max Image Area Exceeded
|
||||
|
||||
**Error**: `Cf-Resized: err=9413` - Image exceeds 100 megapixels
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**: Source image dimensions exceed 100 megapixels (e.g., 10000x10000px)
|
||||
|
||||
**Prevention**:
|
||||
- Validate image dimensions before upload
|
||||
- Pre-process oversized images
|
||||
- Reject images above threshold
|
||||
|
||||
```typescript
|
||||
const MAX_MEGAPIXELS = 100;
|
||||
|
||||
if (width * height > MAX_MEGAPIXELS * 1_000_000) {
|
||||
return new Response('Image too large', { status: 413 });
|
||||
}
|
||||
```
|
||||
|
||||
### Issue #11: Flexible Variants + Signed URLs Incompatibility
|
||||
|
||||
**Error**: Flexible variants don't work with private images
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Enable flexible variants](https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/)
|
||||
|
||||
**Why It Happens**: Flexible variants cannot be used with `requireSignedURLs=true`
|
||||
|
||||
**Prevention**:
|
||||
```typescript
|
||||
// ✅ CORRECT - Use named variants for private images
|
||||
await uploadImage({
|
||||
file: imageFile,
|
||||
requireSignedURLs: true // Use named variants: /public, /avatar, etc.
|
||||
});
|
||||
|
||||
// ❌ WRONG - Flexible variants don't support signed URLs
|
||||
// Cannot use: /w=400,sharpen=3 with requireSignedURLs=true
|
||||
```
|
||||
|
||||
### Issue #12: SVG Resizing Limitation
|
||||
|
||||
**Error**: SVG files don't resize via transformations
|
||||
|
||||
**Source**: [Cloudflare Images Docs - SVG files](https://developers.cloudflare.com/images/transform-images/#svg-files)
|
||||
|
||||
**Why It Happens**: SVG is inherently scalable (vector format), resizing not applicable
|
||||
|
||||
**Prevention**:
|
||||
```typescript
|
||||
// SVGs can be served but not resized
|
||||
// Use any variant name as placeholder
|
||||
// https://imagedelivery.net/<HASH>/<SVG_ID>/public
|
||||
|
||||
// SVG will be served at original size regardless of variant settings
|
||||
```
|
||||
|
||||
### Issue #13: EXIF Metadata Stripped by Default
|
||||
|
||||
**Error**: GPS data, camera settings removed from uploaded JPEGs
|
||||
|
||||
**Source**: [Cloudflare Images Docs - Transform via URL](https://developers.cloudflare.com/images/transform-images/transform-via-url/#metadata)
|
||||
|
||||
**Why It Happens**: Default behavior strips all metadata except copyright
|
||||
|
||||
**Prevention**:
|
||||
```typescript
|
||||
// Preserve metadata
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
metadata: 'keep' // Options: 'none', 'copyright', 'keep'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Options**:
|
||||
- `none`: Strip all metadata
|
||||
- `copyright`: Keep only copyright tag (default for JPEG)
|
||||
- `keep`: Preserve most EXIF metadata including GPS
|
||||
|
||||
---
|
||||
|
||||
## Using Bundled Resources
|
||||
|
||||
### Templates (templates/)
|
||||
|
||||
Copy-paste ready code for common patterns:
|
||||
|
||||
1. **wrangler-images-binding.jsonc** - Wrangler configuration (no binding needed)
|
||||
2. **upload-api-basic.ts** - Upload file to Images API
|
||||
3. **upload-via-url.ts** - Ingest image from external URL
|
||||
4. **direct-creator-upload-backend.ts** - Generate one-time upload URLs
|
||||
5. **direct-creator-upload-frontend.html** - User upload form
|
||||
6. **transform-via-url.ts** - URL transformation examples
|
||||
7. **transform-via-workers.ts** - Workers transformation patterns
|
||||
8. **variants-management.ts** - Create/list/delete variants
|
||||
9. **signed-urls-generation.ts** - HMAC-SHA256 signed URL generation
|
||||
10. **responsive-images-srcset.html** - Responsive image patterns
|
||||
11. **batch-upload.ts** - Batch API for high-volume uploads
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
cp templates/upload-api-basic.ts src/upload.ts
|
||||
# Edit with your account ID and API token
|
||||
```
|
||||
|
||||
### References (references/)
|
||||
|
||||
In-depth documentation Claude can load as needed:
|
||||
|
||||
1. **api-reference.md** - Complete API endpoints (upload, list, delete, variants)
|
||||
2. **transformation-options.md** - All transform params with examples
|
||||
3. **variants-guide.md** - Named vs flexible variants, when to use each
|
||||
4. **signed-urls-guide.md** - HMAC-SHA256 implementation details
|
||||
5. **direct-upload-complete-workflow.md** - Full architecture and flow
|
||||
6. **responsive-images-patterns.md** - srcset, sizes, art direction
|
||||
7. **format-optimization.md** - WebP/AVIF auto-conversion strategies
|
||||
8. **top-errors.md** - All 13+ errors with detailed troubleshooting
|
||||
|
||||
**When to load**:
|
||||
- Deep-dive into specific feature
|
||||
- Troubleshooting complex issues
|
||||
- Understanding API details
|
||||
- Implementing advanced patterns
|
||||
|
||||
### Scripts (scripts/)
|
||||
|
||||
**check-versions.sh** - Verify API endpoints are current
|
||||
|
||||
---
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
**Custom Domains**: Serve from your domain via `/cdn-cgi/imagedelivery/<HASH>/<ID>/<VARIANT>` (requires domain on Cloudflare, proxied). Use Transform Rules for custom paths.
|
||||
|
||||
**Batch API**: High-volume uploads via `batch.imagedelivery.net` with batch tokens (Dashboard → Images → Batch API)
|
||||
|
||||
**Webhooks**: Notifications for Direct Creator Upload (Dashboard → Notifications → Webhooks). Payload includes `imageId`, `status`, `metadata`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Images not transforming
|
||||
|
||||
**Symptoms**: `/cdn-cgi/image/...` returns original image or 404
|
||||
|
||||
**Solutions**:
|
||||
1. Enable transformations on zone: Dashboard → Images → Transformations → Enable for zone
|
||||
2. Verify zone is proxied through Cloudflare (orange cloud)
|
||||
3. Check source image is publicly accessible
|
||||
4. Wait 5-10 minutes for settings to propagate
|
||||
|
||||
### Problem: Direct upload returns CORS error
|
||||
|
||||
**Symptoms**: `Access-Control-Allow-Origin` error in browser console
|
||||
|
||||
**Solutions**:
|
||||
1. Use `multipart/form-data` encoding (let browser set Content-Type)
|
||||
2. Don't call `/direct_upload` from browser; call from backend
|
||||
3. Name file field `file` (not `image`)
|
||||
4. Remove manual Content-Type header
|
||||
|
||||
### Problem: Worker transformations return 9403 loop error
|
||||
|
||||
**Symptoms**: `Cf-Resized: err=9403` in response headers
|
||||
|
||||
**Solutions**:
|
||||
1. Don't fetch Worker's own URL (use external origin)
|
||||
2. Don't transform already-resized images
|
||||
3. Check URL routing logic to avoid loops
|
||||
|
||||
### Problem: Signed URLs not working
|
||||
|
||||
**Symptoms**: 403 Forbidden when accessing signed URL
|
||||
|
||||
**Solutions**:
|
||||
1. Verify image uploaded with `requireSignedURLs=true`
|
||||
2. Check signature generation (HMAC-SHA256)
|
||||
3. Ensure expiry timestamp is in future
|
||||
4. Verify signing key matches dashboard (Images → Keys)
|
||||
5. Cannot use flexible variants with signed URLs (use named variants)
|
||||
|
||||
### Problem: Images uploaded but not appearing
|
||||
|
||||
**Symptoms**: Upload returns 200 OK but image not in dashboard
|
||||
|
||||
**Solutions**:
|
||||
1. Check for `draft: true` in response (Direct Creator Upload)
|
||||
2. Wait for upload to complete (check via GET `/images/v1/{id}`)
|
||||
3. Verify account ID matches
|
||||
4. Check for upload errors in webhooks
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Cloudflare Images**: https://developers.cloudflare.com/images/
|
||||
- **Get Started**: https://developers.cloudflare.com/images/get-started/
|
||||
- **Upload Images**: https://developers.cloudflare.com/images/upload-images/
|
||||
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
|
||||
- **Transform Images**: https://developers.cloudflare.com/images/transform-images/
|
||||
- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||
- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||
- **Create Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
|
||||
- **Serve Private Images**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/
|
||||
- **Troubleshooting**: https://developers.cloudflare.com/images/reference/troubleshooting/
|
||||
- **API Reference**: https://developers.cloudflare.com/api/resources/images/
|
||||
|
||||
---
|
||||
|
||||
## Package Versions
|
||||
|
||||
**Last Verified**: 2025-11-23
|
||||
**API Version**: v2 (direct uploads), v1 (standard uploads)
|
||||
**Optional**: @cloudflare/workers-types@4.20251121.0
|
||||
129
plugin.lock.json
Normal file
129
plugin.lock.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:jezweb/claude-skills:skills/cloudflare-images",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "7bc4849a97dc79c9120657ff4d9914357c7569aa",
|
||||
"treeHash": "18d254c96b6d7b4a32678a5bbb85814a814aaffbc5449d7a09b0b9f0ba5688ed",
|
||||
"generatedAt": "2025-11-28T10:18:58.353667Z",
|
||||
"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-images",
|
||||
"description": "Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "5fc400d45e5f4bc167bb2a599bf71a945cbbfa0b850512cac372a872e354ba47"
|
||||
},
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"sha256": "f4c6d64b2b8d11e6692fc8023e176791e9db44faa59ec60642bae4b0996477ed"
|
||||
},
|
||||
{
|
||||
"path": "references/transformation-options.md",
|
||||
"sha256": "a19f13cd568ee5d736e3e40c266ed1ddcbe7bd555f7e7a6ee92c5273e2ff5945"
|
||||
},
|
||||
{
|
||||
"path": "references/direct-upload-complete-workflow.md",
|
||||
"sha256": "9a829def528fc4c4032536a44dc06b61cbc82bce22185a01aee2f6c6f5a9c9d1"
|
||||
},
|
||||
{
|
||||
"path": "references/top-errors.md",
|
||||
"sha256": "ce6ece72c6795a7d2f6ea3c422437c28c3a7b36fbf5bdb5c46db37349b854c20"
|
||||
},
|
||||
{
|
||||
"path": "references/variants-guide.md",
|
||||
"sha256": "2ab0cfcac5d4f54d78cd39fc988ced51b049ce8f0300b0f0ef1054ddbae3e16f"
|
||||
},
|
||||
{
|
||||
"path": "references/signed-urls-guide.md",
|
||||
"sha256": "5f16310d425c4c4812e39fc871aa2696e172419ddbff890a89a12371f0940dc4"
|
||||
},
|
||||
{
|
||||
"path": "references/format-optimization.md",
|
||||
"sha256": "dd69136c7fdfd2a083292331280c8702ecd0d41f46e9f818cf4d81ff2c8d8758"
|
||||
},
|
||||
{
|
||||
"path": "references/responsive-images-patterns.md",
|
||||
"sha256": "b5b5981640f079a760d14a5bd118028817270f33596a42e08fd59f5f6919200d"
|
||||
},
|
||||
{
|
||||
"path": "references/api-reference.md",
|
||||
"sha256": "dbbb034ded58a2c255760f5f0a62da8261ee15f8b1a4cc88712a41008c3a14ed"
|
||||
},
|
||||
{
|
||||
"path": "scripts/check-versions.sh",
|
||||
"sha256": "7fe965555e2602fe57412743250fda1411118a5d3c0b2a125cd9d9619a45f9fe"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "c75f5055156f8fd1bdf3f2fe985aba36363f94a59f98731785399275020f905d"
|
||||
},
|
||||
{
|
||||
"path": "templates/transform-via-workers.ts",
|
||||
"sha256": "44be03a6442750818121b68eb963db9f94903feea318876ee712acc633e28b01"
|
||||
},
|
||||
{
|
||||
"path": "templates/transform-via-url.ts",
|
||||
"sha256": "15e7122245398f4465104043ff5dee443b082e21756a19ab1a1f43e885106ad1"
|
||||
},
|
||||
{
|
||||
"path": "templates/wrangler-images-binding.jsonc",
|
||||
"sha256": "0575beed7089728428394d0894b5ebc1f6cdbe897f23594b244dfa5ed369fdd3"
|
||||
},
|
||||
{
|
||||
"path": "templates/responsive-images-srcset.html",
|
||||
"sha256": "2e99dc2bd4e84af4cd38340f50ced9e4772520bb3c6024dd083fe567512b2638"
|
||||
},
|
||||
{
|
||||
"path": "templates/upload-api-basic.ts",
|
||||
"sha256": "55937da60b1e6210f19b4f341c3ef0fad475f7dd941e63bc66b7dcf9525859df"
|
||||
},
|
||||
{
|
||||
"path": "templates/direct-creator-upload-frontend.html",
|
||||
"sha256": "1c7f2731d281c4666ba522b5217febf47a92e15560196a22bd0fc1799b7681ca"
|
||||
},
|
||||
{
|
||||
"path": "templates/signed-urls-generation.ts",
|
||||
"sha256": "153fd80ec86728cf78a073e81e70ba1616c58f0f55cfd917123e02ddc070de26"
|
||||
},
|
||||
{
|
||||
"path": "templates/package.json",
|
||||
"sha256": "b9d3d2822116d53f2171c638d4eeedc1f1f455cda37a852fc476dd49aef08604"
|
||||
},
|
||||
{
|
||||
"path": "templates/direct-creator-upload-backend.ts",
|
||||
"sha256": "6a46ace900b3a6f1a19206c120aa9d1a99dd5e688c7def72dc715a54cb4d254e"
|
||||
},
|
||||
{
|
||||
"path": "templates/variants-management.ts",
|
||||
"sha256": "18329a67a61e64d5e0912ec3a0f0fecb0bf74c066cd9b1b864717f670380c80b"
|
||||
},
|
||||
{
|
||||
"path": "templates/upload-via-url.ts",
|
||||
"sha256": "bbb7498b2e4eb33a731a81fdc1ba457022688ccc994654daca0dbb4fe038dc7e"
|
||||
},
|
||||
{
|
||||
"path": "templates/batch-upload.ts",
|
||||
"sha256": "1db4a9cef4ab2bdaee8b488d8713294c12ffa9845d67edd2535a924b85d6e100"
|
||||
}
|
||||
],
|
||||
"dirSha256": "18d254c96b6d7b4a32678a5bbb85814a814aaffbc5449d7a09b0b9f0ba5688ed"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
403
references/api-reference.md
Normal file
403
references/api-reference.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Cloudflare Images API Reference
|
||||
|
||||
Complete API endpoints for Cloudflare Images.
|
||||
|
||||
**Base URL**: `https://api.cloudflare.com/client/v4/accounts/{account_id}`
|
||||
**Batch API**: `https://batch.imagedelivery.net`
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests require an API token with **Cloudflare Images: Edit** permission.
|
||||
|
||||
```bash
|
||||
Authorization: Bearer <API_TOKEN>
|
||||
```
|
||||
|
||||
Get API token: Dashboard → My Profile → API Tokens → Create Token
|
||||
|
||||
---
|
||||
|
||||
## Upload Endpoints
|
||||
|
||||
### Upload Image (File)
|
||||
|
||||
`POST /accounts/{account_id}/images/v1`
|
||||
|
||||
Upload an image file.
|
||||
|
||||
**Headers**:
|
||||
- `Authorization: Bearer <API_TOKEN>`
|
||||
- `Content-Type: multipart/form-data`
|
||||
|
||||
**Form Fields**:
|
||||
- `file` (required): Image file
|
||||
- `id` (optional): Custom ID (auto-generated if not provided)
|
||||
- `requireSignedURLs` (optional): `true` for private images
|
||||
- `metadata` (optional): JSON object (max 1024 bytes)
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl --request POST \
|
||||
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--form 'file=@./image.jpg' \
|
||||
--form 'requireSignedURLs=false' \
|
||||
--form 'metadata={"key":"value"}'
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"filename": "image.jpg",
|
||||
"uploaded": "2022-01-31T16:39:28.458Z",
|
||||
"requireSignedURLs": false,
|
||||
"variants": [
|
||||
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../public"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Upload via URL
|
||||
|
||||
`POST /accounts/{account_id}/images/v1`
|
||||
|
||||
Ingest image from external URL.
|
||||
|
||||
**Form Fields**:
|
||||
- `url` (required): Image URL to ingest
|
||||
- `id` (optional): Custom ID
|
||||
- `requireSignedURLs` (optional): `true` for private images
|
||||
- `metadata` (optional): JSON object
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl --request POST \
|
||||
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--form 'url=https://example.com/image.jpg' \
|
||||
--form 'metadata={"source":"external"}'
|
||||
```
|
||||
|
||||
**Note**: Cannot use both `file` and `url` in same request.
|
||||
|
||||
---
|
||||
|
||||
### Direct Creator Upload
|
||||
|
||||
`POST /accounts/{account_id}/images/v2/direct_upload`
|
||||
|
||||
Generate one-time upload URL for user uploads.
|
||||
|
||||
**Headers**:
|
||||
- `Authorization: Bearer <API_TOKEN>`
|
||||
- `Content-Type: application/json`
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"requireSignedURLs": false,
|
||||
"metadata": {"userId": "12345"},
|
||||
"expiry": "2025-10-26T18:00:00Z",
|
||||
"id": "custom-id"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
- `requireSignedURLs` (optional): `true` for private images
|
||||
- `metadata` (optional): JSON object
|
||||
- `expiry` (optional): ISO 8601 timestamp (default: 30min, max: 6hr)
|
||||
- `id` (optional): Custom ID (cannot use with `requireSignedURLs=true`)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"uploadURL": "https://upload.imagedelivery.net/..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend Upload**:
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]); // MUST be named 'file'
|
||||
|
||||
await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // NO Content-Type header
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Image Management
|
||||
|
||||
### List Images
|
||||
|
||||
`GET /accounts/{account_id}/images/v2`
|
||||
|
||||
List all images (paginated).
|
||||
|
||||
**Query Params**:
|
||||
- `page` (optional): Page number (default: 1)
|
||||
- `per_page` (optional): Results per page (default: 100, max: 100)
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2?page=1&per_page=50" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"images": [
|
||||
{
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"filename": "image.jpg",
|
||||
"uploaded": "2022-01-31T16:39:28.458Z",
|
||||
"requireSignedURLs": false,
|
||||
"variants": ["https://imagedelivery.net/.../public"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Image Details
|
||||
|
||||
`GET /accounts/{account_id}/images/v1/{image_id}`
|
||||
|
||||
Get details of specific image.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"result": {
|
||||
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
|
||||
"filename": "image.jpg",
|
||||
"uploaded": "2022-01-31T16:39:28.458Z",
|
||||
"requireSignedURLs": false,
|
||||
"draft": false,
|
||||
"variants": ["https://imagedelivery.net/.../public"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: `draft: true` means Direct Creator Upload not completed yet.
|
||||
|
||||
---
|
||||
|
||||
### Delete Image
|
||||
|
||||
`DELETE /accounts/{account_id}/images/v1/{image_id}`
|
||||
|
||||
Delete an image.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl --request DELETE \
|
||||
"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variants Management
|
||||
|
||||
### Create Variant
|
||||
|
||||
`POST /accounts/{account_id}/images/v1/variants`
|
||||
|
||||
Create a new variant.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"id": "thumbnail",
|
||||
"options": {
|
||||
"fit": "cover",
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"metadata": "none"
|
||||
},
|
||||
"neverRequireSignedURLs": false
|
||||
}
|
||||
```
|
||||
|
||||
**Options**:
|
||||
- `fit`: `scale-down`, `contain`, `cover`, `crop`, `pad`
|
||||
- `width`: Max width in pixels
|
||||
- `height`: Max height in pixels
|
||||
- `metadata`: `none`, `copyright`, `keep`
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"id":"thumbnail","options":{"fit":"cover","width":300,"height":300}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Variants
|
||||
|
||||
`GET /accounts/{account_id}/images/v1/variants`
|
||||
|
||||
List all variants.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
|
||||
--header "Authorization: Bearer <API_TOKEN>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Variant
|
||||
|
||||
`GET /accounts/{account_id}/images/v1/variants/{variant_id}`
|
||||
|
||||
Get specific variant details.
|
||||
|
||||
---
|
||||
|
||||
### Update Variant
|
||||
|
||||
`PATCH /accounts/{account_id}/images/v1/variants/{variant_id}`
|
||||
|
||||
Update existing variant.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"options": {
|
||||
"width": 350,
|
||||
"height": 350
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Delete Variant
|
||||
|
||||
`DELETE /accounts/{account_id}/images/v1/variants/{variant_id}`
|
||||
|
||||
Delete a variant.
|
||||
|
||||
---
|
||||
|
||||
### Enable Flexible Variants
|
||||
|
||||
`PATCH /accounts/{account_id}/images/v1/config`
|
||||
|
||||
Enable or disable flexible variants (dynamic transformations).
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"flexible_variants": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Batch API
|
||||
|
||||
Same endpoints as regular API, but different host and authentication.
|
||||
|
||||
**Host**: `https://batch.imagedelivery.net`
|
||||
**Auth**: Batch token (create in Dashboard → Images → Batch API)
|
||||
|
||||
**Endpoints**:
|
||||
- `POST /images/v1` - Upload image
|
||||
- `GET /images/v2` - List images
|
||||
- `DELETE /images/v1/{image_id}` - Delete image
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl "https://batch.imagedelivery.net/images/v1" \
|
||||
--header "Authorization: Bearer <BATCH_TOKEN>" \
|
||||
--form 'file=@./image.jpg'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
### HTTP Status Codes
|
||||
- `200 OK` - Request successful
|
||||
- `400 Bad Request` - Invalid request (check error message)
|
||||
- `401 Unauthorized` - Invalid or missing API token
|
||||
- `403 Forbidden` - Insufficient permissions
|
||||
- `404 Not Found` - Resource not found
|
||||
- `413 Payload Too Large` - File too large
|
||||
- `429 Too Many Requests` - Rate limit exceeded
|
||||
- `500 Internal Server Error` - Cloudflare error
|
||||
- `502 Bad Gateway` - Transformation error
|
||||
|
||||
### Cloudflare Errors
|
||||
Check `errors` array in response:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"errors": [
|
||||
{
|
||||
"code": 5400,
|
||||
"message": "Error description"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes:
|
||||
- `5400` - Invalid request
|
||||
- `5408` - Upload timeout
|
||||
- `5454` - Unsupported protocol
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- **Standard uploads**: No published rate limits
|
||||
- **Direct Creator Upload**: Limited by one-time URL expiry (default 30min, max 6hr)
|
||||
- **Batch API**: Contact Cloudflare for high-volume needs
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Images API**: https://developers.cloudflare.com/api/resources/images/
|
||||
- **Upload Images**: https://developers.cloudflare.com/images/upload-images/
|
||||
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
|
||||
- **Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
|
||||
393
references/direct-upload-complete-workflow.md
Normal file
393
references/direct-upload-complete-workflow.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Direct Creator Upload - Complete Workflow
|
||||
|
||||
Complete architecture and implementation guide for user-uploaded images.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌──────────┐
|
||||
│ Browser │ │ Backend │ │Cloudflare│
|
||||
│ (User) │ │ API │ │ Images │
|
||||
└────┬────┘ └────┬────┘ └─────┬────┘
|
||||
│ │ │
|
||||
│ 1. Request upload URL │ │
|
||||
├─────────────────────────────>│ │
|
||||
│ POST /api/upload-url │ │
|
||||
│ { userId: "123" } │ │
|
||||
│ │ │
|
||||
│ │ 2. Generate upload URL │
|
||||
│ ├──────────────────────────────>│
|
||||
│ │ POST /direct_upload │
|
||||
│ │ { requireSignedURLs, metadata }│
|
||||
│ │ │
|
||||
│ │ 3. Return uploadURL + ID │
|
||||
│ │<──────────────────────────────┤
|
||||
│ │ { uploadURL, id } │
|
||||
│ │ │
|
||||
│ 4. Return uploadURL │ │
|
||||
│<─────────────────────────────┤ │
|
||||
│ { uploadURL, imageId } │ │
|
||||
│ │ │
|
||||
│ 5. Upload file directly │ │
|
||||
├──────────────────────────────────────────────────────────────>│
|
||||
│ POST uploadURL │ │
|
||||
│ FormData: { file } │ │
|
||||
│ │ │
|
||||
│ 6. Success response │ │
|
||||
│<──────────────────────────────────────────────────────────────┤
|
||||
│ { success: true } │ │
|
||||
│ │ │
|
||||
│ 7. (Optional) Webhook │ │
|
||||
│ │<──────────────────────────────┤
|
||||
│ │ POST /webhook │
|
||||
│ │ { imageId, status } │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Backend - Generate Upload URL
|
||||
|
||||
**Endpoint**: `POST /api/upload-url`
|
||||
|
||||
```typescript
|
||||
// backend.ts
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_ID: string;
|
||||
IMAGES_API_TOKEN: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
// Parse request
|
||||
const body = await request.json<{
|
||||
userId?: string;
|
||||
requireSignedURLs?: boolean;
|
||||
}>();
|
||||
|
||||
// Generate one-time upload URL
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v2/direct_upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requireSignedURLs: body.requireSignedURLs ?? false,
|
||||
metadata: {
|
||||
userId: body.userId || 'anonymous',
|
||||
uploadedAt: new Date().toISOString()
|
||||
},
|
||||
expiry: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1 hour
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return Response.json({
|
||||
uploadURL: result.result?.uploadURL,
|
||||
imageId: result.result?.id
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Frontend - Request Upload URL
|
||||
|
||||
```javascript
|
||||
// frontend.js
|
||||
async function requestUploadURL() {
|
||||
const response = await fetch('/api/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: getCurrentUserId(),
|
||||
requireSignedURLs: false
|
||||
})
|
||||
});
|
||||
|
||||
const { uploadURL, imageId } = await response.json();
|
||||
return { uploadURL, imageId };
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Frontend - Upload to Cloudflare
|
||||
|
||||
```javascript
|
||||
async function uploadImage(file) {
|
||||
// Step 1: Get upload URL
|
||||
const { uploadURL, imageId } = await requestUploadURL();
|
||||
|
||||
// Step 2: Upload directly to Cloudflare
|
||||
const formData = new FormData();
|
||||
formData.append('file', file); // MUST be named 'file'
|
||||
|
||||
const response = await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // NO Content-Type header - browser sets multipart/form-data
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return imageId;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend HTML Example
|
||||
|
||||
```html
|
||||
<form id="upload-form">
|
||||
<input type="file" id="file-input" accept="image/*" />
|
||||
<button type="submit">Upload</button>
|
||||
<div id="status"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.getElementById('upload-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const status = document.getElementById('status');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
status.textContent = 'Please select a file';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
status.textContent = 'Requesting upload URL...';
|
||||
|
||||
// Get upload URL from backend
|
||||
const urlResponse = await fetch('/api/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: 'user-123' })
|
||||
});
|
||||
|
||||
const { uploadURL, imageId } = await urlResponse.json();
|
||||
|
||||
status.textContent = 'Uploading...';
|
||||
|
||||
// Upload directly to Cloudflare
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const uploadResponse = await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (uploadResponse.ok) {
|
||||
status.textContent = `✓ Upload successful! Image ID: ${imageId}`;
|
||||
} else {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
status.textContent = `✗ Error: ${error.message}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Integration
|
||||
|
||||
### Configure Webhook
|
||||
|
||||
1. Dashboard → Notifications → Destinations → Webhooks → Create
|
||||
2. Enter webhook URL: `https://your-backend.com/webhook`
|
||||
3. Notifications → All Notifications → Add → Images → Select webhook
|
||||
|
||||
### Handle Webhook
|
||||
|
||||
```typescript
|
||||
// backend-webhook.ts
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const webhook = await request.json();
|
||||
|
||||
console.log('Image upload webhook:', webhook);
|
||||
// {
|
||||
// imageId: "abc123",
|
||||
// status: "uploaded",
|
||||
// metadata: { userId: "user-123" }
|
||||
// }
|
||||
|
||||
// Update database
|
||||
await db.images.create({
|
||||
id: webhook.imageId,
|
||||
userId: webhook.metadata.userId,
|
||||
status: webhook.status,
|
||||
uploadedAt: new Date()
|
||||
});
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Draft vs Uploaded State
|
||||
|
||||
When you generate upload URL, image record is created in **draft** state.
|
||||
|
||||
**Check status**:
|
||||
```typescript
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${imageId}`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${apiToken}` }
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.result?.draft) {
|
||||
console.log('Upload not completed yet');
|
||||
} else {
|
||||
console.log('Upload complete, image available');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend Errors
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const response = await fetch(directUploadURL, { ... });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(`Cloudflare error: ${error.errors?.[0]?.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to generate upload URL:', error);
|
||||
return Response.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Errors
|
||||
|
||||
```javascript
|
||||
// File size validation
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new Error('File too large (max 10MB)');
|
||||
}
|
||||
|
||||
// File type validation
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Please select an image file');
|
||||
}
|
||||
|
||||
// Upload timeout
|
||||
const timeout = setTimeout(() => {
|
||||
throw new Error('Upload timeout (30s limit)');
|
||||
}, 28000); // 28s (before Cloudflare's 30s timeout)
|
||||
|
||||
try {
|
||||
await fetch(uploadURL, { body: formData });
|
||||
clearTimeout(timeout);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom ID Support
|
||||
|
||||
```typescript
|
||||
// Generate upload URL with custom ID
|
||||
const response = await fetch(directUploadURL, {
|
||||
method: 'POST',
|
||||
headers: { ... },
|
||||
body: JSON.stringify({
|
||||
id: `user-${userId}-profile`, // Custom ID
|
||||
metadata: { userId }
|
||||
})
|
||||
});
|
||||
|
||||
// Access with custom ID
|
||||
const imageURL = `https://imagedelivery.net/${accountHash}/user-${userId}-profile/public`;
|
||||
```
|
||||
|
||||
**Note**: Custom IDs cannot be used with `requireSignedURLs=true`.
|
||||
|
||||
---
|
||||
|
||||
## Expiry Configuration
|
||||
|
||||
```typescript
|
||||
// Default: 30 minutes
|
||||
// Min: 2 minutes
|
||||
// Max: 6 hours
|
||||
|
||||
const expiry = new Date(Date.now() + 6 * 60 * 60 * 1000); // 6 hours
|
||||
|
||||
const response = await fetch(directUploadURL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
expiry: expiry.toISOString()
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never expose API token to browser**: Backend-only
|
||||
2. **Validate file type and size**: Frontend and backend
|
||||
3. **Rate limit upload URL generation**: Prevent abuse
|
||||
4. **Associate uploads with users**: Track in metadata
|
||||
5. **Implement webhooks**: Verify successful uploads
|
||||
6. **Set reasonable expiry**: 30min-1hr for most cases
|
||||
7. **Use signed URLs for private content**: `requireSignedURLs=true`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test backend endpoint
|
||||
curl -X POST http://localhost:8787/api/upload-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"userId":"test-user"}'
|
||||
|
||||
# Test upload (replace UPLOAD_URL with response)
|
||||
curl -X POST "UPLOAD_URL" \
|
||||
-F "file=@./test-image.jpg"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/
|
||||
- **Configure Webhooks**: https://developers.cloudflare.com/images/manage-images/configure-webhooks/
|
||||
359
references/format-optimization.md
Normal file
359
references/format-optimization.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Format Optimization
|
||||
|
||||
Complete guide to automatic WebP/AVIF conversion and format selection.
|
||||
|
||||
---
|
||||
|
||||
## format=auto (Recommended)
|
||||
|
||||
Automatically serve optimal format based on browser support.
|
||||
|
||||
**Priority**:
|
||||
1. **AVIF** - Best compression (Chrome, Edge)
|
||||
2. **WebP** - Good compression (Safari, Firefox)
|
||||
3. **Original format** - Fallback (older browsers)
|
||||
|
||||
**Usage**:
|
||||
```typescript
|
||||
// URL format
|
||||
/cdn-cgi/image/width=800,quality=85,format=auto/image.jpg
|
||||
|
||||
// Workers format
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cloudflare Images
|
||||
https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser Support Detection
|
||||
|
||||
Cloudflare automatically checks the `Accept` header.
|
||||
|
||||
**Chrome/Edge**:
|
||||
```
|
||||
Accept: image/avif,image/webp,image/apng,image/*,*/*
|
||||
```
|
||||
→ Serves AVIF
|
||||
|
||||
**Safari**:
|
||||
```
|
||||
Accept: image/webp,image/apng,image/*,*/*
|
||||
```
|
||||
→ Serves WebP
|
||||
|
||||
**Older browsers**:
|
||||
```
|
||||
Accept: image/jpeg,image/png,image/*,*/*
|
||||
```
|
||||
→ Serves original format (JPEG)
|
||||
|
||||
---
|
||||
|
||||
## Manual Format Selection
|
||||
|
||||
### In URL Transformations
|
||||
|
||||
```html
|
||||
<!-- AVIF (best compression) -->
|
||||
<img src="/cdn-cgi/image/format=avif/image.jpg" />
|
||||
|
||||
<!-- WebP (good compression, wide support) -->
|
||||
<img src="/cdn-cgi/image/format=webp/image.jpg" />
|
||||
|
||||
<!-- JPEG (progressive) -->
|
||||
<img src="/cdn-cgi/image/format=jpeg/image.jpg" />
|
||||
|
||||
<!-- Baseline JPEG (older devices) -->
|
||||
<img src="/cdn-cgi/image/format=baseline-jpeg/image.jpg" />
|
||||
```
|
||||
|
||||
### In Workers
|
||||
|
||||
```typescript
|
||||
// Get optimal format from Accept header
|
||||
function getOptimalFormat(request: Request): 'avif' | 'webp' | 'auto' {
|
||||
const accept = request.headers.get('accept') || '';
|
||||
|
||||
if (/image\/avif/.test(accept)) {
|
||||
return 'avif';
|
||||
} else if (/image\/webp/.test(accept)) {
|
||||
return 'webp';
|
||||
}
|
||||
|
||||
return 'auto'; // Cloudflare decides
|
||||
}
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
format: getOptimalFormat(request)
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Format Comparison
|
||||
|
||||
| Format | Compression | Quality | Support | Use Case |
|
||||
|--------|-------------|---------|---------|----------|
|
||||
| **AVIF** | Best (~50% smaller) | Excellent | Modern browsers | First choice (auto) |
|
||||
| **WebP** | Good (~30% smaller) | Excellent | Wide support | Fallback from AVIF |
|
||||
| **JPEG** | Standard | Good | Universal | Fallback, photos |
|
||||
| **PNG** | Lossless | Lossless | Universal | Graphics, transparency |
|
||||
|
||||
**File Size Example** (1920x1080 photo):
|
||||
- Original JPEG: 500 KB
|
||||
- WebP: ~350 KB (30% smaller)
|
||||
- AVIF: ~250 KB (50% smaller)
|
||||
|
||||
---
|
||||
|
||||
## Progressive vs Baseline JPEG
|
||||
|
||||
**Progressive JPEG** (default):
|
||||
- Loads in multiple passes (low→high quality)
|
||||
- Better for slow connections
|
||||
- Slightly larger file size
|
||||
|
||||
**Baseline JPEG**:
|
||||
- Loads top-to-bottom
|
||||
- Better for older devices
|
||||
- Slightly smaller file size
|
||||
|
||||
**Usage**:
|
||||
```
|
||||
format=jpeg → Progressive JPEG
|
||||
format=baseline-jpeg → Baseline JPEG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebP Compression Modes
|
||||
|
||||
```typescript
|
||||
// Fast compression (faster encoding, larger file)
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
format: 'webp',
|
||||
compression: 'fast'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Lossless WebP (no quality loss, larger file)
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
format: 'webp',
|
||||
compression: 'lossless'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Images with format=auto
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<!-- Explicit AVIF for modern browsers -->
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=avif 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=avif 1920w
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- WebP fallback -->
|
||||
<source
|
||||
type="image/webp"
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=webp 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=webp 1920w
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- JPEG fallback -->
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=jpeg 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=jpeg 1920w
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,f=jpeg"
|
||||
alt="Responsive image with format fallbacks"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<!-- OR: Let format=auto handle it -->
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
|
||||
alt="Auto-format responsive image"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quality Recommendations by Format
|
||||
|
||||
```typescript
|
||||
const qualitySettings = {
|
||||
jpeg: 85, // Standard for photos
|
||||
webp: 85, // Same as JPEG
|
||||
avif: 85, // AVIF efficient at same quality
|
||||
png: undefined, // Lossless (quality N/A)
|
||||
graphics: 95 // High quality for logos/text
|
||||
};
|
||||
|
||||
// Photos
|
||||
/cdn-cgi/image/width=800,quality=85,format=auto/photo.jpg
|
||||
|
||||
// Graphics with text
|
||||
/cdn-cgi/image/width=800,quality=95,format=auto/logo.png
|
||||
|
||||
// Thumbnails (lower quality acceptable)
|
||||
/cdn-cgi/image/width=300,quality=75,format=auto/thumb.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Support
|
||||
|
||||
**GIF**:
|
||||
```
|
||||
format=auto → Still GIF or first frame
|
||||
anim=true → Preserve animation
|
||||
```
|
||||
|
||||
**Animated WebP**:
|
||||
```typescript
|
||||
fetch(animatedGif, {
|
||||
cf: {
|
||||
image: {
|
||||
format: 'webp',
|
||||
anim: true // Preserve animation
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metadata Handling
|
||||
|
||||
**Strip metadata** (smaller file size):
|
||||
```
|
||||
metadata=none
|
||||
```
|
||||
|
||||
**Keep copyright** (default for JPEG):
|
||||
```
|
||||
metadata=copyright
|
||||
```
|
||||
|
||||
**Keep all EXIF** (GPS, camera settings):
|
||||
```
|
||||
metadata=keep
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
/cdn-cgi/image/width=800,format=auto,metadata=none/photo.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
1. **Use format=auto**: Smallest files = less bandwidth
|
||||
2. **Reasonable quality**: 80-85 for photos, 90-95 for graphics
|
||||
3. **Strip metadata**: `metadata=none` for public images
|
||||
4. **Cache at edge**: First transformation billable, subsequent free
|
||||
5. **WebP animations**: Convert GIF to animated WebP (smaller)
|
||||
|
||||
---
|
||||
|
||||
## Testing Format Support
|
||||
|
||||
```html
|
||||
<script>
|
||||
// Check AVIF support
|
||||
const avifSupport = document.createElement('canvas')
|
||||
.toDataURL('image/avif').indexOf('data:image/avif') === 0;
|
||||
|
||||
// Check WebP support
|
||||
const webpSupport = document.createElement('canvas')
|
||||
.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
||||
|
||||
console.log('AVIF:', avifSupport); // true in Chrome/Edge
|
||||
console.log('WebP:', webpSupport); // true in modern browsers
|
||||
</script>
|
||||
```
|
||||
|
||||
**But**: Let Cloudflare handle this with `format=auto`!
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Hero Image
|
||||
```
|
||||
width=1920,height=1080,fit=cover,quality=85,format=auto,metadata=none
|
||||
```
|
||||
|
||||
### Thumbnail
|
||||
```
|
||||
width=300,height=300,fit=cover,quality=75,format=auto,metadata=none
|
||||
```
|
||||
|
||||
### Avatar
|
||||
```
|
||||
width=200,height=200,fit=cover,gravity=face,quality=90,format=auto
|
||||
```
|
||||
|
||||
### Product Photo
|
||||
```
|
||||
width=800,height=800,fit=contain,quality=90,sharpen=2,format=auto
|
||||
```
|
||||
|
||||
### Blur Placeholder (LQIP)
|
||||
```
|
||||
width=50,quality=10,blur=20,format=webp,metadata=none
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use format=auto**: Let Cloudflare optimize
|
||||
2. **Quality 80-90**: Balance file size and quality
|
||||
3. **Strip unnecessary metadata**: Smaller files
|
||||
4. **Test on real devices**: Verify format delivery
|
||||
5. **Monitor bandwidth**: Check Cloudflare Analytics
|
||||
6. **Use WebP for animations**: Smaller than GIF
|
||||
7. **Progressive JPEG for photos**: Better perceived load time
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||
- **Supported Formats**: https://developers.cloudflare.com/images/transform-images/#supported-formats-and-limitations
|
||||
274
references/responsive-images-patterns.md
Normal file
274
references/responsive-images-patterns.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Responsive Images Patterns
|
||||
|
||||
Complete guide to serving optimal images for different devices and screen sizes.
|
||||
|
||||
---
|
||||
|
||||
## srcset with Named Variants
|
||||
|
||||
Best for consistent, predefined sizes.
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/mobile 480w,
|
||||
https://imagedelivery.net/HASH/ID/tablet 768w,
|
||||
https://imagedelivery.net/HASH/ID/desktop 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/HASH/ID/desktop"
|
||||
alt="Responsive image"
|
||||
loading="lazy"
|
||||
/>
|
||||
```
|
||||
|
||||
**Variants to create**:
|
||||
- `mobile`: width=480, fit=scale-down
|
||||
- `tablet`: width=768, fit=scale-down
|
||||
- `desktop`: width=1920, fit=scale-down
|
||||
|
||||
---
|
||||
|
||||
## srcset with Flexible Variants
|
||||
|
||||
Best for dynamic sizing (public images only).
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
|
||||
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
|
||||
alt="Responsive image"
|
||||
loading="lazy"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Art Direction (Different Crops)
|
||||
|
||||
Serve different image crops for mobile vs desktop.
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<!-- Mobile: Square crop -->
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/mobile-square"
|
||||
/>
|
||||
|
||||
<!-- Desktop: Wide crop -->
|
||||
<source
|
||||
media="(min-width: 768px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/desktop-wide"
|
||||
/>
|
||||
|
||||
<!-- Fallback -->
|
||||
<img
|
||||
src="https://imagedelivery.net/HASH/ID/desktop-wide"
|
||||
alt="Art directed image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
```
|
||||
|
||||
**Variants to create**:
|
||||
- `mobile-square`: width=480, height=480, fit=cover
|
||||
- `desktop-wide`: width=1920, height=1080, fit=cover
|
||||
|
||||
---
|
||||
|
||||
## High-DPI (Retina) Displays
|
||||
|
||||
Serve 2x images for high-resolution screens.
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=400,dpr=1,f=auto 1x,
|
||||
https://imagedelivery.net/HASH/ID/w=400,dpr=2,f=auto 2x
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=400,f=auto"
|
||||
alt="Retina-ready image"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blur Placeholder (LQIP)
|
||||
|
||||
Load tiny blurred placeholder first, then swap to full image.
|
||||
|
||||
```html
|
||||
<img
|
||||
id="lqip-image"
|
||||
src="https://imagedelivery.net/HASH/ID/w=50,q=10,blur=20,f=webp"
|
||||
data-src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
|
||||
alt="Image with LQIP"
|
||||
style="filter: blur(10px); transition: filter 0.3s;"
|
||||
/>
|
||||
|
||||
<script>
|
||||
const img = document.getElementById('lqip-image');
|
||||
const fullSrc = img.getAttribute('data-src');
|
||||
|
||||
const fullImg = new Image();
|
||||
fullImg.src = fullSrc;
|
||||
fullImg.onload = () => {
|
||||
img.src = fullSrc;
|
||||
img.style.filter = 'blur(0)';
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
Defer loading below-the-fold images.
|
||||
|
||||
```html
|
||||
<!-- Native lazy loading (modern browsers) -->
|
||||
<img src="..." loading="lazy" alt="..." />
|
||||
|
||||
<!-- With Intersection Observer (better control) -->
|
||||
<img
|
||||
class="lazy"
|
||||
data-src="https://imagedelivery.net/HASH/ID/w=800,f=auto"
|
||||
alt="Lazy loaded image"
|
||||
/>
|
||||
|
||||
<script>
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URL Transformations (/cdn-cgi/image/)
|
||||
|
||||
Transform ANY publicly accessible image (not just Cloudflare Images storage).
|
||||
|
||||
```html
|
||||
<img
|
||||
srcset="
|
||||
/cdn-cgi/image/width=480,format=auto/uploads/photo.jpg 480w,
|
||||
/cdn-cgi/image/width=768,format=auto/uploads/photo.jpg 768w,
|
||||
/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg"
|
||||
alt="Transformed origin image"
|
||||
loading="lazy"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Breakpoints
|
||||
|
||||
```javascript
|
||||
const breakpoints = {
|
||||
mobile: 480, // Small phones
|
||||
tablet: 768, // Tablets
|
||||
desktop: 1024, // Laptops
|
||||
wide: 1920, // Desktops
|
||||
ultrawide: 2560 // Large displays
|
||||
};
|
||||
```
|
||||
|
||||
**sizes attribute**:
|
||||
```html
|
||||
sizes="
|
||||
(max-width: 480px) 480px,
|
||||
(max-width: 768px) 768px,
|
||||
(max-width: 1024px) 1024px,
|
||||
(max-width: 1920px) 1920px,
|
||||
2560px
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Hero image with art direction -->
|
||||
<picture>
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/w=480,h=480,fit=cover,f=auto"
|
||||
/>
|
||||
<source
|
||||
media="(min-width: 768px)"
|
||||
srcset="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
|
||||
/>
|
||||
<img
|
||||
src="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
|
||||
alt="Hero image"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<!-- Responsive gallery images -->
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
|
||||
https://imagedelivery.net/HASH/ID/w=1024,f=auto 1024w
|
||||
"
|
||||
sizes="
|
||||
(max-width: 480px) 100vw,
|
||||
(max-width: 768px) 50vw,
|
||||
33vw
|
||||
"
|
||||
src="https://imagedelivery.net/HASH/ID/w=1024,f=auto"
|
||||
alt="Gallery image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use format=auto**: Optimal WebP/AVIF delivery
|
||||
2. **Add loading="lazy"**: Below-the-fold images
|
||||
3. **Match sizes to CSS layout**: Use `sizes` attribute correctly
|
||||
4. **Provide descriptive alt text**: Accessibility
|
||||
5. **Use LQIP for perceived performance**: Better UX
|
||||
6. **Named variants for private**: Signed URLs compatible
|
||||
7. **Flexible variants for public**: Dynamic sizing
|
||||
8. **Limit srcset to 3-5 sizes**: Balance performance vs flexibility
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Responsive Images (MDN)**: https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images
|
||||
- **Cloudflare Images**: https://developers.cloudflare.com/images/
|
||||
350
references/signed-urls-guide.md
Normal file
350
references/signed-urls-guide.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Signed URLs Guide
|
||||
|
||||
Complete guide to generating signed URLs for private images using HMAC-SHA256.
|
||||
|
||||
---
|
||||
|
||||
## What Are Signed URLs?
|
||||
|
||||
Time-limited URLs for serving private images securely.
|
||||
|
||||
**Format**:
|
||||
```
|
||||
https://imagedelivery.net/<HASH>/<ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- User profile photos (private until shared)
|
||||
- Paid content (time-limited access)
|
||||
- Temporary downloads
|
||||
- Secure image delivery
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
1. **Upload with signed URLs enabled**:
|
||||
```javascript
|
||||
await uploadImage(file, {
|
||||
requireSignedURLs: true // Image requires signed URL
|
||||
});
|
||||
```
|
||||
|
||||
2. **Get signing key**:
|
||||
Dashboard → Images → Keys → Generate key
|
||||
|
||||
3. **Use named variants only**:
|
||||
Flexible variants NOT compatible with signed URLs.
|
||||
|
||||
---
|
||||
|
||||
## Signature Algorithm (HMAC-SHA256)
|
||||
|
||||
### String to Sign
|
||||
|
||||
```
|
||||
{imageId}{variant}{expiry}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
Image ID: abc123
|
||||
Variant: public
|
||||
Expiry: 1735228800
|
||||
|
||||
String to sign: abc123public1735228800
|
||||
```
|
||||
|
||||
### Generate Signature
|
||||
|
||||
**Workers** (recommended):
|
||||
```typescript
|
||||
async function generateSignature(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expiry: number,
|
||||
signingKey: string
|
||||
): Promise<string> {
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(signingKey);
|
||||
const messageData = encoder.encode(stringToSign);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
|
||||
// Convert to hex string
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
```
|
||||
|
||||
**Node.js**:
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
function generateSignature(imageId, variant, expiry, signingKey) {
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
return crypto
|
||||
.createHmac('sha256', signingKey)
|
||||
.update(stringToSign)
|
||||
.digest('hex');
|
||||
}
|
||||
```
|
||||
|
||||
### Build Signed URL
|
||||
|
||||
```typescript
|
||||
async function generateSignedURL(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expirySeconds: number,
|
||||
accountHash: string,
|
||||
signingKey: string
|
||||
): Promise<string> {
|
||||
const expiry = Math.floor(Date.now() / 1000) + expirySeconds;
|
||||
const sig = await generateSignature(imageId, variant, expiry, signingKey);
|
||||
|
||||
return `https://imagedelivery.net/${accountHash}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expiry Timestamp
|
||||
|
||||
**Unix timestamp** (seconds since epoch):
|
||||
```typescript
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneHour = 60 * 60;
|
||||
const expiry = now + oneHour; // 1 hour from now
|
||||
```
|
||||
|
||||
**From specific date**:
|
||||
```typescript
|
||||
const expiryDate = new Date('2025-10-27T18:00:00Z');
|
||||
const expiry = Math.floor(expiryDate.getTime() / 1000);
|
||||
```
|
||||
|
||||
**Common presets**:
|
||||
```typescript
|
||||
const expiryPresets = {
|
||||
fiveMinutes: 5 * 60,
|
||||
fifteenMinutes: 15 * 60,
|
||||
oneHour: 60 * 60,
|
||||
oneDay: 24 * 60 * 60,
|
||||
oneWeek: 7 * 24 * 60 * 60
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example (Workers)
|
||||
|
||||
```typescript
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_HASH: string;
|
||||
IMAGES_SIGNING_KEY: string;
|
||||
}
|
||||
|
||||
async function generateSignedURL(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expirySeconds: number,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiry = now + expirySeconds;
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
|
||||
const messageData = encoder.encode(stringToSign);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
const sig = Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
// Generate signed URL valid for 1 hour
|
||||
const signedURL = await generateSignedURL(
|
||||
'image-id',
|
||||
'public',
|
||||
3600,
|
||||
env
|
||||
);
|
||||
|
||||
return Response.json({ signedURL });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple Variants
|
||||
|
||||
Generate signed URLs for multiple variants at once:
|
||||
|
||||
```typescript
|
||||
async function generateSignedURLsForVariants(
|
||||
imageId: string,
|
||||
variants: string[],
|
||||
expirySeconds: number,
|
||||
env: Env
|
||||
): Promise<Record<string, string>> {
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
for (const variant of variants) {
|
||||
urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const urls = await generateSignedURLsForVariants(
|
||||
'image-id',
|
||||
['thumbnail', 'medium', 'large'],
|
||||
3600,
|
||||
env
|
||||
);
|
||||
|
||||
// {
|
||||
// thumbnail: 'https://imagedelivery.net/.../thumbnail?exp=...&sig=...',
|
||||
// medium: 'https://imagedelivery.net/.../medium?exp=...&sig=...',
|
||||
// large: 'https://imagedelivery.net/.../large?exp=...&sig=...'
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (Cloudflare handles this)
|
||||
|
||||
For reference, here's how verification works:
|
||||
|
||||
```typescript
|
||||
async function verifySignature(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expiry: number,
|
||||
providedSig: string,
|
||||
signingKey: string
|
||||
): Promise<boolean> {
|
||||
// Check if expired
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (expiry < now) {
|
||||
return false; // Expired
|
||||
}
|
||||
|
||||
// Generate expected signature
|
||||
const expectedSig = await generateSignature(imageId, variant, expiry, signingKey);
|
||||
|
||||
return expectedSig === providedSig;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 1. Signed URL returns 403
|
||||
|
||||
**Causes**:
|
||||
- Image not uploaded with `requireSignedURLs=true`
|
||||
- Signature incorrect (wrong signing key)
|
||||
- URL expired
|
||||
- Using flexible variants (not supported)
|
||||
|
||||
**Solutions**:
|
||||
- Verify image requires signed URLs
|
||||
- Check signing key matches dashboard
|
||||
- Ensure expiry in future
|
||||
- Use named variants only
|
||||
|
||||
### 2. Signature doesn't match
|
||||
|
||||
**Causes**:
|
||||
- Wrong signing key
|
||||
- Incorrect string-to-sign format
|
||||
- Timestamp precision (must be seconds, not milliseconds)
|
||||
|
||||
**Solutions**:
|
||||
```typescript
|
||||
// ✅ CORRECT - Seconds
|
||||
const expiry = Math.floor(Date.now() / 1000);
|
||||
|
||||
// ❌ WRONG - Milliseconds
|
||||
const expiry = Date.now();
|
||||
```
|
||||
|
||||
### 3. Cannot use with flexible variants
|
||||
|
||||
**Error**: 403 Forbidden when using flexible variants with signed URLs
|
||||
|
||||
**Solution**: Use named variants for private images
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const url = await generateSignedURL('id', 'thumbnail', 3600, env);
|
||||
|
||||
// ❌ WRONG
|
||||
const url = `https://imagedelivery.net/${hash}/${id}/w=300?exp=${exp}&sig=${sig}`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Keep signing key secret**: Never expose in client-side code
|
||||
2. **Generate on backend**: Frontend requests signed URL from backend
|
||||
3. **Short expiry for sensitive content**: 5-15 minutes for temporary access
|
||||
4. **Longer expiry for user content**: 1-24 hours for profile photos
|
||||
5. **Rotate keys periodically**: Dashboard → Images → Keys → Regenerate
|
||||
6. **Log suspicious activity**: Monitor for signature mismatches
|
||||
|
||||
---
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### Profile Photos (24-hour expiry)
|
||||
```typescript
|
||||
const profileURL = await generateSignedURL('user-123', 'avatar', 24 * 60 * 60, env);
|
||||
```
|
||||
|
||||
### Temporary Download (5 minutes)
|
||||
```typescript
|
||||
const downloadURL = await generateSignedURL('doc-456', 'large', 5 * 60, env);
|
||||
```
|
||||
|
||||
### Paid Content (1-week subscription)
|
||||
```typescript
|
||||
const contentURL = await generateSignedURL('premium-789', 'medium', 7 * 24 * 60 * 60, env);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Serve Private Images**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/
|
||||
476
references/top-errors.md
Normal file
476
references/top-errors.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Top Errors and Solutions
|
||||
|
||||
Complete troubleshooting guide for all documented Cloudflare Images errors.
|
||||
|
||||
---
|
||||
|
||||
## Direct Creator Upload Errors
|
||||
|
||||
### 1. CORS Error - Content-Type Not Allowed
|
||||
|
||||
**Error**:
|
||||
```
|
||||
Access to XMLHttpRequest blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers
|
||||
```
|
||||
|
||||
**Source**: [Cloudflare Community #345739](https://community.cloudflare.com/t/direct-image-upload-cors-error/345739), [#368114](https://community.cloudflare.com/t/cloudflare-images-direct-upload-cors-problem/368114)
|
||||
|
||||
**Why It Happens**:
|
||||
Server CORS settings only allow `multipart/form-data` for Content-Type header.
|
||||
|
||||
**Solution**:
|
||||
```javascript
|
||||
// ✅ CORRECT
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // Browser sets multipart/form-data automatically
|
||||
});
|
||||
|
||||
// ❌ WRONG
|
||||
await fetch(uploadURL, {
|
||||
headers: { 'Content-Type': 'application/json' }, // CORS error
|
||||
body: JSON.stringify({ file: base64Image })
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Use FormData API
|
||||
- Let browser set Content-Type header (don't set manually)
|
||||
- Name field `file` (not `image` or other)
|
||||
|
||||
---
|
||||
|
||||
### 2. Error 5408 - Upload Timeout
|
||||
|
||||
**Error**: `Error 5408` after ~15 seconds
|
||||
|
||||
**Source**: [Cloudflare Community #571336](https://community.cloudflare.com/t/images-direct-creator-upload-error-5408/571336)
|
||||
|
||||
**Why It Happens**:
|
||||
Cloudflare has 30-second request timeout. Slow uploads or large files exceed limit.
|
||||
|
||||
**Solution**:
|
||||
```javascript
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert('File too large. Please select an image under 10MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Compress image before upload (optional)
|
||||
async function compressImage(file) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = await createImageBitmap(file);
|
||||
|
||||
const maxWidth = 4000;
|
||||
const scale = Math.min(1, maxWidth / img.width);
|
||||
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.9);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Limit file size (10MB max recommended)
|
||||
- Compress images client-side if needed
|
||||
- Show upload progress to user
|
||||
- Handle timeout errors gracefully
|
||||
|
||||
---
|
||||
|
||||
### 3. Error 400 - Invalid File Parameter
|
||||
|
||||
**Error**: `400 Bad Request` with unhelpful message
|
||||
|
||||
**Source**: [Cloudflare Community #487629](https://community.cloudflare.com/t/direct-creator-upload-returning-400/487629)
|
||||
|
||||
**Why It Happens**:
|
||||
File field must be named `file` (not `image`, `photo`, etc.).
|
||||
|
||||
**Solution**:
|
||||
```javascript
|
||||
// ✅ CORRECT
|
||||
formData.append('file', imageFile);
|
||||
|
||||
// ❌ WRONG
|
||||
formData.append('image', imageFile); // 400 error
|
||||
formData.append('photo', imageFile); // 400 error
|
||||
formData.append('upload', imageFile); // 400 error
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Always name the field `file`
|
||||
- Check FormData contents before sending
|
||||
|
||||
---
|
||||
|
||||
### 4. CORS Preflight Failures
|
||||
|
||||
**Error**: Preflight OPTIONS request blocked
|
||||
|
||||
**Source**: [Cloudflare Community #306805](https://community.cloudflare.com/t/cors-error-when-using-direct-creator-upload/306805)
|
||||
|
||||
**Why It Happens**:
|
||||
Calling `/direct_upload` API directly from browser (should be backend-only).
|
||||
|
||||
**Solution**:
|
||||
```
|
||||
CORRECT ARCHITECTURE:
|
||||
|
||||
Browser → POST /api/upload-url → Backend
|
||||
↓
|
||||
POST /direct_upload → Cloudflare API
|
||||
↓
|
||||
Backend ← Returns uploadURL ← Cloudflare API
|
||||
↓
|
||||
Browser receives uploadURL
|
||||
↓
|
||||
Browser → Uploads to uploadURL → Cloudflare (direct upload)
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Never expose API token to browser
|
||||
- Generate upload URL on backend
|
||||
- Return uploadURL to frontend
|
||||
- Frontend uploads to uploadURL (not /direct_upload)
|
||||
|
||||
---
|
||||
|
||||
## Image Transformation Errors
|
||||
|
||||
### 5. Error 9401 - Invalid Arguments
|
||||
|
||||
**Error**: `Cf-Resized: err=9401` in response headers
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Missing required `cf.image` parameters or invalid values.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ❌ WRONG
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 'large', // Must be number
|
||||
quality: 150, // Max 100
|
||||
format: 'invalid' // Must be valid format
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Validate all parameters
|
||||
- Use TypeScript for type checking
|
||||
- Check official docs for valid ranges
|
||||
|
||||
---
|
||||
|
||||
### 6. Error 9402 - Image Too Large
|
||||
|
||||
**Error**: `Cf-Resized: err=9402`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Image exceeds maximum area or download fails.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// Check image dimensions before transforming
|
||||
const response = await fetch(imageURL, { method: 'HEAD' });
|
||||
// Or fetch and check after
|
||||
const img = await fetch(imageURL);
|
||||
// Validate size
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Validate source image dimensions
|
||||
- Max 100 megapixels (e.g., 10000x10000px)
|
||||
- Use reasonable source images
|
||||
|
||||
---
|
||||
|
||||
### 7. Error 9403 - Request Loop
|
||||
|
||||
**Error**: `Cf-Resized: err=9403`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Worker fetching its own URL or already-resized image.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT - Fetch external origin
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
const imagePath = url.pathname.replace('/images/', '');
|
||||
const originURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(originURL, {
|
||||
cf: { image: { width: 800 } }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
// ❌ WRONG - Fetches worker's own URL (loop)
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
return fetch(request, { // Fetches self
|
||||
cf: { image: { width: 800 } }
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Always fetch external origin
|
||||
- Don't transform already-transformed images
|
||||
- Check URL routing logic
|
||||
|
||||
---
|
||||
|
||||
### 8. Error 9406/9419 - Invalid URL Format
|
||||
|
||||
**Error**: `Cf-Resized: err=9406` or `err=9419`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Image URL uses HTTP (not HTTPS) or contains spaces/unescaped Unicode.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const filename = "photo name.jpg";
|
||||
const imageURL = `https://example.com/images/${encodeURIComponent(filename)}`;
|
||||
|
||||
// ❌ WRONG
|
||||
const imageURL = "http://example.com/image.jpg"; // HTTP not allowed
|
||||
const imageURL = "https://example.com/photo name.jpg"; // Space not encoded
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Always use HTTPS (HTTP not supported)
|
||||
- URL-encode all paths with `encodeURIComponent()`
|
||||
- No spaces or unescaped Unicode in URLs
|
||||
|
||||
---
|
||||
|
||||
### 9. Error 9412 - Non-Image Response
|
||||
|
||||
**Error**: `Cf-Resized: err=9412`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Origin server returns HTML (e.g., 404 page) instead of image.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// Verify URL before transforming
|
||||
const originResponse = await fetch(imageURL, { method: 'HEAD' });
|
||||
const contentType = originResponse.headers.get('content-type');
|
||||
|
||||
if (!contentType?.startsWith('image/')) {
|
||||
return new Response('Not an image', { status: 400 });
|
||||
}
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: { image: { width: 800 } }
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Verify origin returns image (check Content-Type)
|
||||
- Handle 404s before transforming
|
||||
- Validate image URLs
|
||||
|
||||
---
|
||||
|
||||
### 10. Error 9413 - Max Image Area Exceeded
|
||||
|
||||
**Error**: `Cf-Resized: err=9413`
|
||||
|
||||
**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/)
|
||||
|
||||
**Why It Happens**:
|
||||
Source image exceeds 100 megapixels (e.g., 10000x10000px).
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
const MAX_MEGAPIXELS = 100;
|
||||
|
||||
if (width * height > MAX_MEGAPIXELS * 1_000_000) {
|
||||
return new Response('Image too large', { status: 413 });
|
||||
}
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Validate image dimensions before transforming
|
||||
- Pre-process oversized images
|
||||
- Reject images above threshold (100 megapixels)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Errors
|
||||
|
||||
### 11. Flexible Variants + Signed URLs Incompatibility
|
||||
|
||||
**Error**: Flexible variants don't work with private images
|
||||
|
||||
**Source**: [Cloudflare Docs - Enable flexible variants](https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/)
|
||||
|
||||
**Why It Happens**:
|
||||
Flexible variants cannot be used with `requireSignedURLs=true`.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ✅ CORRECT - Use named variants for private images
|
||||
await uploadImage({
|
||||
file: imageFile,
|
||||
requireSignedURLs: true // Use named variants: /public, /avatar, etc.
|
||||
});
|
||||
|
||||
// ❌ WRONG - Flexible variants don't support signed URLs
|
||||
// Cannot use: /w=400,sharpen=3 with requireSignedURLs=true
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Use named variants for private images
|
||||
- Use flexible variants for public images only
|
||||
|
||||
---
|
||||
|
||||
### 12. SVG Resizing Limitation
|
||||
|
||||
**Error**: SVG files don't resize via transformations
|
||||
|
||||
**Source**: [Cloudflare Docs - SVG files](https://developers.cloudflare.com/images/transform-images/#svg-files)
|
||||
|
||||
**Why It Happens**:
|
||||
SVG is vector format (inherently scalable), resizing not applicable.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// SVGs can be served but not resized
|
||||
// Use any variant name as placeholder
|
||||
// https://imagedelivery.net/<HASH>/<SVG_ID>/public
|
||||
|
||||
// SVG will be served at original size regardless of variant settings
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Don't try to resize SVGs
|
||||
- Serve SVGs as-is
|
||||
- Use variants as placeholders
|
||||
|
||||
---
|
||||
|
||||
### 13. EXIF Metadata Stripped by Default
|
||||
|
||||
**Error**: GPS data, camera settings removed from uploaded JPEGs
|
||||
|
||||
**Source**: [Cloudflare Docs - Transform via URL](https://developers.cloudflare.com/images/transform-images/transform-via-url/#metadata)
|
||||
|
||||
**Why It Happens**:
|
||||
Default behavior strips all metadata except copyright.
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// Preserve metadata
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
metadata: 'keep' // Options: 'none', 'copyright', 'keep'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Prevention**:
|
||||
- Use `metadata=keep` if preservation needed
|
||||
- Default `copyright` for JPEG
|
||||
- Color profiles and EXIF rotation always applied
|
||||
|
||||
---
|
||||
|
||||
## General Troubleshooting
|
||||
|
||||
### Images not transforming
|
||||
|
||||
**Symptoms**: `/cdn-cgi/image/...` returns original or 404
|
||||
|
||||
**Solutions**:
|
||||
1. Enable transformations: Dashboard → Images → Transformations → Enable
|
||||
2. Verify zone proxied (orange cloud)
|
||||
3. Check source image accessible
|
||||
4. Wait 5-10 minutes for propagation
|
||||
|
||||
### Signed URLs returning 403
|
||||
|
||||
**Symptoms**: 403 Forbidden with signed URL
|
||||
|
||||
**Solutions**:
|
||||
1. Verify image uploaded with `requireSignedURLs=true`
|
||||
2. Check signature generation (HMAC-SHA256)
|
||||
3. Ensure expiry in future
|
||||
4. Verify signing key matches dashboard
|
||||
5. Cannot use flexible variants (use named variants)
|
||||
|
||||
---
|
||||
|
||||
## Checking for Errors
|
||||
|
||||
**Response Headers**:
|
||||
```javascript
|
||||
const response = await fetch(transformedImageURL);
|
||||
const cfResized = response.headers.get('Cf-Resized');
|
||||
|
||||
if (cfResized?.includes('err=')) {
|
||||
console.error('Transformation error:', cfResized);
|
||||
}
|
||||
```
|
||||
|
||||
**Common patterns**:
|
||||
- `Cf-Resized: err=9401` - Invalid arguments
|
||||
- `Cf-Resized: err=9403` - Request loop
|
||||
- `Cf-Resized: err=9412` - Non-image response
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Troubleshooting**: https://developers.cloudflare.com/images/reference/troubleshooting/
|
||||
- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||
337
references/transformation-options.md
Normal file
337
references/transformation-options.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Image Transformation Options
|
||||
|
||||
Complete reference for all image transformation parameters.
|
||||
|
||||
Works with:
|
||||
- **URL format**: `/cdn-cgi/image/<OPTIONS>/<SOURCE>`
|
||||
- **Workers format**: `fetch(url, { cf: { image: {...} } })`
|
||||
|
||||
---
|
||||
|
||||
## Sizing
|
||||
|
||||
### width
|
||||
Max width in pixels.
|
||||
|
||||
**URL**: `width=800` or `w=800`
|
||||
**Workers**: `{ width: 800 }`
|
||||
**Range**: 1-10000
|
||||
|
||||
### height
|
||||
Max height in pixels.
|
||||
|
||||
**URL**: `height=600` or `h=600`
|
||||
**Workers**: `{ height: 600 }`
|
||||
**Range**: 1-10000
|
||||
|
||||
### dpr
|
||||
Device pixel ratio for high-DPI displays.
|
||||
|
||||
**URL**: `dpr=2`
|
||||
**Workers**: `{ dpr: 2 }`
|
||||
**Range**: 1-3
|
||||
**Example**: `dpr=2` serves 2x size for Retina displays
|
||||
|
||||
---
|
||||
|
||||
## Fit Modes
|
||||
|
||||
### fit
|
||||
How to resize image.
|
||||
|
||||
**Options**:
|
||||
- `scale-down`: Shrink to fit (never enlarge)
|
||||
- `contain`: Resize to fit within dimensions (preserve aspect ratio)
|
||||
- `cover`: Resize to fill dimensions (may crop)
|
||||
- `crop`: Crop to exact dimensions
|
||||
- `pad`: Resize and add padding
|
||||
|
||||
**URL**: `fit=cover`
|
||||
**Workers**: `{ fit: 'cover' }`
|
||||
**Default**: `scale-down`
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
fit=scale-down: 800x600 image → max 400x300 → 400x300 (scaled down)
|
||||
fit=scale-down: 400x300 image → max 800x600 → 400x300 (not enlarged)
|
||||
fit=contain: Any size → 800x600 → Fits inside box, preserves aspect
|
||||
fit=cover: Any size → 800x600 → Fills box, may crop edges
|
||||
fit=crop: Any size → 800x600 → Exact size, crops as needed
|
||||
fit=pad: 800x600 image → 1000x1000 → 800x600 + padding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quality & Format
|
||||
|
||||
### quality
|
||||
JPEG/WebP quality.
|
||||
|
||||
**URL**: `quality=85` or `q=85`
|
||||
**Workers**: `{ quality: 85 }`
|
||||
**Range**: 1-100
|
||||
**Default**: 85
|
||||
**Recommended**: 80-90 for photos, 90-100 for graphics
|
||||
|
||||
### format
|
||||
Output format.
|
||||
|
||||
**Options**:
|
||||
- `auto`: Serve AVIF → WebP → Original based on browser support
|
||||
- `avif`: Always AVIF (with WebP fallback)
|
||||
- `webp`: Always WebP
|
||||
- `jpeg`: JPEG (progressive)
|
||||
- `baseline-jpeg`: JPEG (baseline, for older devices)
|
||||
- `json`: Image metadata instead of image
|
||||
|
||||
**URL**: `format=auto` or `f=auto`
|
||||
**Workers**: `{ format: 'auto' }`
|
||||
**Default**: Original format
|
||||
**Recommended**: `format=auto` for optimal delivery
|
||||
|
||||
### compression
|
||||
WebP compression mode.
|
||||
|
||||
**Options**:
|
||||
- `fast`: Faster encoding, larger file
|
||||
- `lossless`: No quality loss
|
||||
|
||||
**URL**: `compression=fast`
|
||||
**Workers**: `{ compression: 'fast' }`
|
||||
|
||||
---
|
||||
|
||||
## Cropping
|
||||
|
||||
### gravity
|
||||
Crop focal point.
|
||||
|
||||
**Options**:
|
||||
- `auto`: Smart crop based on saliency
|
||||
- `face`: Crop to detected face
|
||||
- `left`, `right`, `top`, `bottom`: Crop to side
|
||||
- `XxY`: Coordinates (e.g., `0.5x0.5` for center)
|
||||
|
||||
**URL**: `gravity=face` or `gravity=0.5x0.3`
|
||||
**Workers**: `{ gravity: 'face' }` or `{ gravity: { x: 0.5, y: 0.3 } }`
|
||||
**Default**: `auto`
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
gravity=auto: Smart crop to interesting area
|
||||
gravity=face: Crop to detected face (if found)
|
||||
gravity=0.5x0.5: Center crop
|
||||
gravity=0x0: Top-left corner
|
||||
gravity=1x1: Bottom-right corner
|
||||
```
|
||||
|
||||
### zoom
|
||||
Face cropping zoom level (when `gravity=face`).
|
||||
|
||||
**URL**: `zoom=0.5`
|
||||
**Workers**: `{ zoom: 0.5 }`
|
||||
**Range**: 0-1
|
||||
**Default**: 0
|
||||
**Behavior**: `0` = include background, `1` = crop close to face
|
||||
|
||||
### trim
|
||||
Remove border (pixels to trim from edges).
|
||||
|
||||
**URL**: `trim=10`
|
||||
**Workers**: `{ trim: 10 }`
|
||||
**Range**: 0-100
|
||||
|
||||
---
|
||||
|
||||
## Effects
|
||||
|
||||
### blur
|
||||
Gaussian blur radius.
|
||||
|
||||
**URL**: `blur=20`
|
||||
**Workers**: `{ blur: 20 }`
|
||||
**Range**: 1-250
|
||||
**Use cases**: Privacy, background blur, LQIP placeholders
|
||||
|
||||
### sharpen
|
||||
Sharpen intensity.
|
||||
|
||||
**URL**: `sharpen=3`
|
||||
**Workers**: `{ sharpen: 3 }`
|
||||
**Range**: 0-10
|
||||
**Recommended**: 1-3 for subtle sharpening
|
||||
|
||||
### brightness
|
||||
Brightness adjustment.
|
||||
|
||||
**URL**: `brightness=1.2`
|
||||
**Workers**: `{ brightness: 1.2 }`
|
||||
**Range**: 0-2
|
||||
**Default**: 1 (no change)
|
||||
**Examples**: `0.5` = darker, `1.5` = brighter
|
||||
|
||||
### contrast
|
||||
Contrast adjustment.
|
||||
|
||||
**URL**: `contrast=1.1`
|
||||
**Workers**: `{ contrast: 1.1 }`
|
||||
**Range**: 0-2
|
||||
**Default**: 1 (no change)
|
||||
|
||||
### gamma
|
||||
Gamma correction.
|
||||
|
||||
**URL**: `gamma=1.5`
|
||||
**Workers**: `{ gamma: 1.5 }`
|
||||
**Range**: 0-2
|
||||
**Default**: 1 (no change)
|
||||
**Note**: `0` is ignored
|
||||
|
||||
---
|
||||
|
||||
## Rotation & Flipping
|
||||
|
||||
### rotate
|
||||
Rotate image.
|
||||
|
||||
**Options**: `0`, `90`, `180`, `270`
|
||||
|
||||
**URL**: `rotate=90`
|
||||
**Workers**: `{ rotate: 90 }`
|
||||
|
||||
### flip
|
||||
Flip image.
|
||||
|
||||
**Options**:
|
||||
- `h`: Horizontal flip
|
||||
- `v`: Vertical flip
|
||||
- `hv`: Both horizontal and vertical
|
||||
|
||||
**URL**: `flip=h`
|
||||
**Workers**: `{ flip: 'h' }`
|
||||
|
||||
**Note**: Flipping is performed BEFORE rotation.
|
||||
|
||||
---
|
||||
|
||||
## Other
|
||||
|
||||
### background
|
||||
Background color for transparency or padding.
|
||||
|
||||
**URL**: `background=rgb(255 0 0)` (CSS4 syntax)
|
||||
**Workers**: `{ background: 'rgb(255 0 0)' }`
|
||||
**Examples**:
|
||||
- `background=white`
|
||||
- `background=rgb(255 255 255)`
|
||||
- `background=rgba(255 255 255 50)`
|
||||
|
||||
**Use with**: `fit=pad` or transparent images (PNG, WebP)
|
||||
|
||||
### metadata
|
||||
EXIF metadata handling.
|
||||
|
||||
**Options**:
|
||||
- `none`: Strip all metadata
|
||||
- `copyright`: Keep only copyright tag
|
||||
- `keep`: Preserve most EXIF metadata
|
||||
|
||||
**URL**: `metadata=keep`
|
||||
**Workers**: `{ metadata: 'keep' }`
|
||||
**Default**: `copyright` for JPEG, `none` for others
|
||||
|
||||
**Note**: Color profiles and EXIF rotation always applied, even if metadata stripped.
|
||||
|
||||
### anim
|
||||
Preserve animation frames (GIF, WebP).
|
||||
|
||||
**URL**: `anim=false`
|
||||
**Workers**: `{ anim: false }`
|
||||
**Default**: `true`
|
||||
|
||||
**Use case**: Converting animated GIF to still image
|
||||
|
||||
---
|
||||
|
||||
## Combining Options
|
||||
|
||||
**URL Format**:
|
||||
```
|
||||
/cdn-cgi/image/width=800,height=600,fit=cover,quality=85,format=auto/image.jpg
|
||||
```
|
||||
|
||||
**Workers Format**:
|
||||
```javascript
|
||||
fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
fit: 'cover',
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Presets
|
||||
|
||||
### Thumbnail
|
||||
```
|
||||
width=300,height=300,fit=cover,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Avatar
|
||||
```
|
||||
width=200,height=200,fit=cover,gravity=face,quality=90,format=auto
|
||||
```
|
||||
|
||||
### Hero
|
||||
```
|
||||
width=1920,height=1080,fit=cover,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Blur Placeholder (LQIP)
|
||||
```
|
||||
width=50,quality=10,blur=20,format=webp
|
||||
```
|
||||
|
||||
### Product Image
|
||||
```
|
||||
width=800,height=800,fit=contain,sharpen=2,quality=90,format=auto
|
||||
```
|
||||
|
||||
### Responsive (Mobile)
|
||||
```
|
||||
width=480,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Responsive (Tablet)
|
||||
```
|
||||
width=768,quality=85,format=auto
|
||||
```
|
||||
|
||||
### Responsive (Desktop)
|
||||
```
|
||||
width=1920,quality=85,format=auto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
- **Max dimensions**: 10,000 x 10,000 pixels
|
||||
- **Max area**: 100 megapixels
|
||||
- **Max file size**: No published limit (but 10MB recommended)
|
||||
- **Quality range**: 1-100
|
||||
- **DPR range**: 1-3
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||
- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||
264
references/variants-guide.md
Normal file
264
references/variants-guide.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Variants Guide - Named vs Flexible
|
||||
|
||||
Complete guide to Cloudflare Images variants.
|
||||
|
||||
---
|
||||
|
||||
## What Are Variants?
|
||||
|
||||
Variants define how images should be resized and transformed for different use cases.
|
||||
|
||||
**Two Types**:
|
||||
1. **Named Variants** - Predefined transformations (up to 100)
|
||||
2. **Flexible Variants** - Dynamic transformations (unlimited)
|
||||
|
||||
---
|
||||
|
||||
## Named Variants
|
||||
|
||||
### Overview
|
||||
|
||||
Pre-configured transformations that apply consistently across all images.
|
||||
|
||||
**Limits**: 100 variants per account
|
||||
**Use with**: Public and private images (signed URLs compatible)
|
||||
|
||||
### Creating Named Variants
|
||||
|
||||
**Via API**:
|
||||
```bash
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{
|
||||
"id": "thumbnail",
|
||||
"options": {
|
||||
"fit": "cover",
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"metadata": "none"
|
||||
},
|
||||
"neverRequireSignedURLs": false
|
||||
}'
|
||||
```
|
||||
|
||||
**Via Dashboard**:
|
||||
1. Dashboard → Images → Variants
|
||||
2. Create variant
|
||||
3. Set dimensions, fit mode, metadata handling
|
||||
|
||||
### Using Named Variants
|
||||
|
||||
**URL Format**:
|
||||
```
|
||||
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT_NAME>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<img src="https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/abc123/thumbnail" />
|
||||
```
|
||||
|
||||
### Common Named Variants
|
||||
|
||||
```javascript
|
||||
const presets = {
|
||||
thumbnail: { width: 300, height: 300, fit: 'cover' },
|
||||
avatar: { width: 200, height: 200, fit: 'cover' },
|
||||
small: { width: 480, fit: 'scale-down' },
|
||||
medium: { width: 768, fit: 'scale-down' },
|
||||
large: { width: 1920, fit: 'scale-down' },
|
||||
hero: { width: 1920, height: 1080, fit: 'cover' },
|
||||
product: { width: 800, height: 800, fit: 'contain' }
|
||||
};
|
||||
```
|
||||
|
||||
### When to Use Named Variants
|
||||
|
||||
✅ **Use when**:
|
||||
- Consistent sizes needed across app
|
||||
- Private images (signed URLs required)
|
||||
- Predictable, simple URLs
|
||||
- Team collaboration (shared definitions)
|
||||
|
||||
❌ **Don't use when**:
|
||||
- Need dynamic sizing per request
|
||||
- Rapid prototyping with many sizes
|
||||
- Approaching 100-variant limit
|
||||
|
||||
---
|
||||
|
||||
## Flexible Variants
|
||||
|
||||
### Overview
|
||||
|
||||
Dynamic transformations using params directly in URL.
|
||||
|
||||
**Limits**: Unlimited transformations
|
||||
**Use with**: Public images only (signed URLs NOT compatible)
|
||||
|
||||
### Enabling Flexible Variants
|
||||
|
||||
**One-time setup per account**:
|
||||
```bash
|
||||
curl --request PATCH \
|
||||
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/config \
|
||||
--header "Authorization: Bearer <API_TOKEN>" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"flexible_variants": true}'
|
||||
```
|
||||
|
||||
### Using Flexible Variants
|
||||
|
||||
**URL Format**:
|
||||
```
|
||||
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<TRANSFORMATION_PARAMS>
|
||||
```
|
||||
|
||||
**Examples**:
|
||||
```html
|
||||
<!-- Basic resize -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=400,h=300" />
|
||||
|
||||
<!-- With quality and format -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto" />
|
||||
|
||||
<!-- Sharpen and crop -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=600,h=600,fit=cover,sharpen=3" />
|
||||
|
||||
<!-- Blur effect -->
|
||||
<img src="https://imagedelivery.net/HASH/ID/w=500,blur=20,q=50" />
|
||||
```
|
||||
|
||||
### Available Parameters
|
||||
|
||||
Same as transformation options:
|
||||
- `w`, `h` - Width, height
|
||||
- `fit` - Fit mode (scale-down, contain, cover, crop, pad)
|
||||
- `q` - Quality (1-100)
|
||||
- `f` - Format (auto, avif, webp, jpeg)
|
||||
- `gravity` - Crop focal point (auto, face, left, right, top, bottom)
|
||||
- `blur`, `sharpen`, `brightness`, `contrast`, `gamma`
|
||||
- `rotate`, `flip`
|
||||
- `dpr` - Device pixel ratio
|
||||
- `metadata` - EXIF handling (none, copyright, keep)
|
||||
- `anim` - Preserve animation (true/false)
|
||||
|
||||
### When to Use Flexible Variants
|
||||
|
||||
✅ **Use when**:
|
||||
- Dynamic sizing needs
|
||||
- Public images only
|
||||
- Rapid prototyping
|
||||
- User-controlled transformations
|
||||
|
||||
❌ **Don't use when**:
|
||||
- Need signed URLs (private images)
|
||||
- Want consistent, predictable URLs
|
||||
- Team needs shared definitions
|
||||
|
||||
---
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Feature | Named Variants | Flexible Variants |
|
||||
|---------|---------------|-------------------|
|
||||
| **Limit** | 100 per account | Unlimited |
|
||||
| **Signed URLs** | ✅ Compatible | ❌ Not compatible |
|
||||
| **URL Format** | `/thumbnail` | `/w=400,h=300,fit=cover` |
|
||||
| **URL Length** | Short, clean | Longer, dynamic |
|
||||
| **Setup** | Create variants first | Enable once, use anywhere |
|
||||
| **Use Case** | Consistent sizes | Dynamic sizing |
|
||||
| **Team Sharing** | Shared definitions | Ad-hoc transformations |
|
||||
| **Private Images** | ✅ Supported | ❌ Public only |
|
||||
|
||||
---
|
||||
|
||||
## Combining Both
|
||||
|
||||
You can use both types in the same account:
|
||||
|
||||
```html
|
||||
<!-- Named variant for avatar (private image, signed URL) -->
|
||||
<img src="https://imagedelivery.net/HASH/PRIVATE_ID/avatar?exp=123&sig=abc" />
|
||||
|
||||
<!-- Flexible variant for public thumbnail -->
|
||||
<img src="https://imagedelivery.net/HASH/PUBLIC_ID/w=300,h=300,fit=cover" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Named Variants
|
||||
|
||||
1. **Create core sizes first**: thumbnail, small, medium, large
|
||||
2. **Use descriptive names**: `product-square`, `hero-wide`, `avatar-round`
|
||||
3. **Document variant usage**: Share definitions with team
|
||||
4. **Set consistent quality**: 85 for photos, 90+ for graphics
|
||||
5. **Use `metadata: none`**: Unless specific need to preserve EXIF
|
||||
|
||||
### For Flexible Variants
|
||||
|
||||
1. **Always use `f=auto`**: Optimal format for each browser
|
||||
2. **Limit dynamic range**: Don't allow arbitrary sizes (performance)
|
||||
3. **Cache popular sizes**: Create named variants for common sizes
|
||||
4. **URL-encode params**: Especially if using special characters
|
||||
5. **Public images only**: Remember signed URL incompatibility
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategies
|
||||
|
||||
### From Flexible to Named
|
||||
|
||||
If approaching flexibility limits or need signed URLs:
|
||||
|
||||
```javascript
|
||||
// Analyze usage logs
|
||||
const popularSizes = analyzeImageRequests();
|
||||
// { w=300,h=300: 50000, w=800,h=600: 30000, ... }
|
||||
|
||||
// Create named variants for top sizes
|
||||
for (const [params, count] of Object.entries(popularSizes)) {
|
||||
if (count > 10000) {
|
||||
await createVariant(getNameForParams(params), parseParams(params));
|
||||
}
|
||||
}
|
||||
|
||||
// Update URLs from flexible to named
|
||||
// Before: /w=300,h=300,fit=cover
|
||||
// After: /thumbnail
|
||||
```
|
||||
|
||||
### From Named to Flexible
|
||||
|
||||
If need more than 100 variants:
|
||||
|
||||
1. Enable flexible variants
|
||||
2. Gradually migrate to dynamic params
|
||||
3. Keep popular sizes as named variants
|
||||
4. Use flexible for long-tail sizes
|
||||
|
||||
---
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
**Named Variants**:
|
||||
- Cached at edge (fast delivery)
|
||||
- Predictable bandwidth
|
||||
- Good for high traffic
|
||||
|
||||
**Flexible Variants**:
|
||||
- Also cached at edge
|
||||
- More cache keys (potentially)
|
||||
- Good for diverse sizing needs
|
||||
|
||||
**Both**: First transformation billable, subsequent cached requests free
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation
|
||||
|
||||
- **Create Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/
|
||||
- **Enable Flexible Variants**: https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/
|
||||
106
scripts/check-versions.sh
Executable file
106
scripts/check-versions.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Cloudflare Images - Version Checker
|
||||
# Verifies API endpoints are current
|
||||
|
||||
echo "Cloudflare Images - API Version Checker"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if API token is set
|
||||
if [ -z "$IMAGES_API_TOKEN" ]; then
|
||||
echo -e "${YELLOW}WARNING:${NC} IMAGES_API_TOKEN environment variable not set"
|
||||
echo "Set it with: export IMAGES_API_TOKEN=your_token_here"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGES_ACCOUNT_ID" ]; then
|
||||
echo -e "${YELLOW}WARNING:${NC} IMAGES_ACCOUNT_ID environment variable not set"
|
||||
echo "Set it with: export IMAGES_ACCOUNT_ID=your_account_id"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Checking Cloudflare Images API endpoints..."
|
||||
echo ""
|
||||
|
||||
# Check main API endpoint
|
||||
echo -n "Checking /images/v1 endpoint... "
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v1" \
|
||||
-H "Authorization: Bearer ${IMAGES_API_TOKEN}" 2>/dev/null)
|
||||
|
||||
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then
|
||||
echo -e "${GREEN}✓ Available${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}"
|
||||
fi
|
||||
|
||||
# Check v2 endpoint
|
||||
echo -n "Checking /images/v2 endpoint... "
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v2" \
|
||||
-H "Authorization: Bearer ${IMAGES_API_TOKEN}" 2>/dev/null)
|
||||
|
||||
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then
|
||||
echo -e "${GREEN}✓ Available${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}"
|
||||
fi
|
||||
|
||||
# Check direct upload endpoint
|
||||
echo -n "Checking /images/v2/direct_upload endpoint... "
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v2/direct_upload" \
|
||||
-H "Authorization: Bearer ${IMAGES_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' 2>/dev/null)
|
||||
|
||||
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "400" ] || [ "$RESPONSE" = "401" ]; then
|
||||
echo -e "${GREEN}✓ Available${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}"
|
||||
fi
|
||||
|
||||
# Check batch API endpoint
|
||||
echo -n "Checking batch.imagedelivery.net endpoint... "
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"https://batch.imagedelivery.net/images/v1" \
|
||||
-H "Authorization: Bearer ${IMAGES_BATCH_TOKEN}" 2>/dev/null)
|
||||
|
||||
if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then
|
||||
echo -e "${GREEN}✓ Available${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Cannot verify (set IMAGES_BATCH_TOKEN if using)${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Package Recommendations:"
|
||||
echo "========================"
|
||||
echo "TypeScript types: @cloudflare/workers-types@latest"
|
||||
echo "Wrangler CLI: wrangler@latest"
|
||||
echo ""
|
||||
echo "No npm packages required for Cloudflare Images API"
|
||||
echo "(uses native fetch API)"
|
||||
echo ""
|
||||
|
||||
# Check if wrangler is installed
|
||||
if command -v wrangler &> /dev/null; then
|
||||
WRANGLER_VERSION=$(wrangler --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
|
||||
echo -e "${GREEN}✓${NC} Wrangler installed: v$WRANGLER_VERSION"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Wrangler not installed (optional)"
|
||||
echo " Install: npm install -g wrangler"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "API Version: v2 (direct uploads), v1 (standard uploads)"
|
||||
echo "Last Verified: 2025-10-26"
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ All core endpoints available${NC}"
|
||||
308
templates/batch-upload.ts
Normal file
308
templates/batch-upload.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Cloudflare Images - Batch API
|
||||
*
|
||||
* High-volume image uploads using batch tokens.
|
||||
*
|
||||
* When to use:
|
||||
* - Migrating thousands of images
|
||||
* - Bulk upload workflows
|
||||
* - Automated image ingestion
|
||||
*
|
||||
* IMPORTANT: Batch API uses different host and authentication
|
||||
* - Host: batch.imagedelivery.net (NOT api.cloudflare.com)
|
||||
* - Auth: Batch token (NOT regular API token)
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
IMAGES_BATCH_TOKEN: string; // From Dashboard → Images → Batch API
|
||||
}
|
||||
|
||||
interface BatchUploadOptions {
|
||||
id?: string;
|
||||
requireSignedURLs?: boolean;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CloudflareImagesResponse {
|
||||
success: boolean;
|
||||
result?: {
|
||||
id: string;
|
||||
filename: string;
|
||||
uploaded: string;
|
||||
requireSignedURLs: boolean;
|
||||
variants: string[];
|
||||
};
|
||||
errors?: Array<{ code: number; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload single image via Batch API
|
||||
*/
|
||||
export async function batchUploadImage(
|
||||
file: File,
|
||||
options: BatchUploadOptions = {},
|
||||
env: Env
|
||||
): Promise<CloudflareImagesResponse> {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', file);
|
||||
|
||||
if (options.id) {
|
||||
formData.append('id', options.id);
|
||||
}
|
||||
|
||||
if (options.requireSignedURLs !== undefined) {
|
||||
formData.append('requireSignedURLs', String(options.requireSignedURLs));
|
||||
}
|
||||
|
||||
if (options.metadata) {
|
||||
formData.append('metadata', JSON.stringify(options.metadata));
|
||||
}
|
||||
|
||||
const response = await fetch('https://batch.imagedelivery.net/images/v1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload image via URL using Batch API
|
||||
*/
|
||||
export async function batchUploadViaURL(
|
||||
imageUrl: string,
|
||||
options: BatchUploadOptions = {},
|
||||
env: Env
|
||||
): Promise<CloudflareImagesResponse> {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('url', imageUrl);
|
||||
|
||||
if (options.id) {
|
||||
formData.append('id', options.id);
|
||||
}
|
||||
|
||||
if (options.requireSignedURLs !== undefined) {
|
||||
formData.append('requireSignedURLs', String(options.requireSignedURLs));
|
||||
}
|
||||
|
||||
if (options.metadata) {
|
||||
formData.append('metadata', JSON.stringify(options.metadata));
|
||||
}
|
||||
|
||||
const response = await fetch('https://batch.imagedelivery.net/images/v1', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List images via Batch API
|
||||
*/
|
||||
export async function batchListImages(
|
||||
page: number = 1,
|
||||
perPage: number = 100,
|
||||
env: Env
|
||||
): Promise<{ success: boolean; result?: { images: unknown[] } }> {
|
||||
const response = await fetch(
|
||||
`https://batch.imagedelivery.net/images/v2?page=${page}&per_page=${perPage}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel batch upload (multiple images at once)
|
||||
*/
|
||||
export async function uploadMultipleImages(
|
||||
images: Array<{ file?: File; url?: string; id?: string; metadata?: Record<string, string> }>,
|
||||
concurrency: number = 5,
|
||||
env: Env
|
||||
): Promise<Array<{ input: unknown; result?: CloudflareImagesResponse; error?: string }>> {
|
||||
const results: Array<{ input: unknown; result?: CloudflareImagesResponse; error?: string }> = [];
|
||||
const chunks: typeof images[] = [];
|
||||
|
||||
// Split into chunks for parallel processing
|
||||
for (let i = 0; i < images.length; i += concurrency) {
|
||||
chunks.push(images.slice(i, i + concurrency));
|
||||
}
|
||||
|
||||
// Process each chunk
|
||||
for (const chunk of chunks) {
|
||||
const promises = chunk.map(async (img) => {
|
||||
try {
|
||||
let result: CloudflareImagesResponse;
|
||||
|
||||
if (img.file) {
|
||||
result = await batchUploadImage(img.file, { id: img.id, metadata: img.metadata }, env);
|
||||
} else if (img.url) {
|
||||
result = await batchUploadViaURL(img.url, { id: img.id, metadata: img.metadata }, env);
|
||||
} else {
|
||||
throw new Error('Must provide either file or url');
|
||||
}
|
||||
|
||||
return { input: img, result };
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
input: img,
|
||||
error: error instanceof Error ? error.message : 'Upload failed'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const chunkResults = await Promise.all(promises);
|
||||
results.push(...chunkResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration helper: Bulk ingest from URLs
|
||||
*/
|
||||
export async function migrateImagesFromURLs(
|
||||
imageUrls: string[],
|
||||
options: {
|
||||
concurrency?: number;
|
||||
prefix?: string; // ID prefix for all images
|
||||
metadata?: Record<string, string>;
|
||||
} = {},
|
||||
env: Env
|
||||
): Promise<{
|
||||
successful: number;
|
||||
failed: number;
|
||||
results: Array<{ url: string; id?: string; error?: string }>;
|
||||
}> {
|
||||
const concurrency = options.concurrency || 5;
|
||||
const successful: string[] = [];
|
||||
const failed: string[] = [];
|
||||
const results: Array<{ url: string; id?: string; error?: string }> = [];
|
||||
|
||||
const images = imageUrls.map((url, index) => ({
|
||||
url,
|
||||
id: options.prefix ? `${options.prefix}-${index}` : undefined,
|
||||
metadata: options.metadata
|
||||
}));
|
||||
|
||||
const uploadResults = await uploadMultipleImages(images, concurrency, env);
|
||||
|
||||
for (const result of uploadResults) {
|
||||
const input = result.input as { url: string; id?: string };
|
||||
|
||||
if (result.error) {
|
||||
failed.push(input.url);
|
||||
results.push({ url: input.url, error: result.error });
|
||||
} else {
|
||||
successful.push(input.url);
|
||||
results.push({ url: input.url, id: result.result?.result?.id });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Example Worker
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Bulk upload: POST /api/batch-upload
|
||||
if (request.method === 'POST' && url.pathname === '/api/batch-upload') {
|
||||
try {
|
||||
const body = await request.json<{ imageUrls: string[] }>();
|
||||
|
||||
if (!body.imageUrls || !Array.isArray(body.imageUrls)) {
|
||||
return Response.json({ error: 'imageUrls array required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await migrateImagesFromURLs(
|
||||
body.imageUrls,
|
||||
{
|
||||
concurrency: 5,
|
||||
prefix: 'migration',
|
||||
metadata: { source: 'bulk-upload' }
|
||||
},
|
||||
env
|
||||
);
|
||||
|
||||
return Response.json(result);
|
||||
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Batch upload failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Usage examples:
|
||||
*
|
||||
* ```typescript
|
||||
* // Single upload via Batch API
|
||||
* const result = await batchUploadImage(file, {
|
||||
* metadata: { source: 'migration' }
|
||||
* }, env);
|
||||
*
|
||||
* // Upload from URL
|
||||
* const result = await batchUploadViaURL('https://example.com/image.jpg', {}, env);
|
||||
*
|
||||
* // Parallel upload multiple images
|
||||
* const images = [
|
||||
* { file: file1, id: 'image-1' },
|
||||
* { file: file2, id: 'image-2' },
|
||||
* { url: 'https://example.com/image3.jpg', id: 'image-3' }
|
||||
* ];
|
||||
* const results = await uploadMultipleImages(images, 5, env);
|
||||
*
|
||||
* // Migrate from URLs
|
||||
* const urls = [
|
||||
* 'https://old-cdn.example.com/image1.jpg',
|
||||
* 'https://old-cdn.example.com/image2.jpg',
|
||||
* // ... thousands more
|
||||
* ];
|
||||
* const migration = await migrateImagesFromURLs(urls, {
|
||||
* concurrency: 10,
|
||||
* prefix: 'migrated',
|
||||
* metadata: { migratedAt: new Date().toISOString() }
|
||||
* }, env);
|
||||
*
|
||||
* console.log(`Successful: ${migration.successful}, Failed: ${migration.failed}`);
|
||||
* ```
|
||||
*
|
||||
* SETUP:
|
||||
* 1. Dashboard → Images → Batch API
|
||||
* 2. Create batch token
|
||||
* 3. Add to wrangler.toml: wrangler secret put IMAGES_BATCH_TOKEN
|
||||
*
|
||||
* DIFFERENCES FROM REGULAR API:
|
||||
* - Host: batch.imagedelivery.net (NOT api.cloudflare.com)
|
||||
* - Auth: Batch token (NOT regular API token)
|
||||
* - Same endpoints: /images/v1, /images/v2
|
||||
* - Rate limits may differ (contact Cloudflare for high-volume needs)
|
||||
*/
|
||||
208
templates/direct-creator-upload-backend.ts
Normal file
208
templates/direct-creator-upload-backend.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Cloudflare Images - Direct Creator Upload (Backend)
|
||||
*
|
||||
* Generate one-time upload URLs for users to upload directly to Cloudflare.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Frontend requests upload URL from this backend
|
||||
* 2. Backend calls Cloudflare /direct_upload API
|
||||
* 3. Backend returns uploadURL to frontend
|
||||
* 4. Frontend uploads directly to Cloudflare using uploadURL
|
||||
*
|
||||
* Benefits:
|
||||
* - No API key exposure to browser
|
||||
* - Users upload directly to Cloudflare (faster)
|
||||
* - No intermediary storage needed
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_ID: string;
|
||||
IMAGES_API_TOKEN: string;
|
||||
}
|
||||
|
||||
interface DirectUploadOptions {
|
||||
requireSignedURLs?: boolean;
|
||||
metadata?: Record<string, string>;
|
||||
expiry?: string; // ISO 8601 format (default: 30min, max: 6hr)
|
||||
id?: string; // Custom ID (optional)
|
||||
}
|
||||
|
||||
interface DirectUploadResponse {
|
||||
success: boolean;
|
||||
result?: {
|
||||
id: string; // Image ID that will be uploaded
|
||||
uploadURL: string; // One-time upload URL for frontend
|
||||
};
|
||||
errors?: Array<{ code: number; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one-time upload URL
|
||||
*/
|
||||
export async function generateUploadURL(
|
||||
options: DirectUploadOptions = {},
|
||||
env: Env
|
||||
): Promise<DirectUploadResponse> {
|
||||
const requestBody: Record<string, unknown> = {};
|
||||
|
||||
// Optional: Require signed URLs for private images
|
||||
if (options.requireSignedURLs !== undefined) {
|
||||
requestBody.requireSignedURLs = options.requireSignedURLs;
|
||||
}
|
||||
|
||||
// Optional: Metadata (attached to image, not visible to end users)
|
||||
if (options.metadata) {
|
||||
requestBody.metadata = options.metadata;
|
||||
}
|
||||
|
||||
// Optional: Expiry (default 30min, max 6hr from now)
|
||||
if (options.expiry) {
|
||||
requestBody.expiry = options.expiry;
|
||||
}
|
||||
|
||||
// Optional: Custom ID (cannot use with requireSignedURLs=true)
|
||||
if (options.id) {
|
||||
requestBody.id = options.id;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v2/direct_upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
}
|
||||
);
|
||||
|
||||
const result: DirectUploadResponse = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to generate upload URL:', result.errors);
|
||||
throw new Error(`Failed to generate upload URL: ${result.errors?.[0]?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example Cloudflare Worker endpoint
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// CORS headers for frontend
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*', // Replace with your domain
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
};
|
||||
|
||||
// Handle preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
// Endpoint: POST /api/upload-url
|
||||
if (request.method === 'POST' && url.pathname === '/api/upload-url') {
|
||||
try {
|
||||
const body = await request.json<{
|
||||
userId?: string;
|
||||
requireSignedURLs?: boolean;
|
||||
}>();
|
||||
|
||||
// Generate upload URL
|
||||
const result = await generateUploadURL(
|
||||
{
|
||||
requireSignedURLs: body.requireSignedURLs ?? false,
|
||||
metadata: {
|
||||
userId: body.userId || 'anonymous',
|
||||
uploadedAt: new Date().toISOString()
|
||||
},
|
||||
// Set expiry: 1 hour from now
|
||||
expiry: new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
env
|
||||
);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
uploadURL: result.result?.uploadURL,
|
||||
imageId: result.result?.id
|
||||
},
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to generate upload URL' },
|
||||
{ status: 500, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check upload status (useful with webhooks)
|
||||
*/
|
||||
export async function checkImageStatus(
|
||||
imageId: string,
|
||||
env: Env
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
id: string;
|
||||
uploaded: string;
|
||||
draft?: boolean; // true if upload not completed yet
|
||||
variants?: string[];
|
||||
};
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/${imageId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage example:
|
||||
*
|
||||
* Frontend calls this endpoint:
|
||||
* ```javascript
|
||||
* const response = await fetch('/api/upload-url', {
|
||||
* method: 'POST',
|
||||
* headers: { 'Content-Type': 'application/json' },
|
||||
* body: JSON.stringify({ userId: '12345' })
|
||||
* });
|
||||
* const { uploadURL, imageId } = await response.json();
|
||||
*
|
||||
* // Now frontend can upload directly to uploadURL
|
||||
* const formData = new FormData();
|
||||
* formData.append('file', fileInput.files[0]); // MUST be named 'file'
|
||||
*
|
||||
* await fetch(uploadURL, {
|
||||
* method: 'POST',
|
||||
* body: formData // NO Content-Type header
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Custom expiry:
|
||||
* ```typescript
|
||||
* const result = await generateUploadURL({
|
||||
* expiry: new Date('2025-10-26T18:00:00Z').toISOString(), // Specific time
|
||||
* metadata: { purpose: 'profile-photo' }
|
||||
* }, env);
|
||||
* ```
|
||||
*/
|
||||
294
templates/direct-creator-upload-frontend.html
Normal file
294
templates/direct-creator-upload-frontend.html
Normal file
@@ -0,0 +1,294 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Direct Creator Upload - Cloudflare Images</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { margin-bottom: 2rem; }
|
||||
.upload-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.file-input-wrapper {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.file-input-wrapper:hover { border-color: #007bff; background: #f8f9fa; }
|
||||
.file-input-wrapper.dragover { border-color: #28a745; background: #e7f5e9; }
|
||||
input[type="file"] { display: none; }
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #0056b3; }
|
||||
button:disabled { background: #6c757d; cursor: not-allowed; }
|
||||
.progress {
|
||||
height: 30px;
|
||||
background: #e9ecef;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #28a745;
|
||||
transition: width 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
.message {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
.message.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.message.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.preview { margin-top: 1rem; max-width: 100%; border-radius: 6px; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Upload Image to Cloudflare</h1>
|
||||
|
||||
<form id="upload-form" class="upload-form">
|
||||
<label for="file-input" class="file-input-wrapper" id="drop-zone">
|
||||
<div>
|
||||
<p><strong>Choose a file</strong> or drag it here</p>
|
||||
<p style="margin-top: 0.5rem; color: #666;">Max 10MB, JPEG/PNG/WebP/GIF</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" />
|
||||
</label>
|
||||
|
||||
<div id="file-name" style="color: #666;"></div>
|
||||
|
||||
<img id="preview" class="preview" alt="Preview" />
|
||||
|
||||
<button type="submit" id="upload-btn" disabled>Upload Image</button>
|
||||
|
||||
<div class="progress" id="progress">
|
||||
<div class="progress-bar" id="progress-bar">0%</div>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Configuration
|
||||
const API_ENDPOINT = '/api/upload-url'; // Your backend endpoint
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// Elements
|
||||
const form = document.getElementById('upload-form');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileName = document.getElementById('file-name');
|
||||
const preview = document.getElementById('preview');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
const progress = document.getElementById('progress');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const message = document.getElementById('message');
|
||||
|
||||
let selectedFile = null;
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
|
||||
// Drag and drop
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
handleFileSelect();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle file selection
|
||||
function handleFileSelect() {
|
||||
selectedFile = fileInput.files[0];
|
||||
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (selectedFile.size > MAX_FILE_SIZE) {
|
||||
showMessage(`File too large (${(selectedFile.size / 1024 / 1024).toFixed(2)}MB). Max 10MB.`, 'error');
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!selectedFile.type.startsWith('image/')) {
|
||||
showMessage('Please select an image file.', 'error');
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show file name
|
||||
fileName.textContent = `Selected: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)}MB)`;
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
preview.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(selectedFile);
|
||||
|
||||
// Enable upload button
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedFile) {
|
||||
showMessage('Please select a file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Disable form
|
||||
uploadBtn.disabled = true;
|
||||
fileInput.disabled = true;
|
||||
progress.style.display = 'block';
|
||||
message.style.display = 'none';
|
||||
|
||||
// Step 1: Get upload URL from backend
|
||||
showProgress(10, 'Requesting upload URL...');
|
||||
|
||||
const urlResponse = await fetch(API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: 'user-123', // Replace with actual user ID
|
||||
requireSignedURLs: false
|
||||
})
|
||||
});
|
||||
|
||||
if (!urlResponse.ok) {
|
||||
throw new Error('Failed to get upload URL');
|
||||
}
|
||||
|
||||
const { uploadURL, imageId } = await urlResponse.json();
|
||||
|
||||
// Step 2: Upload directly to Cloudflare
|
||||
showProgress(30, 'Uploading...');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile); // MUST be named 'file'
|
||||
|
||||
const uploadResponse = await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
// NO Content-Type header - browser sets multipart/form-data automatically
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
||||
}
|
||||
|
||||
const uploadResult = await uploadResponse.json();
|
||||
|
||||
showProgress(100, 'Complete!');
|
||||
|
||||
// Success
|
||||
setTimeout(() => {
|
||||
showMessage(`✓ Upload successful! Image ID: ${imageId}`, 'success');
|
||||
progress.style.display = 'none';
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
showMessage(`✗ Upload failed: ${error.message}`, 'error');
|
||||
progress.style.display = 'none';
|
||||
uploadBtn.disabled = false;
|
||||
fileInput.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: Show progress
|
||||
function showProgress(percent, text) {
|
||||
progressBar.style.width = `${percent}%`;
|
||||
progressBar.textContent = text || `${percent}%`;
|
||||
}
|
||||
|
||||
// Helper: Show message
|
||||
function showMessage(text, type) {
|
||||
message.textContent = text;
|
||||
message.className = `message ${type}`;
|
||||
message.style.display = 'block';
|
||||
}
|
||||
|
||||
// Helper: Reset form
|
||||
function resetForm() {
|
||||
selectedFile = null;
|
||||
fileInput.value = '';
|
||||
fileName.textContent = '';
|
||||
preview.style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<!--
|
||||
CRITICAL CORS FIX:
|
||||
|
||||
✅ CORRECT:
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile); // Name MUST be 'file'
|
||||
await fetch(uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData // Browser sets multipart/form-data automatically
|
||||
});
|
||||
|
||||
❌ WRONG:
|
||||
await fetch(uploadURL, {
|
||||
headers: { 'Content-Type': 'application/json' }, // CORS error
|
||||
body: JSON.stringify({ file: base64Image })
|
||||
});
|
||||
|
||||
ARCHITECTURE:
|
||||
1. Frontend → POST /api/upload-url → Backend
|
||||
2. Backend → POST /direct_upload → Cloudflare API
|
||||
3. Backend → Returns uploadURL → Frontend
|
||||
4. Frontend → Uploads to uploadURL → Cloudflare
|
||||
5. Cloudflare → Returns success → Frontend
|
||||
|
||||
WHY:
|
||||
- No API key exposure to browser
|
||||
- Users upload directly to Cloudflare (faster)
|
||||
- multipart/form-data required (CORS)
|
||||
- Field name MUST be 'file'
|
||||
-->
|
||||
16
templates/package.json
Normal file
16
templates/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "cloudflare-images-example",
|
||||
"version": "1.0.0",
|
||||
"description": "Cloudflare Images examples and templates",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"tail": "wrangler tail"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241112.0",
|
||||
"typescript": "^5.9.0",
|
||||
"wrangler": "^3.95.0"
|
||||
}
|
||||
}
|
||||
251
templates/responsive-images-srcset.html
Normal file
251
templates/responsive-images-srcset.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Responsive Images with Cloudflare Images</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { margin-bottom: 2rem; }
|
||||
section { margin-bottom: 3rem; }
|
||||
h2 { margin-bottom: 1rem; color: #333; }
|
||||
p { margin-bottom: 1rem; color: #666; }
|
||||
img { max-width: 100%; height: auto; display: block; border-radius: 8px; }
|
||||
.code-block { background: #f5f5f5; padding: 1rem; border-radius: 6px; margin-top: 0.5rem; overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Responsive Images with Cloudflare Images</h1>
|
||||
|
||||
<!-- Example 1: srcset with named variants -->
|
||||
<section>
|
||||
<h2>1. Using Named Variants</h2>
|
||||
<p>Serve different image sizes based on viewport width using predefined variants.</p>
|
||||
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile 480w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/tablet 768w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop"
|
||||
alt="Responsive image with named variants"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div class="code-block">
|
||||
<code>
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile 480w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/tablet 768w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop"
|
||||
alt="Responsive image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Example 2: srcset with flexible variants -->
|
||||
<section>
|
||||
<h2>2. Using Flexible Variants</h2>
|
||||
<p>Dynamic transformations with format=auto for optimal WebP/AVIF delivery.</p>
|
||||
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=768,f=auto 768w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto"
|
||||
alt="Responsive image with flexible variants"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div class="code-block">
|
||||
<code>
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=480,f=auto 480w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=768,f=auto 768w,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto"
|
||||
/>
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Example 3: Art direction with picture element -->
|
||||
<section>
|
||||
<h2>3. Art Direction (Different Crops)</h2>
|
||||
<p>Serve different image crops for mobile vs desktop (e.g., portrait on mobile, landscape on desktop).</p>
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile-square"
|
||||
/>
|
||||
<source
|
||||
media="(min-width: 768px)"
|
||||
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
|
||||
/>
|
||||
<img
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
|
||||
alt="Art directed image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<div class="code-block">
|
||||
<code>
|
||||
<picture>
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile-square"
|
||||
/>
|
||||
<source
|
||||
media="(min-width: 768px)"
|
||||
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
|
||||
/>
|
||||
<img
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
|
||||
alt="Art directed image"
|
||||
/>
|
||||
</picture>
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Example 4: Retina displays -->
|
||||
<section>
|
||||
<h2>4. High-DPI (Retina) Displays</h2>
|
||||
<p>Serve 2x images for high-resolution screens.</p>
|
||||
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=1,f=auto 1x,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=2,f=auto 2x
|
||||
"
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,f=auto"
|
||||
alt="Retina-ready image"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div class="code-block">
|
||||
<code>
|
||||
<img
|
||||
srcset="
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=1,f=auto 1x,
|
||||
https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=2,f=auto 2x
|
||||
"
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,f=auto"
|
||||
/>
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Example 5: Blur placeholder (LQIP) -->
|
||||
<section>
|
||||
<h2>5. Low-Quality Image Placeholder (LQIP)</h2>
|
||||
<p>Load a tiny blurred placeholder first, then swap to full image.</p>
|
||||
|
||||
<img
|
||||
id="lqip-image"
|
||||
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=50,q=10,blur=20,f=webp"
|
||||
data-src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto"
|
||||
alt="Image with LQIP"
|
||||
style="filter: blur(10px); transition: filter 0.3s;"
|
||||
/>
|
||||
|
||||
<script>
|
||||
const lqipImage = document.getElementById('lqip-image');
|
||||
const fullImageURL = lqipImage.getAttribute('data-src');
|
||||
|
||||
// Load full-size image
|
||||
const fullImage = new Image();
|
||||
fullImage.src = fullImageURL;
|
||||
fullImage.onload = () => {
|
||||
lqipImage.src = fullImageURL;
|
||||
lqipImage.style.filter = 'blur(0)';
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="code-block">
|
||||
<code>
|
||||
<img
|
||||
src="https://imagedelivery.net/HASH/ID/w=50,q=10,blur=20,f=webp"
|
||||
data-src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
|
||||
alt="Image with LQIP"
|
||||
/>
|
||||
|
||||
<script>
|
||||
const img = document.querySelector('img');
|
||||
const fullImg = new Image();
|
||||
fullImg.src = img.getAttribute('data-src');
|
||||
fullImg.onload = () => { img.src = fullImg.src; };
|
||||
</script>
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Example 6: URL transformations -->
|
||||
<section>
|
||||
<h2>6. Using URL Transformations (/cdn-cgi/image/)</h2>
|
||||
<p>Transform ANY publicly accessible image (not just Cloudflare Images storage).</p>
|
||||
|
||||
<img
|
||||
srcset="
|
||||
/cdn-cgi/image/width=480,quality=85,format=auto/uploads/photo.jpg 480w,
|
||||
/cdn-cgi/image/width=768,quality=85,format=auto/uploads/photo.jpg 768w,
|
||||
/cdn-cgi/image/width=1920,quality=85,format=auto/uploads/photo.jpg 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="/cdn-cgi/image/width=1920,quality=85,format=auto/uploads/photo.jpg"
|
||||
alt="Transformed image from origin"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div class="code-block">
|
||||
<code>
|
||||
<img
|
||||
srcset="
|
||||
/cdn-cgi/image/width=480,format=auto/uploads/photo.jpg 480w,
|
||||
/cdn-cgi/image/width=768,format=auto/uploads/photo.jpg 768w,
|
||||
/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg 1920w
|
||||
"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
/>
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tips -->
|
||||
<section>
|
||||
<h2>Best Practices</h2>
|
||||
<ul style="color: #666; line-height: 1.8; padding-left: 1.5rem;">
|
||||
<li>Always use <code>format=auto</code> for optimal WebP/AVIF delivery</li>
|
||||
<li>Add <code>loading="lazy"</code> for images below the fold</li>
|
||||
<li>Use <code>sizes</code> attribute to match your CSS layout</li>
|
||||
<li>Provide descriptive <code>alt</code> text for accessibility</li>
|
||||
<li>Consider art direction for different screen sizes (portrait vs landscape)</li>
|
||||
<li>Use LQIP (Low-Quality Image Placeholder) for better perceived performance</li>
|
||||
<li>Named variants: Best for consistent sizes and signed URLs</li>
|
||||
<li>Flexible variants: Best for dynamic sizing (public images only)</li>
|
||||
</ul>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
255
templates/signed-urls-generation.ts
Normal file
255
templates/signed-urls-generation.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Cloudflare Images - Signed URLs Generation
|
||||
*
|
||||
* Generate time-limited, signed URLs for private images using HMAC-SHA256.
|
||||
*
|
||||
* URL format:
|
||||
* https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_HASH: string;
|
||||
IMAGES_SIGNING_KEY: string; // From Dashboard → Images → Keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signed URL for private image
|
||||
*/
|
||||
export async function generateSignedURL(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expirySeconds: number = 3600, // Default: 1 hour
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
// Calculate expiry timestamp
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiry = now + expirySeconds;
|
||||
|
||||
// String to sign: {imageId}{variant}{expiry}
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
// Generate HMAC-SHA256 signature
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
|
||||
const messageData = encoder.encode(stringToSign);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
|
||||
// Convert to hex string
|
||||
const sig = Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
// Build signed URL
|
||||
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signed URL with absolute expiry time
|
||||
*/
|
||||
export async function generateSignedURLWithExpiry(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expiryDate: Date,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
const expiry = Math.floor(expiryDate.getTime() / 1000);
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
|
||||
const messageData = encoder.encode(stringToSign);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
const sig = Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signed URLs for multiple variants
|
||||
*/
|
||||
export async function generateSignedURLsForVariants(
|
||||
imageId: string,
|
||||
variants: string[],
|
||||
expirySeconds: number,
|
||||
env: Env
|
||||
): Promise<Record<string, string>> {
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
for (const variant of variants) {
|
||||
urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example Cloudflare Worker
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Generate signed URL: GET /api/signed-url/:imageId/:variant
|
||||
if (request.method === 'GET' && url.pathname.startsWith('/api/signed-url/')) {
|
||||
const parts = url.pathname.replace('/api/signed-url/', '').split('/');
|
||||
const [imageId, variant] = parts;
|
||||
|
||||
if (!imageId || !variant) {
|
||||
return Response.json({ error: 'Missing imageId or variant' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse expiry (default 1 hour)
|
||||
const expirySeconds = parseInt(url.searchParams.get('expiry') || '3600');
|
||||
|
||||
try {
|
||||
const signedURL = await generateSignedURL(imageId, variant, expirySeconds, env);
|
||||
|
||||
return Response.json({
|
||||
signedURL,
|
||||
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to generate signed URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Common expiry presets
|
||||
*/
|
||||
export const expiryPresets = {
|
||||
fiveMinutes: 5 * 60,
|
||||
fifteenMinutes: 15 * 60,
|
||||
oneHour: 60 * 60,
|
||||
oneDay: 24 * 60 * 60,
|
||||
oneWeek: 7 * 24 * 60 * 60
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate signed URL with preset expiry
|
||||
*/
|
||||
export async function generateSignedURLPreset(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
preset: keyof typeof expiryPresets,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
return generateSignedURL(imageId, variant, expiryPresets[preset], env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if URL signature is valid (for reference, Cloudflare handles verification)
|
||||
*/
|
||||
export async function verifySignature(
|
||||
imageId: string,
|
||||
variant: string,
|
||||
expiry: number,
|
||||
signature: string,
|
||||
env: Env
|
||||
): Promise<boolean> {
|
||||
// Check if expired
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (expiry < now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate expected signature
|
||||
const stringToSign = `${imageId}${variant}${expiry}`;
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(env.IMAGES_SIGNING_KEY);
|
||||
const messageData = encoder.encode(stringToSign);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const expectedSignature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||
const expectedSig = Array.from(new Uint8Array(expectedSignature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return expectedSig === signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage examples:
|
||||
*
|
||||
* ```typescript
|
||||
* // Generate signed URL valid for 1 hour
|
||||
* const signedURL = await generateSignedURL(
|
||||
* 'image-id',
|
||||
* 'public',
|
||||
* 3600,
|
||||
* env
|
||||
* );
|
||||
* // https://imagedelivery.net/{hash}/{id}/public?exp=1234567890&sig=abc123...
|
||||
*
|
||||
* // Generate with specific expiry date
|
||||
* const expiryDate = new Date('2025-10-27T18:00:00Z');
|
||||
* const signedURL = await generateSignedURLWithExpiry(
|
||||
* 'image-id',
|
||||
* 'public',
|
||||
* expiryDate,
|
||||
* env
|
||||
* );
|
||||
*
|
||||
* // Generate for multiple variants
|
||||
* const urls = await generateSignedURLsForVariants(
|
||||
* 'image-id',
|
||||
* ['thumbnail', 'medium', 'large'],
|
||||
* 3600,
|
||||
* env
|
||||
* );
|
||||
* // { thumbnail: 'https://...', medium: 'https://...', large: 'https://...' }
|
||||
*
|
||||
* // Use preset expiry
|
||||
* const signedURL = await generateSignedURLPreset(
|
||||
* 'image-id',
|
||||
* 'public',
|
||||
* 'oneDay',
|
||||
* env
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* REQUIREMENTS:
|
||||
* - Image must be uploaded with requireSignedURLs=true
|
||||
* - Get signing key from Dashboard → Images → Keys
|
||||
* - CANNOT use flexible variants with signed URLs (use named variants only)
|
||||
*
|
||||
* WHEN TO USE:
|
||||
* - User profile photos (private until shared)
|
||||
* - Paid content (time-limited access)
|
||||
* - Temporary downloads
|
||||
* - Secure image delivery
|
||||
*/
|
||||
225
templates/transform-via-url.ts
Normal file
225
templates/transform-via-url.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Cloudflare Images - Transform via URL
|
||||
*
|
||||
* Transform images using the special URL format:
|
||||
* /cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>
|
||||
*
|
||||
* Works on ANY publicly accessible image (not just Cloudflare Images storage).
|
||||
*/
|
||||
|
||||
/**
|
||||
* URL Transformation Examples
|
||||
*/
|
||||
|
||||
// Basic resize
|
||||
const thumbnailURL = '/cdn-cgi/image/width=300,height=300,fit=cover/uploads/photo.jpg';
|
||||
|
||||
// Responsive with auto format (WebP/AVIF)
|
||||
const responsiveURL = '/cdn-cgi/image/width=800,quality=85,format=auto/uploads/hero.jpg';
|
||||
|
||||
// Smart crop to face
|
||||
const avatarURL = '/cdn-cgi/image/width=200,height=200,gravity=face,fit=cover/uploads/profile.jpg';
|
||||
|
||||
// Blur effect
|
||||
const blurredURL = '/cdn-cgi/image/blur=20,quality=50/uploads/background.jpg';
|
||||
|
||||
// Sharpen
|
||||
const sharpenedURL = '/cdn-cgi/image/sharpen=3,quality=90/uploads/product.jpg';
|
||||
|
||||
// Rotate and flip
|
||||
const rotatedURL = '/cdn-cgi/image/rotate=90,flip=h/uploads/document.jpg';
|
||||
|
||||
/**
|
||||
* All available options (comma-separated)
|
||||
*/
|
||||
interface TransformOptions {
|
||||
// Sizing
|
||||
width?: number; // Max width in pixels (alias: w)
|
||||
height?: number; // Max height in pixels (alias: h)
|
||||
dpr?: number; // Device pixel ratio (1-3)
|
||||
|
||||
// Fit modes
|
||||
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||
|
||||
// Quality
|
||||
quality?: number; // 1-100 (alias: q)
|
||||
compression?: 'fast' | 'lossless'; // WebP only
|
||||
|
||||
// Format
|
||||
format?: 'auto' | 'avif' | 'webp' | 'jpeg' | 'baseline-jpeg' | 'json';
|
||||
// 'auto' serves AVIF → WebP → original based on browser support
|
||||
|
||||
// Cropping
|
||||
gravity?: 'auto' | 'face' | 'left' | 'right' | 'top' | 'bottom' | string; // Or 'XxY' coordinates
|
||||
zoom?: number; // 0-1 for face cropping
|
||||
trim?: number; // Remove border (pixels)
|
||||
|
||||
// Effects
|
||||
blur?: number; // 1-250
|
||||
sharpen?: number; // 0-10
|
||||
brightness?: number; // 0-2 (1 = no change)
|
||||
contrast?: number; // 0-2 (1 = no change)
|
||||
gamma?: number; // 0-2 (1 = no change)
|
||||
|
||||
// Rotation
|
||||
rotate?: 0 | 90 | 180 | 270;
|
||||
flip?: 'h' | 'v' | 'hv'; // Horizontal, vertical, both
|
||||
|
||||
// Other
|
||||
background?: string; // CSS color for transparency/padding
|
||||
metadata?: 'none' | 'copyright' | 'keep'; // EXIF handling
|
||||
anim?: boolean; // Preserve GIF/WebP animation (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build transformation URL
|
||||
*/
|
||||
export function buildTransformURL(
|
||||
imagePath: string,
|
||||
options: Partial<TransformOptions>
|
||||
): string {
|
||||
const params: string[] = [];
|
||||
|
||||
// Sizing
|
||||
if (options.width) params.push(`width=${options.width}`);
|
||||
if (options.height) params.push(`height=${options.height}`);
|
||||
if (options.dpr) params.push(`dpr=${options.dpr}`);
|
||||
|
||||
// Fit
|
||||
if (options.fit) params.push(`fit=${options.fit}`);
|
||||
|
||||
// Quality
|
||||
if (options.quality) params.push(`quality=${options.quality}`);
|
||||
if (options.compression) params.push(`compression=${options.compression}`);
|
||||
|
||||
// Format
|
||||
if (options.format) params.push(`format=${options.format}`);
|
||||
|
||||
// Cropping
|
||||
if (options.gravity) params.push(`gravity=${options.gravity}`);
|
||||
if (options.zoom) params.push(`zoom=${options.zoom}`);
|
||||
if (options.trim) params.push(`trim=${options.trim}`);
|
||||
|
||||
// Effects
|
||||
if (options.blur) params.push(`blur=${options.blur}`);
|
||||
if (options.sharpen) params.push(`sharpen=${options.sharpen}`);
|
||||
if (options.brightness) params.push(`brightness=${options.brightness}`);
|
||||
if (options.contrast) params.push(`contrast=${options.contrast}`);
|
||||
if (options.gamma) params.push(`gamma=${options.gamma}`);
|
||||
|
||||
// Rotation
|
||||
if (options.rotate) params.push(`rotate=${options.rotate}`);
|
||||
if (options.flip) params.push(`flip=${options.flip}`);
|
||||
|
||||
// Other
|
||||
if (options.background) params.push(`background=${encodeURIComponent(options.background)}`);
|
||||
if (options.metadata) params.push(`metadata=${options.metadata}`);
|
||||
if (options.anim === false) params.push('anim=false');
|
||||
|
||||
return `/cdn-cgi/image/${params.join(',')}/${imagePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example HTML generation
|
||||
*/
|
||||
export function generateResponsiveHTML(imagePath: string, alt: string): string {
|
||||
return `
|
||||
<img
|
||||
srcset="${buildTransformURL(imagePath, { width: 480, format: 'auto' })} 480w,
|
||||
${buildTransformURL(imagePath, { width: 768, format: 'auto' })} 768w,
|
||||
${buildTransformURL(imagePath, { width: 1920, format: 'auto' })} 1920w"
|
||||
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
src="${buildTransformURL(imagePath, { width: 1920, format: 'auto' })}"
|
||||
alt="${alt}"
|
||||
/>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Common presets
|
||||
*/
|
||||
export const presets = {
|
||||
thumbnail: (path: string) => buildTransformURL(path, {
|
||||
width: 300,
|
||||
height: 300,
|
||||
fit: 'cover',
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}),
|
||||
|
||||
avatar: (path: string) => buildTransformURL(path, {
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: 'cover',
|
||||
gravity: 'face',
|
||||
quality: 90,
|
||||
format: 'auto'
|
||||
}),
|
||||
|
||||
hero: (path: string) => buildTransformURL(path, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fit: 'cover',
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}),
|
||||
|
||||
blurPlaceholder: (path: string) => buildTransformURL(path, {
|
||||
width: 50,
|
||||
quality: 10,
|
||||
blur: 20,
|
||||
format: 'webp'
|
||||
}),
|
||||
|
||||
productImage: (path: string) => buildTransformURL(path, {
|
||||
width: 800,
|
||||
height: 800,
|
||||
fit: 'contain',
|
||||
quality: 90,
|
||||
sharpen: 2,
|
||||
format: 'auto'
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* Usage examples:
|
||||
*
|
||||
* ```html
|
||||
* <!-- Thumbnail -->
|
||||
* <img src="/cdn-cgi/image/width=300,height=300,fit=cover,quality=85,format=auto/uploads/photo.jpg" />
|
||||
*
|
||||
* <!-- Smart crop to face -->
|
||||
* <img src="/cdn-cgi/image/width=200,height=200,gravity=face,fit=cover/uploads/profile.jpg" />
|
||||
*
|
||||
* <!-- Blur effect for privacy -->
|
||||
* <img src="/cdn-cgi/image/blur=20,quality=50/uploads/document.jpg" />
|
||||
*
|
||||
* <!-- Responsive with srcset -->
|
||||
* <img
|
||||
* srcset="/cdn-cgi/image/width=480,format=auto/uploads/hero.jpg 480w,
|
||||
* /cdn-cgi/image/width=768,format=auto/uploads/hero.jpg 768w,
|
||||
* /cdn-cgi/image/width=1920,format=auto/uploads/hero.jpg 1920w"
|
||||
* sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
|
||||
* src="/cdn-cgi/image/width=1920,format=auto/uploads/hero.jpg"
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* With helper functions:
|
||||
* ```typescript
|
||||
* const url = buildTransformURL('uploads/photo.jpg', {
|
||||
* width: 800,
|
||||
* quality: 85,
|
||||
* format: 'auto'
|
||||
* });
|
||||
*
|
||||
* const html = generateResponsiveHTML('uploads/hero.jpg', 'Hero image');
|
||||
*
|
||||
* const thumbURL = presets.thumbnail('uploads/photo.jpg');
|
||||
* ```
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Must enable transformations on zone first (Dashboard → Images → Transformations)
|
||||
* - Works on any publicly accessible image (not just Cloudflare Images storage)
|
||||
* - Source image must use HTTPS (HTTP not supported)
|
||||
* - URL-encode special characters in paths
|
||||
*/
|
||||
331
templates/transform-via-workers.ts
Normal file
331
templates/transform-via-workers.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Cloudflare Images - Transform via Workers
|
||||
*
|
||||
* Use Workers to apply transformations programmatically with fetch() cf.image options.
|
||||
*
|
||||
* Benefits:
|
||||
* - Custom URL schemes (hide storage location)
|
||||
* - Preset names instead of pixel values
|
||||
* - Content negotiation (serve optimal format)
|
||||
* - Access control before serving
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
// Optional: If storing originals in R2
|
||||
IMAGES_BUCKET?: R2Bucket;
|
||||
}
|
||||
|
||||
interface ImageTransformOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||
quality?: number; // 1-100
|
||||
format?: 'avif' | 'webp' | 'jpeg' | 'auto';
|
||||
gravity?: 'auto' | 'face' | 'left' | 'right' | 'top' | 'bottom' | string;
|
||||
blur?: number; // 1-250
|
||||
sharpen?: number; // 0-10
|
||||
rotate?: 0 | 90 | 180 | 270;
|
||||
flip?: 'h' | 'v' | 'hv';
|
||||
anim?: boolean;
|
||||
metadata?: 'none' | 'copyright' | 'keep';
|
||||
background?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 1: Custom URL schemes with preset names
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Custom URL: /images/thumbnail/photo.jpg
|
||||
if (url.pathname.startsWith('/images/thumbnail/')) {
|
||||
const imagePath = url.pathname.replace('/images/thumbnail/', '');
|
||||
const imageURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 300,
|
||||
height: 300,
|
||||
fit: 'cover',
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Custom URL: /images/avatar/photo.jpg
|
||||
if (url.pathname.startsWith('/images/avatar/')) {
|
||||
const imagePath = url.pathname.replace('/images/avatar/', '');
|
||||
const imageURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: 'cover',
|
||||
gravity: 'face', // Smart crop to face
|
||||
quality: 90,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Custom URL: /images/large/photo.jpg
|
||||
if (url.pathname.startsWith('/images/large/')) {
|
||||
const imagePath = url.pathname.replace('/images/large/', '');
|
||||
const imageURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 1920,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 2: Content negotiation (serve optimal format)
|
||||
*/
|
||||
function getOptimalFormat(request: Request): 'avif' | 'webp' | 'auto' {
|
||||
const accept = request.headers.get('accept') || '';
|
||||
|
||||
if (/image\/avif/.test(accept)) {
|
||||
return 'avif';
|
||||
} else if (/image\/webp/.test(accept)) {
|
||||
return 'webp';
|
||||
}
|
||||
|
||||
return 'auto'; // Cloudflare decides
|
||||
}
|
||||
|
||||
export const contentNegotiationWorker = {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const imagePath = url.pathname.replace('/images/', '');
|
||||
const imageURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: getOptimalFormat(request)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 3: Dynamic sizing based on query params
|
||||
*/
|
||||
export const dynamicSizeWorker = {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const imagePath = url.pathname.replace('/images/', '');
|
||||
const imageURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
// Parse query params: /images/photo.jpg?w=800&q=85
|
||||
const width = parseInt(url.searchParams.get('w') || '1920');
|
||||
const quality = parseInt(url.searchParams.get('q') || '85');
|
||||
|
||||
// Validate
|
||||
const safeWidth = Math.min(Math.max(width, 100), 4000); // 100-4000px
|
||||
const safeQuality = Math.min(Math.max(quality, 10), 100); // 10-100
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: safeWidth,
|
||||
quality: safeQuality,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 4: Access control before serving
|
||||
*/
|
||||
export const protectedImageWorker = {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Check authentication (example)
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Verify token (simplified example)
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
if (token !== 'valid-token') {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
// Serve image after auth check
|
||||
const imagePath = url.pathname.replace('/protected/', '');
|
||||
const imageURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 5: R2 integration
|
||||
*/
|
||||
export const r2ImageWorker = {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const key = url.pathname.replace('/images/', '');
|
||||
|
||||
// Get image from R2
|
||||
const object = await env.IMAGES_BUCKET?.get(key);
|
||||
if (!object) {
|
||||
return new Response('Image not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Transform and serve
|
||||
return fetch(new Request(url.toString(), {
|
||||
method: 'GET',
|
||||
body: object.body
|
||||
}), {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 6: Prevent transformation loops (error 9403)
|
||||
*/
|
||||
export const safeTransformWorker = {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// ✅ CORRECT: Fetch external origin
|
||||
if (url.pathname.startsWith('/images/')) {
|
||||
const imagePath = url.pathname.replace('/images/', '');
|
||||
const originURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
return fetch(originURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ WRONG: Don't fetch Worker's own URL (causes loop)
|
||||
// return fetch(request, { cf: { image: { width: 800 } } }); // ERROR 9403
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 7: Error handling
|
||||
*/
|
||||
export const robustImageWorker = {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const imagePath = url.pathname.replace('/images/', '');
|
||||
const imageURL = `https://storage.example.com/${imagePath}`;
|
||||
|
||||
try {
|
||||
// Verify origin returns image (prevent error 9412)
|
||||
const headResponse = await fetch(imageURL, { method: 'HEAD' });
|
||||
const contentType = headResponse.headers.get('content-type');
|
||||
|
||||
if (!contentType?.startsWith('image/')) {
|
||||
return new Response('Not an image', { status: 400 });
|
||||
}
|
||||
|
||||
// Transform
|
||||
const response = await fetch(imageURL, {
|
||||
cf: {
|
||||
image: {
|
||||
width: 800,
|
||||
quality: 85,
|
||||
format: 'auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check for transformation errors
|
||||
const cfResized = response.headers.get('Cf-Resized');
|
||||
if (cfResized?.includes('err=')) {
|
||||
console.error('Transformation error:', cfResized);
|
||||
return new Response('Image transformation failed', { status: 502 });
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Image fetch error:', error);
|
||||
return new Response('Failed to fetch image', { status: 502 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: Build transform options
|
||||
*/
|
||||
export function buildTransformOptions(
|
||||
preset: 'thumbnail' | 'avatar' | 'hero' | 'product',
|
||||
overrides?: Partial<ImageTransformOptions>
|
||||
): ImageTransformOptions {
|
||||
const presets = {
|
||||
thumbnail: { width: 300, height: 300, fit: 'cover' as const, quality: 85 },
|
||||
avatar: { width: 200, height: 200, fit: 'cover' as const, gravity: 'face', quality: 90 },
|
||||
hero: { width: 1920, height: 1080, fit: 'cover' as const, quality: 85 },
|
||||
product: { width: 800, height: 800, fit: 'contain' as const, quality: 90, sharpen: 2 }
|
||||
};
|
||||
|
||||
return {
|
||||
...presets[preset],
|
||||
format: 'auto',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CRITICAL ERROR CODES:
|
||||
*
|
||||
* - 9401: Invalid cf.image options
|
||||
* - 9402: Image too large or connection interrupted
|
||||
* - 9403: Request loop (Worker fetching itself)
|
||||
* - 9406/9419: Non-HTTPS URL or URL has spaces/unescaped Unicode
|
||||
* - 9412: Origin returned non-image (e.g., HTML error page)
|
||||
* - 9413: Image exceeds 100 megapixels
|
||||
*
|
||||
* Check 'Cf-Resized' header for error codes.
|
||||
*/
|
||||
150
templates/upload-api-basic.ts
Normal file
150
templates/upload-api-basic.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Cloudflare Images - Basic Upload via API
|
||||
*
|
||||
* Uploads an image file to Cloudflare Images storage.
|
||||
*
|
||||
* Usage:
|
||||
* const result = await uploadImageToCloudflare(file, {
|
||||
* requireSignedURLs: false,
|
||||
* metadata: { userId: '12345' }
|
||||
* });
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_ID: string;
|
||||
IMAGES_API_TOKEN: string;
|
||||
}
|
||||
|
||||
interface UploadOptions {
|
||||
id?: string; // Custom ID (optional, auto-generated if not provided)
|
||||
requireSignedURLs?: boolean; // true for private images
|
||||
metadata?: Record<string, string>; // Max 1024 bytes, not visible to end users
|
||||
}
|
||||
|
||||
interface CloudflareImagesResponse {
|
||||
success: boolean;
|
||||
result?: {
|
||||
id: string;
|
||||
filename: string;
|
||||
uploaded: string;
|
||||
requireSignedURLs: boolean;
|
||||
variants: string[];
|
||||
};
|
||||
errors?: Array<{ code: number; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload image to Cloudflare Images
|
||||
*/
|
||||
export async function uploadImageToCloudflare(
|
||||
file: File,
|
||||
options: UploadOptions = {},
|
||||
env: Env
|
||||
): Promise<CloudflareImagesResponse> {
|
||||
const formData = new FormData();
|
||||
|
||||
// Required: File to upload
|
||||
formData.append('file', file);
|
||||
|
||||
// Optional: Custom ID (if not provided, auto-generated)
|
||||
if (options.id) {
|
||||
formData.append('id', options.id);
|
||||
}
|
||||
|
||||
// Optional: Require signed URLs for private images
|
||||
if (options.requireSignedURLs !== undefined) {
|
||||
formData.append('requireSignedURLs', String(options.requireSignedURLs));
|
||||
}
|
||||
|
||||
// Optional: Metadata (JSON object, max 1024 bytes)
|
||||
if (options.metadata) {
|
||||
formData.append('metadata', JSON.stringify(options.metadata));
|
||||
}
|
||||
|
||||
// Upload to Cloudflare Images API
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
|
||||
// Don't set Content-Type - FormData sets it automatically with boundary
|
||||
},
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
const result: CloudflareImagesResponse = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Upload failed:', result.errors);
|
||||
throw new Error(`Upload failed: ${result.errors?.[0]?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example Cloudflare Worker endpoint
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method === 'POST' && new URL(request.url).pathname === '/upload') {
|
||||
try {
|
||||
// Parse multipart/form-data from request
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File;
|
||||
|
||||
if (!file) {
|
||||
return Response.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upload to Cloudflare Images
|
||||
const result = await uploadImageToCloudflare(
|
||||
file,
|
||||
{
|
||||
requireSignedURLs: false,
|
||||
metadata: {
|
||||
uploadedBy: 'worker',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
env
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
imageId: result.result?.id,
|
||||
variants: result.result?.variants
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Example usage from another script:
|
||||
*
|
||||
* ```typescript
|
||||
* const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
* const file = fileInput.files?.[0];
|
||||
*
|
||||
* if (file) {
|
||||
* const result = await uploadImageToCloudflare(file, {
|
||||
* requireSignedURLs: false,
|
||||
* metadata: { source: 'user-upload' }
|
||||
* }, env);
|
||||
*
|
||||
* console.log('Uploaded:', result.result?.id);
|
||||
* console.log('Serve at:', result.result?.variants[0]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
207
templates/upload-via-url.ts
Normal file
207
templates/upload-via-url.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Cloudflare Images - Upload via URL
|
||||
*
|
||||
* Ingest images from external URLs without downloading first.
|
||||
*
|
||||
* Use cases:
|
||||
* - Migrating images from another service
|
||||
* - Ingesting user-provided URLs
|
||||
* - Backing up images from external sources
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_ID: string;
|
||||
IMAGES_API_TOKEN: string;
|
||||
}
|
||||
|
||||
interface UploadViaURLOptions {
|
||||
url: string; // Image URL to ingest
|
||||
id?: string; // Custom ID (optional)
|
||||
requireSignedURLs?: boolean;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CloudflareImagesResponse {
|
||||
success: boolean;
|
||||
result?: {
|
||||
id: string;
|
||||
filename: string;
|
||||
uploaded: string;
|
||||
requireSignedURLs: boolean;
|
||||
variants: string[];
|
||||
};
|
||||
errors?: Array<{ code: number; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload image from external URL
|
||||
*/
|
||||
export async function uploadImageViaURL(
|
||||
options: UploadViaURLOptions,
|
||||
env: Env
|
||||
): Promise<CloudflareImagesResponse> {
|
||||
const formData = new FormData();
|
||||
|
||||
// Required: URL to ingest
|
||||
formData.append('url', options.url);
|
||||
|
||||
// Optional: Custom ID
|
||||
if (options.id) {
|
||||
formData.append('id', options.id);
|
||||
}
|
||||
|
||||
// Optional: Require signed URLs
|
||||
if (options.requireSignedURLs !== undefined) {
|
||||
formData.append('requireSignedURLs', String(options.requireSignedURLs));
|
||||
}
|
||||
|
||||
// Optional: Metadata
|
||||
if (options.metadata) {
|
||||
formData.append('metadata', JSON.stringify(options.metadata));
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
|
||||
},
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
const result: CloudflareImagesResponse = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Upload via URL failed:', result.errors);
|
||||
throw new Error(`Upload via URL failed: ${result.errors?.[0]?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example Cloudflare Worker
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Endpoint: POST /ingest-image
|
||||
if (request.method === 'POST' && url.pathname === '/ingest-image') {
|
||||
try {
|
||||
const body = await request.json<{ imageUrl: string }>();
|
||||
|
||||
if (!body.imageUrl) {
|
||||
return Response.json({ error: 'imageUrl required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(body.imageUrl);
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upload from external URL
|
||||
const result = await uploadImageViaURL(
|
||||
{
|
||||
url: body.imageUrl,
|
||||
metadata: {
|
||||
source: 'external',
|
||||
ingestedAt: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
env
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
imageId: result.result?.id,
|
||||
variants: result.result?.variants
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Ingestion failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch ingestion example
|
||||
*/
|
||||
export async function batchIngestImages(
|
||||
imageUrls: string[],
|
||||
env: Env
|
||||
): Promise<Array<{ url: string; result?: CloudflareImagesResponse; error?: string }>> {
|
||||
const results = await Promise.allSettled(
|
||||
imageUrls.map(async (url) => {
|
||||
return {
|
||||
url,
|
||||
result: await uploadImageViaURL({ url }, env)
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
} else {
|
||||
return {
|
||||
url: imageUrls[index],
|
||||
error: result.reason instanceof Error ? result.reason.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Example with authentication for private origins
|
||||
*/
|
||||
export async function uploadFromPrivateURL(
|
||||
imageUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
env: Env
|
||||
): Promise<CloudflareImagesResponse> {
|
||||
// Cloudflare supports HTTP Basic Auth in URL
|
||||
const urlObj = new URL(imageUrl);
|
||||
const authenticatedURL = `${urlObj.protocol}//${username}:${password}@${urlObj.host}${urlObj.pathname}${urlObj.search}`;
|
||||
|
||||
return uploadImageViaURL({ url: authenticatedURL }, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage examples:
|
||||
*
|
||||
* ```typescript
|
||||
* // Single image
|
||||
* const result = await uploadImageViaURL({
|
||||
* url: 'https://example.com/photo.jpg',
|
||||
* metadata: { source: 'migration' }
|
||||
* }, env);
|
||||
*
|
||||
* // Batch ingestion
|
||||
* const urls = [
|
||||
* 'https://example.com/photo1.jpg',
|
||||
* 'https://example.com/photo2.jpg',
|
||||
* 'https://example.com/photo3.jpg'
|
||||
* ];
|
||||
* const results = await batchIngestImages(urls, env);
|
||||
*
|
||||
* // Private origin with auth
|
||||
* const result = await uploadFromPrivateURL(
|
||||
* 'https://private-storage.example.com/image.jpg',
|
||||
* 'username',
|
||||
* 'password',
|
||||
* env
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
312
templates/variants-management.ts
Normal file
312
templates/variants-management.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Cloudflare Images - Variants Management
|
||||
*
|
||||
* Create, list, update, and delete image variants.
|
||||
* Variants define predefined transformations for different use cases.
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
IMAGES_ACCOUNT_ID: string;
|
||||
IMAGES_API_TOKEN: string;
|
||||
}
|
||||
|
||||
interface VariantOptions {
|
||||
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||
width?: number;
|
||||
height?: number;
|
||||
metadata?: 'none' | 'copyright' | 'keep';
|
||||
}
|
||||
|
||||
interface Variant {
|
||||
id: string;
|
||||
options: VariantOptions;
|
||||
neverRequireSignedURLs?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new variant
|
||||
*/
|
||||
export async function createVariant(
|
||||
id: string,
|
||||
options: VariantOptions,
|
||||
neverRequireSignedURLs: boolean = false,
|
||||
env: Env
|
||||
): Promise<{ success: boolean; result?: Variant }> {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
options,
|
||||
neverRequireSignedURLs
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all variants
|
||||
*/
|
||||
export async function listVariants(
|
||||
env: Env
|
||||
): Promise<{ success: boolean; result?: { variants: Variant[] } }> {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific variant
|
||||
*/
|
||||
export async function getVariant(
|
||||
id: string,
|
||||
env: Env
|
||||
): Promise<{ success: boolean; result?: Variant }> {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a variant
|
||||
*/
|
||||
export async function updateVariant(
|
||||
id: string,
|
||||
options: VariantOptions,
|
||||
neverRequireSignedURLs?: boolean,
|
||||
env: Env
|
||||
): Promise<{ success: boolean; result?: Variant }> {
|
||||
const body: Record<string, unknown> = { options };
|
||||
if (neverRequireSignedURLs !== undefined) {
|
||||
body.neverRequireSignedURLs = neverRequireSignedURLs;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a variant
|
||||
*/
|
||||
export async function deleteVariant(
|
||||
id: string,
|
||||
env: Env
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable flexible variants (dynamic transformations)
|
||||
*/
|
||||
export async function enableFlexibleVariants(
|
||||
enabled: boolean,
|
||||
env: Env
|
||||
): Promise<{ success: boolean }> {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/config`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
flexible_variants: enabled
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Example Worker endpoint
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Create variant: POST /api/variants
|
||||
if (request.method === 'POST' && url.pathname === '/api/variants') {
|
||||
try {
|
||||
const body = await request.json<{
|
||||
id: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fit?: string;
|
||||
}>();
|
||||
|
||||
const result = await createVariant(
|
||||
body.id,
|
||||
{
|
||||
width: body.width,
|
||||
height: body.height,
|
||||
fit: body.fit as VariantOptions['fit'],
|
||||
metadata: 'none'
|
||||
},
|
||||
false,
|
||||
env
|
||||
);
|
||||
|
||||
return Response.json(result);
|
||||
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create variant' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// List variants: GET /api/variants
|
||||
if (request.method === 'GET' && url.pathname === '/api/variants') {
|
||||
const result = await listVariants(env);
|
||||
return Response.json(result);
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Common variant presets
|
||||
*/
|
||||
export async function setupCommonVariants(env: Env): Promise<void> {
|
||||
// Thumbnail
|
||||
await createVariant('thumbnail', {
|
||||
width: 300,
|
||||
height: 300,
|
||||
fit: 'cover',
|
||||
metadata: 'none'
|
||||
}, false, env);
|
||||
|
||||
// Avatar
|
||||
await createVariant('avatar', {
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: 'cover',
|
||||
metadata: 'none'
|
||||
}, false, env);
|
||||
|
||||
// Small
|
||||
await createVariant('small', {
|
||||
width: 480,
|
||||
fit: 'scale-down',
|
||||
metadata: 'none'
|
||||
}, false, env);
|
||||
|
||||
// Medium
|
||||
await createVariant('medium', {
|
||||
width: 768,
|
||||
fit: 'scale-down',
|
||||
metadata: 'none'
|
||||
}, false, env);
|
||||
|
||||
// Large
|
||||
await createVariant('large', {
|
||||
width: 1920,
|
||||
fit: 'scale-down',
|
||||
metadata: 'none'
|
||||
}, false, env);
|
||||
|
||||
// Hero (wide)
|
||||
await createVariant('hero', {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fit: 'cover',
|
||||
metadata: 'none'
|
||||
}, false, env);
|
||||
|
||||
// Product (square)
|
||||
await createVariant('product', {
|
||||
width: 800,
|
||||
height: 800,
|
||||
fit: 'contain',
|
||||
metadata: 'none'
|
||||
}, false, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage examples:
|
||||
*
|
||||
* ```typescript
|
||||
* // Create a variant
|
||||
* await createVariant('thumbnail', {
|
||||
* width: 300,
|
||||
* height: 300,
|
||||
* fit: 'cover',
|
||||
* metadata: 'none'
|
||||
* }, false, env);
|
||||
*
|
||||
* // List all variants
|
||||
* const { result } = await listVariants(env);
|
||||
* console.log(result?.variants);
|
||||
*
|
||||
* // Update a variant
|
||||
* await updateVariant('thumbnail', {
|
||||
* width: 350, // Changed from 300
|
||||
* height: 350,
|
||||
* fit: 'cover'
|
||||
* }, undefined, env);
|
||||
*
|
||||
* // Delete a variant
|
||||
* await deleteVariant('old-variant', env);
|
||||
*
|
||||
* // Enable flexible variants (dynamic transformations)
|
||||
* await enableFlexibleVariants(true, env);
|
||||
* // Now can use: /w=400,sharpen=3 in URLs
|
||||
*
|
||||
* // Use variant in image URL
|
||||
* const imageURL = `https://imagedelivery.net/${accountHash}/${imageId}/thumbnail`;
|
||||
* ```
|
||||
*
|
||||
* LIMITS:
|
||||
* - Maximum 100 named variants per account
|
||||
* - Flexible variants: unlimited dynamic transformations (but can't use with signed URLs)
|
||||
*
|
||||
* WHEN TO USE:
|
||||
* - Named variants: Consistent sizes, private images (signed URLs), predictable URLs
|
||||
* - Flexible variants: Dynamic sizing, public images only, rapid prototyping
|
||||
*/
|
||||
41
templates/wrangler-images-binding.jsonc
Normal file
41
templates/wrangler-images-binding.jsonc
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "my-worker",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-10-26",
|
||||
|
||||
// Cloudflare Images doesn't require explicit bindings in wrangler.jsonc
|
||||
// Images API is accessed via fetch to api.cloudflare.com
|
||||
// Image Transformations work automatically when enabled on your zone
|
||||
|
||||
// Example: If storing originals in R2 and using Images for transformations
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "IMAGES_BUCKET",
|
||||
"bucket_name": "original-images",
|
||||
"preview_bucket_name": "original-images-preview"
|
||||
}
|
||||
],
|
||||
|
||||
// Environment variables for Images API
|
||||
"vars": {
|
||||
"IMAGES_ACCOUNT_ID": "your-account-id",
|
||||
"IMAGES_ACCOUNT_HASH": "your-account-hash" // From Dashboard → Images → Developer Resources
|
||||
},
|
||||
|
||||
// Secrets (set via: wrangler secret put IMAGES_API_TOKEN)
|
||||
// IMAGES_API_TOKEN - API token with Cloudflare Images: Edit permission
|
||||
// IMAGES_SIGNING_KEY - Key for signed URLs (optional, from Dashboard → Images → Keys)
|
||||
|
||||
// No explicit binding needed for:
|
||||
// - Image Transformations (/cdn-cgi/image/...)
|
||||
// - Workers fetch with cf.image options
|
||||
// - Direct Creator Upload API
|
||||
|
||||
// Example worker routes
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "example.com/*",
|
||||
"zone_name": "example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user