R2¶
Purpose¶
Object storage for user/admin-uploaded files: embroidery custom-order reference images, freelancer
profile photos, and (new in feature/commerce-production-backend) Commerce product images.
Bucket¶
- Name:
akshaya-group-global-files - Binding:
FILES, bound in 3 Workers:workers/api,apps/software-web,apps/admin-web.apps/software-web/worker/index.tscallsenv.FILESfor freelancer avatar upload/delete (see below) - verified directly againstuploadFreelancerPhoto/deleteOwnedAvatarIfAny.apps/corporate-webandapps/commerce-webdo not bindFILESat all - Commerce reaches product images only through the public/api/files/:keyroute, same as any other visitor, never by binding the bucket directly.
Application consumers¶
Three independent write paths into this bucket, verified against every env.FILES.put call site
in the repo:
workers/api/src/index.ts's/api/embroidery/enquirieshandler writes embroidery custom-order reference images; the same Worker's/api/files/:keyreads back only recognized public key shapes (see "Security" below).apps/software-web/worker/index.ts'suploadFreelancerPhotowrites freelancer profile photos (freelancer-avatar-...), and deletes the freelancer's previous photo on replace/removal.apps/admin-web/worker/index.ts's Commerce catalog routes write product images (product-image-...) viaPOST /api/commerce/products/:id/images, and delete them viaDELETE /api/commerce/products/:id/images/:imageId- bothrequireAdmin-gated. The same Worker's/api/referenceroute also reads embroidery reference images back out, behindrequireAdmin()session auth.
Object naming convention¶
private/embroidery-references/<contactId>/<uuid>.<ext> (embroidery reference images)
freelancer-avatar-<userId>-<uuid>.<ext> (freelancer profile photos)
product-image-<productId>-<uuid>.<ext> (Commerce product images)
private/ prefix is a deliberate, enforced-in-code convention (see Security below) - not an R2
bucket feature.
- <ext> is one of jpg/png/webp only for every category (validated against sniffed magic
bytes, not just the declared Content-Type; 5 MB max for reference images, 3 MB max for
avatars, 4 MB max for product images).
- Neither public key shape (avatar or product image) has directory nesting, by design - see
apps/software-web/worker/index.ts's avatarObjectKey/ownedAvatarKey and
apps/admin-web/worker/commerce-admin.ts's productImageObjectKey/ownedProductImageKey
comments for why (a nested path would make the "does this URL belong to this
user/product" ownership check unreliable).
Upload flow¶
Embroidery reference images:
1. POST /api/embroidery/enquiries with a multipart/form-data body including the reference
image.
2. API validates size (≤5 MB) and MIME type (JPEG/PNG/WebP only), rejecting anything else with
413/415.
3. env.FILES.put(key, file, { httpMetadata: { contentType, cacheControl: 'private, no-store' },
customMetadata: { contactId, category: 'embroidery-reference' } }).
4. The D1 contacts row is inserted referencing the same key. If the D1 insert fails, the
just-uploaded R2 object is deleted (env.FILES.delete(referenceKey)) - a manual
compensating-action pattern, not a real transaction (R2 and D1 have no cross-store
transactionality).
Freelancer avatars (apps/software-web/worker/index.ts):
1. POST requires an authenticated freelancer session (requireFreelancer).
2. Declared Content-Type is cross-checked against the file's own magic bytes (sniffImageType),
so a spoofed Content-Type doesn't bypass the JPEG/PNG/WebP allowlist.
3. env.FILES.put writes the new photo and the D1 pointer is updated first; only after that
succeeds is the previous avatar deleted, and deletion re-checks the object's own
customMetadata.userId before removing it (defense in depth beyond the key-prefix ownership
check).
Commerce product images (apps/admin-web/worker/index.ts + commerce-admin.ts), new in
feature/commerce-production-backend:
1. POST /api/commerce/products/:id/images requires requireAdmin - there is no seller/customer
self-upload path.
2. Same magic-byte sniffing as avatars (sniffImageType, duplicated in commerce-admin.ts per
this repo's self-containment convention), 4 MB cap, JPEG/PNG/WebP only.
3. The R2 key (productImageObjectKey) is always generated by this Worker, never accepted from
the caller. commerce_product_images.object_key stores it alongside product_id. If the
env.FILES.put succeeds but the following D1 insert fails, the R2 object is deleted immediately
(compensating action, same reasoning as the embroidery-reference upload above) rather than left
as an untracked but still-publicly-fetchable orphan.
4. DELETE /api/commerce/products/:id/images/:imageId verifies the image row belongs to the given
product and that the key was actually generated for that product
(ownedProductImageKey) before deleting anything - never deletes an arbitrary caller-supplied
key. Ordering matters: the R2 object is deleted first, and the commerce_product_images row
only after that succeeds. If the R2 delete fails, this returns 500 and deliberately leaves the
D1 row in place - recoverable/retryable - rather than the reverse ordering, which could leave the
R2 object as an untracked public orphan if the R2 delete then failed after the D1 row was already
gone.
Download flow¶
GET /api/files/:key (workers/api - public avatar/product-image downloads only, see Security below)
GET /api/reference?key=... (apps/admin-web/worker - authenticated embroidery-reference downloads)
object.writeHttpMetadata
+ etag are forwarded on the public path; the admin path additionally sets a locked-down CSP and
no-store cache-control since the content is private.
Security¶
- Hardened in
fix/platform-hardening, extended infeature/commerce-production-backend:/api/files/:key(workers/api/src/index.ts) used to be a blocklist - serve any key that does not start withprivate(workers/api/src/public-file-access.ts's predecessor logic). That meant anyone who could guess or otherwise obtain any non-private-prefixed key could fetch it with zero authentication, and a future R2 write landing outside theprivate/prefix would have been silently exposed by default. It is now an allowlist (workers/api/src/public-file-access.ts'sisPubliclyServableFileKey): only key shapes that are actually meant to be public are servable -freelancer-avatar-<userId>-<uuid>.<ext>and, sincefeature/commerce-production-backend,product-image-<productId>-<uuid>.<ext>; everything else,private/-prefixed or not, gets the same 404 a missing key would. Adding the product-image shape was a deliberate, minimal addition to the allowlist (one more specific pattern), never a loosening of the check itself. Unit tested directly (public-file-access.test.ts) against private keys, private-like prefixes, path-traversal and percent-encoded variants, and non-avatar/non-product-image-shaped keys - including a test proving the new product-image category does not loosen protection for the pre-existing private/avatar categories. - The genuinely private object category (embroidery reference images) was never reachable through
/api/files/:keybefore or after this change - it's independently gated behindapps/admin-web/worker/index.ts'srequireAdmin()session auth on/api/reference, which is unchanged. This is still an application-level check, not an R2 bucket ACL or signed-URL mechanism, but it is now identity-checked (admin session) for the private category rather than relying on the download route being merely unguessable. - The bucket itself is never exposed via a public R2.dev URL or
custom_domainin anywrangler.jsoncchecked - the two Worker proxies above are the only access paths today.
CORS¶
No R2 bucket-level CORS configuration exists (no cors block in any wrangler.jsonc, no
bucket CORS policy referenced in infrastructure/cloudflare/README.md). Cross-origin access is
handled entirely by the API Worker's own ALLOWED_ORIGINS-derived CORS headers on the
/api/files/:key proxy response - see Workers.
Lifecycle / retention¶
Not configured - no lifecycle/expiration rule exists for this bucket in anything checked. Uploaded reference images persist indefinitely unless manually deleted.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
Upload returns 413 |
File over 5 MB (embroidery reference) / 3 MB (avatar) |
Upload returns 415 |
Not JPEG/PNG/WebP by sniffed content, regardless of declared Content-Type |
/api/files/:key returns 404 for a key you just uploaded |
The key isn't a recognized public (freelancer-avatar-... or product-image-...) key shape - by design, never servable through this route, whether or not it's private/-prefixed |
| Object exists in R2 but the D1 row is missing | The D1 insert failed after upload but before the compensating delete ran (e.g. the Worker crashed mid-request) - a genuine, if rare, inconsistency window; there's no reconciliation job today |