Initial commit
This commit is contained in:
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