Skip to content

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.ts calls env.FILES for freelancer avatar upload/delete (see below) - verified directly against uploadFreelancerPhoto/deleteOwnedAvatarIfAny. apps/corporate-web and apps/commerce-web do not bind FILES at all - Commerce reaches product images only through the public /api/files/:key route, 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/enquiries handler writes embroidery custom-order reference images; the same Worker's /api/files/:key reads back only recognized public key shapes (see "Security" below).
  • apps/software-web/worker/index.ts's uploadFreelancerPhoto writes 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-...) via POST /api/commerce/products/:id/images, and delete them via DELETE /api/commerce/products/:id/images/:imageId - both requireAdmin-gated. The same Worker's /api/reference route also reads embroidery reference images back out, behind requireAdmin() 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)
Both proxy through their respective Worker (never a direct public R2 URL) - 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 in feature/commerce-production-backend: /api/files/:key (workers/api/src/index.ts) used to be a blocklist - serve any key that does not start with private (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 the private/ prefix would have been silently exposed by default. It is now an allowlist (workers/api/src/public-file-access.ts's isPubliclyServableFileKey): only key shapes that are actually meant to be public are servable - freelancer-avatar-<userId>-<uuid>.<ext> and, since feature/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/:key before or after this change - it's independently gated behind apps/admin-web/worker/index.ts's requireAdmin() 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_domain in any wrangler.jsonc checked - 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