cloudflare-r2-private-image-upload
Skill0 starsChanged 6 days agoMITView on GitHub
Store user-uploaded photos in a PRIVATE Cloudflare R2 bucket and serve them through the Worker only after session + space-membership authorization, with client-side downscaling (EXIF/GPS stripped) before upload. Use when a Workers + Hono + D1 app needs image upload where photos must stay private to the family/space (no public bucket, no r2.dev URL), when an uploaded image returns 404 after switching between `vite dev` and `wrangler dev`, when `wrangler deploy` fails with `Authentication error` (code 10000) on `/r2/buckets` after adding `r2_buckets`, or when an iPhone HEIC photo won't display on Android. Covers the decision matrix with pricing verified 2026-08 (R2 free tier, Images 5,000 free transformations and the Free-plan 9422 error), the no-extension key scheme `<prefix>/<spaceId>/<parentId>/<id>`, magic-bytes validation, the authorize-before-R2 serve route with conditional GET, delete ordering, thumbnails (client variants vs Images binding cached into R2), e2e, and the no-PITR backup decision.
In this repository23 other files it gives its agents.
- skills/vercel-react-best-practices/AGENTS.mdAGENTS.md
- agent-status-hubSkill
- claude-code-docker-sandboxSkill
- cloudflare-api-token-permissionsSkill
- cloudflare-cron-to-discordSkill
- cloudflare-d1-drizzle-migrationSkill
- cloudflare-d1-keyless-host-backupSkill
- cloudflare-d1-weekly-backup-via-prSkill
- cloudflare-mcp-claude-toolingSkill
- cloudflare-workers-bot-scan-defenseSkill
- cloudflare-workers-builds-keyless-deploySkill
- cloudflare-workers-deploy-skeletonSkill
- cloudflare-workers-e2e-playwrightSkill
- cloudflare-workers-host-synthetic-checkSkill
- cloudflare-workers-passkey-authSkill
- cloudflare-workers-pat-bearer-authSkill
- cloudflare-workers-space-membership-inviteSkill
- cloudflare-workflows-for-long-tasksSkill
- playwright-e2e-in-docker-sandboxSkill
- sandboxed-agent-github-token-via-1passwordSkill
- sandboxed-agent-git-relaySkill
- snr-writingSkill
- vercel-react-best-practicesSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 29
Do not use for: public marketing assets (put them in the Assets binding or a public bucket), video or multi-GB objects (multipart upload, …
- Very specific: line 108
Never public and never the Cache API: the edge cache is keyed by URL, not by cookie — one cached public response is every family's photo …
- Very specific: line 162
Write-back rule: when a bullet is confirmed or corrected, edit this section *and* the reference that carries the code, add "(verified …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Private R2 image upload, served through the Worker
Provenance: extracted from nyalog (ADR-006 "medical images on R2 + Worker proxy", PRs #40–#42 — the attachment routes have run in production since 2026-05). Pricing and limits were re-verified against developers.cloudflare.com on 2026-08-22; nyalog's ADR quoted a "25 GB / 5K writes" R2 free tier that was wrong — the real numbers are in the table below. This skill was written ahead of its second consumer (matatabetai meal photos); that implementation landed 2026-09-01 (matatabetai PR #27, local dev + e2e — prod deploy pending R2 bucket creation) and confirmed several claims below.
Why this shape: a family photo is private data. The moment a photo has a URL that works without a cookie (public bucket, r2.dev, signed URL pasted into chat) you have lost revocation. Keeping the bucket private and routing every byte through the Worker means the same session + space-membership check that guards the JSON API guards the pixels — and <img src="/api/…"> on the same origin sends the session cookie for free, so nothing changes on the client, in the CSP, or in the auth model.
When to use this skill
- Adding photo/image upload to a Workers + Hono + D1 app where the images belong to a space/family and must not be reachable without a session
- You're deciding between R2, Cloudflare Images,
/cdn-cgi/imagetransformations, and presigned S3 URLs and want the 2026-08 numbers, not folklore - Symptoms:
Authentication error [code: 10000]on/accounts/<id>/r2/buckets/<name>duringwrangler deploy(or a Workers Builds build) right after adding anr2_bucketsbinding- An image uploaded under
vite devis 404 underwrangler dev(or in e2e) — or vice versa TS2358: The left-hand side of an 'instanceof' expression must be of type 'any'…onfile instanceof Filein the Worker- A photo taken on an iPhone uploads fine but is a broken image on Android/Chrome (HEIC)
- Portrait phone photos come out sideways after upload
- Planning thumbnails and wondering whether the Images binding will cost money (Free plan: it won't — it errors with
9422instead)
Do not use for: public marketing assets (put them in the Assets binding or a public bucket), video or multi-GB objects (multipart upload, different limits), third-party direct-to-bucket uploads at scale (presigned URLs — deliberately rejected here, see the matrix), or apps without the per-space authorization model.
Deliverables (completion criteria)
wrangler.jsonchasr2_buckets: [{ binding, bucket_name }]; the bucket exists (wrangler r2 bucket create); the deploy/build token hasAccount / Workers R2 Storage / Edit- Bucket is private (no public access, no
r2.devdomain, no custom domain); every read goes throughGET /api/<parent>/:id/attachments/:attachmentId - Keys follow
<prefix>/<spaceId>/<parentId>/<attachmentId>with no extension; content type lives in R2httpMetadataand the D1 row - Upload route: multipart guard →
filenarrowing → size cap (413) → magic-bytes allowlist (415) →put→ INSERT, with a compensatingdeleteif the INSERT throws - Serve route: session → space membership → parent row → attachment row, then
get(key, { onlyIf }); headersContent-Type(DB),Content-Length(obj.size),ETag(obj.httpEtag),Cache-Control: private, max-age=3600,X-Content-Type-Options: nosniff; 304 on precondition failure; 404 when the object is missing - Delete: R2
deletebefore the DB delete (never the other way round); parent delete collects keys and uses the array form ofdelete - Client downscales to ≤ 1600 px JPEG (and a 320 px thumb) via canvas before upload — EXIF/GPS gone, ~200–400 KB per photo
- HEIC policy decided and written down (default here: reject on the server with a clear message; the client converts what it can decode)
- Backup decision recorded (default: accept — the phones keep the originals; see the matrix)
- One e2e path: upload →
<img>200 → cross-user 404 → delete → 404 - Every
UNVERIFIED:bullet below was confirmed or corrected in the consuming project, and this skill was updated
The decision in one table
Numbers verified 2026-08-22 (sources and the full matrix in references/decision-matrix.md).
| Option | Authz possible? | Cost at family scale | Verdict |
| Private R2 + Worker proxy | Yes — same middleware as the API | $0: free tier is 10 GB-month, 1M Class A, 10M Class B ops/month, egress free | Chosen |
Public bucket / r2.dev / custom domain | No — URL = access | $0 | Rejected: private photos |
| Cloudflare Images (stored) | Signed URLs or binding | $5 per 100k stored + $1 per 100k delivered, monthly | Rejected: fixed cost for ~100 photos/month |
/cdn-cgi/image/... URL transformations | No — needs a public origin | 5,000 unique transformations/month free | Unusable behind authz |
| Images binding (transform in the Worker) | Yes — it's just bytes in the Worker | 5,000 unique/month free; Free plan returns error 9422 beyond that (no charge); Paid $0.50 per 1,000 | Optional thumbnail path, see below |
| Presigned S3 URLs (browser → R2 direct) | Per-URL, time-boxed | $0 but a second token type (R2 API token) | Rejected: nothing to gain at family scale |
Platform ceilings that matter: request body ≤ 100 MB on Free/Pro (so a 10 MB app cap is a policy, not a limit); Workers Free allows 50 subrequests per invocation.
Wiring: binding, bucket, token, local state
// wrangler.jsonc (and wrangler.local.jsonc if you keep a credential-free dev config — keep them in sync)
"r2_buckets": [
{ "binding": "PHOTOS_BUCKET", "bucket_name": "<app>-photos" }
]- Create the bucket on the host (needs
wrangler login; the sandbox has no credentials):pnpm exec wrangler r2 bucket create <app>-photos— optionally--location apacas a placement hint. Deploy performs an existence check, so the bucket must exist before the first deploy with the binding. - Token permission: the
r2_bucketsbinding needsAccount / Workers R2 Storage / Editon whatever token deploys — the GH ActionsCLOUDFLARE_API_TOKEN(matrix incloudflare-api-token-permissions) or the Workers Builds build token. The token Workers Builds generates via Create new token already carriesWorkers R2 Storage (edit)(docs + dashboard notice, 2026-08-23 — seecloudflare-workers-builds-keyless-deploy0.3.0); only an older or hand-made token may lack it — then extend it in place, don't regenerate. Missing it →Authentication error [code: 10000]on/accounts/<id>/r2/buckets/<name>. - Local dev is fully simulated (Miniflare) — unlike the AI binding, an R2 binding does not force a remote session, so uploads work credential-free in the sandbox. Objects live under
.wrangler/state/v3/r2/<bucket_name>/next to the D1 sqlite; the same--persist-to .wrangler/staterule fromcloudflare-workers-e2e-playwrightapplies, otherwisevite devandwrangler dev --config dist/…see two different buckets and uploads "disappear". - Types:
PHOTOS_BUCKET: R2BucketinBindings(global type viawrangler types).
Key scheme and the attachments row
photos/<spaceId>/<parentId>/<attachmentId> full-size (≤ 1600 px JPEG)
photos/<spaceId>/<parentId>/<attachmentId>/w320 thumbnail (client variant or Images-binding cache)- No extension. The content type is validated once at upload and stored in R2
httpMetadata.contentTypeand the D1 row; the serve route never guesses from the key - The
spaceIdprefix meanslist({ prefix: "photos/<spaceId>/" })can audit or wipe one family's objects without touching another's - D1 row (
attachments):id,record_id(FK → the parent that carriesspace_id,ON DELETE CASCADE),r2_key,thumb_key(nullable),content_type,size_bytes,width,height,original_filename,created_by,created_at. Schema and routes in references/worker-routes.md. For matatabetai: parent =meals, table =meal_photos
Upload route — validate before put, put before INSERT
Order of checks (each one returns before the next runs):
- Parent row resolved through
memberSpaceIds(404 if it isn't in one of your spaces — existence-hiding, same rule as every other route) Content-Typeheader starts withmultipart/form-data, else 400 —c.req.formData()throws on anything else and you'd get an opaque 500const file = form.get("file"); rejectnullandstring(400). Don't writefile instanceof File— the Worker tsconfig fails it with TS2358; excluding the other two members ofFormDataEntryValuenarrows toFilefile.size === 0→ 400;file.size > MAX_IMAGE_BYTES(10 MB) → 413attachment_too_large- Sniff the first 12 bytes (
FF D8 FFJPEG,89 50 4E 47 0D 0A 1A 0APNG,RIFF….WEBP,ftyp+ HEIF brand) and allow onlyimage/jpeg/image/png/image/webp→ 415attachment_type_not_allowedotherwise.file.typeis whatever the client said; store the sniffed type, notfile.type PHOTOS_BUCKET.put(key, bytes, { httpMetadata: { contentType } })— then the optionalthumbfield the same way- INSERT the row. If the INSERT throws,
deletethe object(s) you just put and rethrow — an object without a row is an orphan nobody can ever delete through the app - 201 with the row plus its serve URL
Serve route — authorize before touching R2
sessionMiddleware → parent ∈ memberSpaceIds → attachment.record_id === parent.id → PHOTOS_BUCKET.get(key, { onlyIf: request.headers })- The chain is N hops of "child row points at the row I already trusted"; only the first hop checks space membership. Every miss is 404, never 403
onlyIf: c.req.raw.headerslets R2 evaluate the browser'sIf-None-Match/If-Modified-Since; when the precondition fails R2 returns anR2Objectwithoutbody→ respond304withETag+Cache-Control. (Documented R2 API; not yet exercised in the source projects — see UNVERIFIED)- Headers:
Content-Typefrom the DB row,Content-Length: obj.size(not the DB value — they drift after a re-upload or a cached transform),ETag: obj.httpEtag,Cache-Control: private, max-age=3600,Content-Disposition: inline,X-Content-Type-Options: nosniff nullfromgetwith a row present = DB/R2 inconsistency → 404 andconsole.error(it should never happen with the delete ordering below)- Never
publicand never the Cache API: the edge cache is keyed by URL, not by cookie — one cachedpublicresponse is every family's photo for an hour
Delete — R2 first (best-effort), then D1
- Single attachment:
delete([r2Key, thumbKey])→ DB delete. If R2 throws, the row survives and the user retries; the reverse order turns a transient R2 failure into a permanent 404 row - Parent delete:
SELECT r2_key, thumb_key WHERE record_id = ?→ onedelete(keys[])call (array form, up to 1,000 keys — one call instead of N, which also matters under the Free plan's 50-subrequest cap) → delete the parent (rows cascade) - Orphans are acceptable at family scale; an optional weekly sweep (
list({ prefix: "photos/" })minus the rows, skipping objects uploaded in the last hour) is sketched in references/e2e-and-ops.md — cron plumbing percloudflare-cron-to-discord
Client — downscale, strip EXIF, then FormData
<input type="file" accept="image/*">. Addingcapture="environment"forces the camera and hides the gallery picker on most mobile browsers — offer it as a second button, not the defaultcreateImageBitmap(file, { imageOrientation: "from-image" })→ canvas with the longest side ≤ 1600 px →canvas.toBlob("image/jpeg", 0.85); same bitmap again at 320 px for the thumb. A canvas re-encode carries no EXIF, so GPS (your home) and device info never reach the server; 3–8 MB phone photos become ~200–400 KB, which makes the 10 GB free tier good for tens of thousands of photos- If decoding throws (Chrome/Firefox/Android cannot decode HEIC) the helper returns
null; the UI tells the user to switch iPhone Camera → Formats → Most Compatible or share as JPEG. Safari can decode HEIC into the canvas, so on iOS the file still becomes a JPEG. The server rejects HEIC (415) precisely so that an Android family member never gets a broken<img>— nyalog instead accepted HEIC and downgraded it to a download link, which is the right call only for documents nobody needs to see inline FormData+ rawfetch— never setContent-Typeyourself (the boundary is generated by fetch) and don't route through a JSONrequest()wrapper that forcesapplication/json- Code in references/client-resize-upload.md
Thumbnails — two client variants by default
Option 1 (default): client uploads full + thumb | Option 2: Images binding, cached back into R2 | |
| Cost | zero transformations | 1 unique transformation per image, once (cached under <key>/w320), not per view |
| Local dev / e2e | identical to prod | wrangler dev runs a low-fidelity offline Images implementation (width / height / rotate / format only) — don't assert on thumbnail bytes locally; --remote needs credentials |
| Free plan | n/a | beyond 5,000 unique/month the binding returns error 9422 (no charge) — fall back to the full image |
| Extra wiring | one more form field | "images": { "binding": "IMAGES" }, max input 20 MB |
Both in references/thumbnails-and-images-binding.md. Option 2 is the right move only when you need variants the client didn't produce (a later redesign) — and never transform per request with client-chosen widths: every new (image, options) pair is a new unique transformation.
e2e — one golden-path extension, not a new suite
Stay inside the "3 specs" philosophy of cloudflare-workers-e2e-playwright: extend the golden path with setInputFiles (a 74-byte PNG fixture, the client resize runs for real in headless Chromium) → POST 201 → <img> naturalWidth > 0 → direct GET is 200 with cache-control: private → the other seeded user's session gets 404 → delete → 404. Whether the R2 object is physically gone is checked via the local state directory, command in references/e2e-and-ops.md (marked unverified). In the sandbox nothing changes: R2 is local, see playwright-e2e-in-docker-sandbox.
Backup — decide and write it down
R2 has no point-in-time recovery; bucket locks only prevent deletion/overwrite for a retention period and would break the app's own delete feature if applied to the live bucket. Options (table with costs in references/decision-matrix.md): accept the risk (the phones keep the originals — the app stores downscaled copies, so for a meal log this is the honest default), rclone copy (additive — sync would mirror an accidental delete on its next run) to a second bucket with a bucket lock or lifecycle rule, from a GitHub Actions cron using an R2 S3 API token (a separate token type created under R2's API tokens — not the Workers R2 Storage permission), or a copy to another provider if the archive is irreplaceable. D1 backups stay with cloudflare-d1-weekly-backup-via-pr. Record the choice in an ADR; "we'll think about it later" is how family photos vanish.
The pitfalls that eat hours
- Trusting
file.type— it's client-controlled; a.exerenamed.jpgwithimage/jpegpasses an allowlist. Sniff the bytes, store the sniffed type Content-Lengthfrom the DB row — after a re-upload or a cached transform the object size differs and the browser truncates or hangs. Useobj.size- Token lacks
Workers R2 Storage: Edit— deploy/build fails withcode: 10000on/r2/buckets/<name>; the dash-generated Workers Builds token includes it (2026-08-23), a hand-made or older build token may not Cache-Control: publicorcaches.default.put()on an authenticated image — cross-user leak at the edge.privateonly- "Just make the bucket public for now" — there is no "for now" for a URL that was shared once
- Images binding local-dev divergence — low-fidelity offline in
wrangler dev; quality/blur/format nuances only exist with--remote(credentials) or in prod - Free-plan 9422 — transforming per view with client-supplied widths burns unique transformations; fix the variant set and cache into R2
.wrangler/statemismatch —vite devandwrangler dev --config dist/…/wrangler.jsonresolve state differently; uploads vanish between them.--persist-to .wrangler/state, same as D1- Bytes through Workflows steps —
step.do()outputs must be JSON; pass the R2 key and re-getinside the step (cloudflare-workflows-for-long-tasks) c.req.formData()on a non-multipart body throws → 500 with no hint; guard the header first- Deleting the DB row before the object — a transient R2 error then leaves an object with no row (un-deletable through the app) and a 404 that looks like data loss
capture="environment"hides "choose from gallery" on phones- Japanese filenames in
Content-Disposition— plainfilename="…"breaks; usefilename*=UTF-8''<percent-encoded>or omit the filename forinline - Transparent PNG → JPEG loses alpha (black background). Meal photos are camera JPEGs, so JPEG output is fine; keep PNG for screenshots if that matters
Unverified claims — confirm while implementing, then write back
Write-back rule: when a bullet is confirmed or corrected, edit this section and the reference that carries the code, add "(verified YYYY-MM-DD in <project>)", bump metadata.version, and ship it as feat(r2-image-upload): … を還元.
- PARTIALLY VERIFIED (2026-09-01 in matatabetai): the JPEG/PNG/WebP/HEIF signature code works — unit tests cover all four plus PDF/HTML/EXE/WAV rejection, and e2e confirms a real headless-Chromium canvas JPEG passes the sniff end to end. Still unverified: real phone uploads (a WebP from Android Chrome's share sheet, an iOS camera JPEG) and a renamed non-image through the actual UI
- UNVERIFIED → VERIFIED (2026-09-01 in matatabetai, local dev/Miniflare):
get(key, { onlyIf: c.req.raw.headers })returns a body-lessR2ObjectonIf-None-Matchmatch; narrowing with!("body" in obj)→ 304 works and is fixed in e2e (direct GET 200 + ETag, conditional GET 304). Not yet exercised against prod R2 - UNVERIFIED: iOS Safari transcodes HEIC → JPEG when the picker is opened with
accept="image/*"(and may send the original HEIC whenacceptlistsimage/heic). Test on a current iPhone with Camera → Formats → High Efficiency; record the iOS version - UNVERIFIED:
createImageBitmap(file, { imageOrientation: "from-image" })orientation handling on current Safari and Firefox; test with a portrait photo from each family phone and note which browsers needed the<img>-decode fallback - UNVERIFIED: the Images binding's accepted input formats — whether HEIC input is supported (docs list max 20 MB input but no HEIC statement). Don't plan server-side HEIC transcoding until confirmed
- UNVERIFIED → VERIFIED (2026-09-01 in matatabetai, wrangler 4.125.0): blobs live under
.wrangler/state/v3/r2/<bucket_name>/blobs/(object metadata in a sibling.wrangler/state/v3/r2/miniflare-R2BucketObject/*.sqlite);wrangler r2 object get <bucket>/<key> --local --persist-to .wrangler/state --file <out>exits 1 withThe specified key does not exist.for a missing key. After the e2e run the blobs dir was empty — both delete paths physically remove objects - UNVERIFIED: whether R2 binding calls count toward the Workers Free 50-subrequest cap. If they do, the per-key
deleteloop is a real bug on large parent deletes; the array form sidesteps it either way - PARTIALLY VERIFIED (2026-09-01 in matatabetai): the signature exists —
wrangler types4.125.0 emitsdelete(keys: string | string[]): Promise<void>, and the array form works locally (e2e parent delete). The 1,000-key ceiling itself is still docs-only - UNVERIFIED: "R2 has no object versioning" is inferred from the absence of any versioning/PITR mention on the bucket-locks page (2026-08-22). Re-check https://developers.cloudflare.com/r2/ before the backup ADR
- UNVERIFIED: the
rclone+ R2 S3 token backup sketch has never run in these projects; the env-var config form (RCLONE_CONFIG_R2_*) and theCloudflareprovider name need one successful dry run - UNVERIFIED: an actual R2 deploy through Workers Builds — the generated build token lists
Workers R2 Storage (edit)(docs and dashboard notice, 2026-08-23) but no R2-bound Worker has been deployed through it yet; the permission requirement itself is verified only for the GH Actions token incloudflare-api-token-permissions - UNVERIFIED → VERIFIED (2026-09-01 in matatabetai):
canvas.toBlob("image/jpeg")works in the sandbox-baked headless Chromium (no GPU) — the e2e golden path runs the realcreateImageBitmap→ canvas → JPEG pipeline on a PNG fixture, the Worker sniffsimage/jpeg, and the rendered<img>hasnaturalWidth > 0
Scope boundary — what this skill does NOT cover
- Sessions, passkeys, the session cookie —
cloudflare-workers-passkey-auth memberSpaceIds,spaces/space_members, the 404 existence-hiding rule and invites —cloudflare-workers-space-membership-invite(this skill only consumesc.get("memberSpaceIds"))- Token permission matrix and the in-place token edit —
cloudflare-api-token-permissions; the Workers Builds custom token flow —cloudflare-workers-builds-keyless-deploy - Playwright wiring, the CSP/HMR trap,
--persist-to—cloudflare-workers-e2e-playwright; running it in the sandbox —playwright-e2e-in-docker-sandbox - Post-upload processing longer than 30 s (Vision LLM on the photo) —
cloudflare-workflows-for-long-tasks(pass the key, not the bytes) - D1 backups —
cloudflare-d1-weekly-backup-via-pr; cron plumbing for the orphan sweep —cloudflare-cron-to-discord - Presigned S3 uploads, multipart uploads, video, public buckets, custom domains on R2 — out of scope by decision
References
- decision-matrix.md — full option matrix, the 2026-08-22 verified pricing table with sources, migration triggers, backup decision table
- worker-routes.md — Drizzle schema,
wrangler.jsonc,Bindings, magic-bytes helper, upload / serve / delete / parent-delete templates - client-resize-upload.md —
prepareImage()(decode → downscale → JPEG, EXIF gone), HEIC handling,FormDataupload, React usage - thumbnails-and-images-binding.md — client-variant thumbnails vs Images binding cached into R2, config, counting rules, local-dev caveat
- e2e-and-ops.md — Playwright spec with the PNG fixture, local R2 inspection, orphan sweep, backup workflow sketch, runbook
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

