R2¶
Purpose¶
Object storage for user-uploaded files - currently, embroidery custom-order reference images.
Bucket¶
- Name:
akshaya-group-global-files - Binding:
FILES, bound in 3 Workers:workers/api,apps/software-web,apps/admin-web. Corrected from an earlier version of this page:apps/software-web/worker/index.tsdoes callenv.FILES(freelancer avatar upload/delete, see below) - verified directly againstapps/software-web/worker/index.ts'suploadFreelancerPhoto/deleteOwnedAvatarIfAny.apps/corporate-webdoes not bindFILESat all.
Application consumers¶
Two 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 the one public key shape (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/api/referenceroute 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)
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 both categories (validated against sniffed magic
bytes, not just the declared Content-Type; 5 MB max for reference images, 3 MB max for
avatars).
- The avatar key has no directory nesting by design - see apps/software-web/worker/index.ts's
avatarObjectKey/ownedAvatarKey comments for why (a nested path would make the "does this URL
belong to this user" 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).
Download flow¶
GET /api/files/:key (workers/api - public avatar 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:/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 the one key shape that is actually meant to be public -freelancer-avatar-<userId>-<uuid>.<ext>- is servable; everything else,private/-prefixed or not, gets the same 404 a missing key would. Unit tested directly (public-file-access.test.ts) against private keys, private-like prefixes, path-traversal and percent-encoded variants, and non-avatar-shaped keys. - 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-...) 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 |