Skip to content
Merged
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Skills are grouped by category below. The category is a folder in this repo only
|---|---|
| `cloudinary-docs` | Selects the most relevant markdown pages from the current documentation using the latest llms.txt. Use when answering Cloudinary questions or integrating Cloudinary into code. |
| `cloudinary-transformations` | Turns natural language image and video transformation requirements into valid URL transformation strings that follow Cloudinary best practices. Use when building delivery URLs, applying transformations, optimizing media, or debugging transformation syntax errors. |
| `cloudinary-upload` | Reference for uploading assets to Cloudinary, covering signed and unsigned uploads, upload presets, transformations on upload, naming and folders, large files, remote uploads, and signature generation. Use when uploading files or URLs to Cloudinary, configuring upload presets, generating upload signatures, choosing between the SDK, REST API, or Upload widget, or debugging upload failures. For Next.js or React projects, use cloudinary-next or cloudinary-react alongside this skill. |

### Frameworks

Expand All @@ -26,6 +27,7 @@ Install the skills you need by name:

```bash
npx skills add cloudinary-devs/skills --skill cloudinary-docs,cloudinary-transformations
npx skills add cloudinary-devs/skills --skill cloudinary-upload
npx skills add cloudinary-devs/skills --skill cloudinary-next
```

Expand Down
515 changes: 515 additions & 0 deletions skills/platform/cloudinary-upload/SKILL.md

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions skills/platform/cloudinary-upload/references/chunked-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Chunked Uploads — Large File Reference

Reference for uploading files larger than 100 MB using the chunked upload API.

## When to Use

- Files > 100 MB
- Unreliable network connections where resumable uploads help
- Progress tracking on large uploads

## Required Headers

Every chunk request must include:

| Header | Value | Notes |
|---|---|---|
| `Content-Range` | `bytes <start>-<end>/<total>` | See arithmetic below |
| `X-Unique-Upload-Id` | Any unique string | Same value for all chunks of one file |
| `Content-Type` | `multipart/form-data` | Standard upload content type |

## Content-Range Arithmetic

**Format:** `bytes <start>-<end>/<total>`

- `start`: byte offset of first byte in this chunk (0-indexed)
- `end`: byte offset of **last byte** in this chunk (inclusive — this is where off-by-one errors happen)
- `total`: total file size in bytes

**Example — 22,744,222 byte file split into 6 MB chunks:**

| Chunk | Start | End | Total | Header |
|---|---|---|---|---|
| 1 | 0 | 5,999,999 | 22,744,222 | `bytes 0-5999999/22744222` |
| 2 | 6,000,000 | 11,999,999 | 22,744,222 | `bytes 6000000-11999999/22744222` |
| 3 | 12,000,000 | 17,999,999 | 22,744,222 | `bytes 12000000-17999999/22744222` |
| 4 | 18,000,000 | 22,744,221 | 22,744,222 | `bytes 18000000-22744221/22744222` |

**Key:** end = start + chunk_size - 1 (not start + chunk_size)

## Chunk Size Rules

- **Minimum chunk size:** 5 MB (5,242,880 bytes) — enforced for all chunks except the last
- **Maximum chunk size:** No hard limit, but 20 MB is a practical ceiling for reliability
- **Last chunk:** Can be any size (even 1 byte)

## X-Unique-Upload-Id

- Generate once per file upload (UUID or similar)
- Use the **same value** for every chunk of that file
- Cloudinary uses this to reassemble chunks on its end
- Generating a new ID mid-upload = Cloudinary treats it as a new upload

## Response Handling

| Chunk | Response | Meaning |
|---|---|---|
| Intermediate | `{"done": false}` | Chunk received, waiting for more |
| Final | Full upload result object | Upload complete |

Do not treat `{"done": false}` as an error. Track chunk index and continue sending.

## Chunking Algorithm

```
chunk_size = 6 * 1024 * 1024 # 6 MB
upload_id = generate_uuid()
total_size = file.size
offset = 0

while offset < total_size:
chunk_end = min(offset + chunk_size, total_size) - 1
chunk_data = file.read(offset, chunk_end + 1)

headers = {
"Content-Range": f"bytes {offset}-{chunk_end}/{total_size}",
"X-Unique-Upload-Id": upload_id
}

response = post("/upload", data=chunk_data, headers=headers)

if response["done"]:
return response # final result
else:
offset = chunk_end + 1
```

## Common Mistakes

| Mistake | Symptom | Fix |
|---|---|---|
| Off-by-one in `end` (using `start + size` instead of `start + size - 1`) | `400 Invalid Content-Range` | end = start + chunk_size - 1 |
| Different `X-Unique-Upload-Id` per chunk | Upload never completes; new upload started | Generate once, reuse for all chunks |
| Chunk size < 5 MB (not final chunk) | `400 Chunk too small` | Enforce 5 MB minimum for all but last chunk |
| Not handling `done: false` | Treating mid-upload as error | Only treat HTTP errors as failures |
| Wrong `total` in Content-Range | `400 Invalid Content-Range` | Use actual file byte size, not estimated |

Official documentation: [Manual Chunked Upload](https://cloudinary.com/documentation/upload_images.md?install_source=skillspack&referrer=upload-skill#manual_chunked_upload_rest)
83 changes: 83 additions & 0 deletions skills/platform/cloudinary-upload/references/signed-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Signed Uploads — Signature Generation

Complete reference for generating Cloudinary upload signatures.

## Minimal Recommended Request

When the upload parameters live in an upload preset, the request carries only `upload_preset`, `timestamp`, `api_key`, `file`, and `signature`. Only two of those are signed, and they are already in alphabetical order:

```
timestamp=1718100000&upload_preset=product-imagesMY_API_SECRET
```

Hash that string and send the hex digest as `signature`. No further sorting or joining rules come into play. Use the full algorithm below only when the request carries additional parameters.

## The Algorithm

1. **Collect parameters**: every non-empty upload request parameter except:
- `file`
- `cloud_name`
- `resource_type`
- `api_key`
- `signature` itself

2. **Sort alphabetically** by parameter key (a–z)

3. **Build the parameter string**: join as `key=value` pairs with `&`, signing each value exactly as it is sent:
```
folder=uploads&public_id=my_asset&tags=product,sale&timestamp=1718100000
```

4. **Append API secret**: append directly, no `&`, no `=`, no separator:
```
folder=uploads&public_id=my_asset&tags=product,sale&timestamp=1718100000MY_API_SECRET
```

5. **Hash it** with SHA-1 or SHA-256 (SHA-256 recommended for new implementations):
```
signature = sha256(parameter_string + api_secret)
```

## Timestamp Rules

- Must be **Unix time in seconds** (not milliseconds)
- JavaScript pitfall: `Date.now()` → milliseconds → divide by 1000:
```javascript
const timestamp = Math.floor(Date.now() / 1000);
```
- Python: `int(time.time())` returns seconds natively
- Signature is valid for **1 hour** from the timestamp value
- Expired signatures return: `{"error": {"message": "Signature has expired"}}`

## Value Encoding

Sign the value exactly as it appears in the request:

- **Arrays are comma-separated.** `tags: ["product", "sale"]` is signed as `tags=product,sale`.
- **Multiple eager transformations are pipe-separated**, because that is how the `eager` value itself is sent: `eager=c_fill,w_400|c_scale,w_200`.
- **`&` inside a value is encoded as `%26`** before hashing, so it cannot be confused with the parameter separator. This matters for values such as a `notification_url` with a query string: `notification_url=https://example.com/hook?a=1%26b=2`.
- **Empty parameters are left out** of both the request and the signature.

## Common Mistakes

| Mistake | Symptom | Fix |
|---|---|---|
| Timestamp in milliseconds | `Signature has expired` immediately | Divide by 1000 in JS |
| Including `file` in signature | `Invalid signature` | Exclude `file` from signature params |
| Including `api_key` in signature | `Invalid signature` | Exclude `api_key` from signature params |
| Wrong sort order | `Invalid signature` | Sort keys a–z strictly |
| Missing parameter in signature | `Invalid signature` | Include every non-empty request param except the excluded ones |
| Tags joined with `\|` | `Invalid signature` | Sign arrays comma-separated, exactly as sent |
| Unencoded `&` inside a value | `Invalid signature` | Encode `&` in values as `%26` before hashing |
| Parameter in request but not signature | `Invalid signature` | Any param sent in the request must be in the signature |
| Wrong API secret | `Invalid signature` | Verify you're using the correct secret for the cloud |

## Verification

To debug a signature mismatch:
1. Log the exact parameter string before hashing (temporarily)
2. Verify each parameter appears in both the string and the request
3. Verify sort order: compare your sorted keys to alphabetical
4. Verify timestamp value is ≤ 3600 seconds in the past

Official documentation: [Authentication Signatures](https://cloudinary.com/documentation/authentication_signatures.md?install_source=skillspack&referrer=upload-skill)
148 changes: 148 additions & 0 deletions skills/platform/cloudinary-upload/references/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Upload Troubleshooting

Error messages, causes, and fixes for Cloudinary upload failures.

## Error Response Format

Cloudinary returns errors as JSON:
```json
{"error": {"message": "Exact error message here"}}
```
HTTP status is typically 400 (bad request) or 401 (auth failure).

## Error Reference

### Authentication & Signature Errors

| Error message | Root cause | Fix |
|---|---|---|
| `Invalid signature` | Signature mismatch | Check: parameter sort order, excluded params (file, cloud_name, resource_type, api_key), API secret is correct. See [signed-uploads.md](signed-uploads.md) |
| `Signature has expired` | Timestamp > 1 hour old, or timestamp in milliseconds | Use `Math.floor(Date.now() / 1000)` in JS; regenerate signature |
| `Missing required parameter - signature` | Signed upload missing signature field | Add `signature` to request |
| `Missing required parameter - timestamp` | Signed upload missing timestamp field | Add `timestamp` (seconds) to request |
| `Unknown API key` | Wrong or missing `api_key` | Ask the user to confirm the API key and secret pair in Cloudinary Console → Settings → API Keys, and that it belongs to the same cloud as `cloud_name` |

### Upload Preset Errors

| Error message | Root cause | Fix |
|---|---|---|
| `Upload preset must be specified` | Unsigned upload missing `upload_preset` | Add `upload_preset` parameter |
| `Upload preset not found` | Preset name is wrong or doesn't exist | Check preset name in Console → Settings → Upload |
| `Upload preset is not enabled for unsigned uploads` | Preset is set to signed mode | Change preset mode to unsigned, or use signed upload |

### Resource & File Errors

| Error message | Root cause | Fix |
|---|---|---|
| `Invalid image file` | Wrong `resource_type` for uploaded file | Set `resource_type: "video"` for videos, `"raw"` for non-media files |
| `Invalid file type` | File extension not in `allowed_formats` | Add format to preset's `allowed_formats`, or use a different file |
| `Resource not found` | Wrong `public_id` or asset doesn't exist | Check `public_id` for whitespace; verify asset exists |

### Chunked Upload Errors

| Error message | Root cause | Fix |
|---|---|---|
| `Invalid Content-Range header` | Wrong format or arithmetic | Use `bytes start-end/total`; end = start + chunk_size - 1 |
| `Chunk too small` | Non-final chunk < 5 MB | Ensure all chunks except last are ≥ 5,242,880 bytes |

### Account & Quota Errors

These need action from the account owner. Tell the user what to do; the agent cannot fix them in code.

| Error message | Root cause | Fix |
|---|---|---|
| `Account is not active` | New account not email-verified | Ask the user to verify their email address from the Cloudinary sign-up email |
| `Quota exceeded` | Storage or bandwidth limit reached | Ask the user to upgrade their plan or delete unused assets |
| `Transformation credits exceeded` | Eager transformations over quota | Reduce eager transformations, or ask the user to upgrade their plan |

## Silent Failures (No Error, Wrong Behavior)

| Behavior | Root cause | Fix |
|---|---|---|
| Asset gets random public_id instead of expected | `public_id` has whitespace, or null/empty | Strip whitespace from `public_id` before upload |
| Extra parameters ignored in unsigned upload | Parameters not in the unsigned allow-list | Move parameters to the upload preset |
| Allow-listed parameter (e.g. `folder`, `tags`) ignored in unsigned upload | Preset defines the same parameter; for unsigned uploads the preset wins (only `public_id` and `filename_override` come from the request; `context` and `metadata` merge) | Remove the parameter from the preset, or switch to a signed upload |
| Asset lands in the root folder despite slashes in `public_id` | Dynamic folder mode: `public_id` path does not set the folder | Set `asset_folder` (in the preset) |
| Folder path missing from delivery URL | Dynamic folder mode: `asset_folder` is not part of the `public_id` | Add `public_id_prefix` or `use_asset_folder_as_public_id_prefix: true` |
| Eager transformation exists but delivery still generates lazily | Delivery string differs from the eager string (parameter order, extension, `f_auto`) | Use identical strings or a shared `t_<name>`; pre-generate explicit formats and deliver with `f_auto` and no extension |
| `f_auto` in `eager` or incoming `transformation` does nothing | No requesting browser at upload time | Use explicit formats (`f_webp`, `f_jpg`) in eager; keep `f_auto` on the delivery URL |
| `overwrite: true` has no effect | Forced `false` for unsigned uploads | Switch to signed upload if overwrite is needed |
| Format not converted as expected | File type is in `allowed_formats` — stored as-is | Remove file type from `allowed_formats` if conversion is needed |
| Async upload result never arrives | `notification_url` not set | Add `notification_url` to request or preset |

## Debugging Workflow

When an upload fails or behaves unexpectedly and the tables above don't match, follow these steps in order.

### Step 1: Read the error response
Cloudinary errors are in the response body:
```json
{"error": {"message": "..."}}
```
Note the exact message before doing anything else, and check it against the [Error Reference](#error-reference) above.

### Step 2: Check `resource_type`
Is the `resource_type` in the URL correct for the file being uploaded?
- URL contains `/image/upload/` but the file is a video → change to `/video/upload/`
- Use `/auto/upload/` if the file type varies

### Step 3: Check upload type (signed vs unsigned)
- **Unsigned:** Is `upload_preset` in the request? Is it spelled correctly and set to unsigned in the Console?
- **Signed:** Are `api_key`, `timestamp`, and `signature` all present? Is the timestamp in seconds?

### Step 4: Check parameter validity and precedence
- **Unsigned:** Are any non-allow-listed parameters in the request? Remove them or move them to the preset.
- **Unsigned:** Is a request parameter being "ignored"? Check whether the preset defines the same parameter; the preset wins for most of them.
- **All:** Is `public_id` free of whitespace? Is the folder parameter right for the folder mode?

### Step 5: Verify the signature (signed uploads only)
1. Did you exclude `file`, `cloud_name`, `resource_type`, `api_key` from the signature string?
2. Are parameters sorted alphabetically before joining?
3. Is every value signed exactly as sent (comma-separated `tags`, `&` inside values encoded as `%26`, empty parameters left out)?
4. Is the API secret appended directly with no separator?
5. Is the timestamp within the last hour?

See [signed-uploads.md](signed-uploads.md) for the full algorithm.

### Step 6: Check preset configuration
Open Cloudinary Console → Settings → Upload → [your preset] and verify:
- Preset mode matches usage (signed vs unsigned)
- `allowed_formats` isn't blocking the file type
- Eager transformations don't contain `f_auto`

### Step 7: Check eager and delivery transformations match
- Does the delivery URL use the identical transformation string (same parameter order, same format component)?
- Delivering with `f_auto`? Pre-generate each format you expect `f_auto` to pick (for example `f_webp`, `f_avif`, `f_jpg`) as separate eager variants, and deliver without a file extension so the extension does not become part of the transformation string.
- Was `eager_async` used? The derived asset may not exist yet; check the `eager_notification_url` webhook.

### Step 8: Check chunked upload (large files only)
- Is `Content-Range` in the form `bytes <start>-<end>/<total>`?
- Is the range inclusive on both ends?
- Is `X-Unique-Upload-Id` the same value across all chunks?
- Are all chunks except the last ≥ 5 MB?

See [chunked-uploads.md](chunked-uploads.md) for arithmetic details.

### Step 9: Remote upload checks
- Is the URL ≤ 255 characters?
- Are special characters URL-encoded?
- Is the URL publicly accessible without authentication?

## Common Mistakes & Best Practices

**Avoid:**
- ❌ Passing `folder`, `tags`, `eager`, `allowed_formats` on every request → ✅ Put them in an upload preset and pass `upload_preset`
- ❌ `eager: c_fill,w_600,h_400,f_auto` → ✅ `eager: c_fill,h_400,w_600,f_webp|c_fill,h_400,w_600,f_jpg` (no `f_auto` at upload; explicit formats)
- ❌ Eager `c_fill,h_400,w_600`, delivery `c_fill,w_600,h_400` → ✅ Identical strings, or `t_card` in both
- ❌ `folder: products` in a dynamic-folder environment → ✅ `asset_folder: products` (+ `public_id_prefix: products` if the path belongs in the URL)
- ❌ `public_id: photo.jpg` → ✅ `public_id: photo` (extension only for `raw`)
- ❌ Expecting `overwrite: true` in an unsigned upload → ✅ Signed upload for overwrites
- ❌ Hand-rolling chunked upload in Node or Python → ✅ SDK `upload_large()`
- ❌ Incoming `transformation` to "optimize" originals → ✅ `eager` to warm derived assets; keep the original unless normalizing user-generated content

**Always:**
- Recommend an upload preset for any repeated upload pattern
- Set `resource_type` explicitly
- Set `allowed_formats` on unsigned presets
- Use `eager_async: true` for video eager transformations
- Build delivery URLs with the cloudinary-transformations skill so they match eager output
Loading
Loading