Skip to content

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.ts does call env.FILES (freelancer avatar upload/delete, see below) - verified directly against apps/software-web/worker/index.ts's uploadFreelancerPhoto/deleteOwnedAvatarIfAny. apps/corporate-web does not bind FILES at 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/enquiries handler writes embroidery custom-order reference images; the same Worker's /api/files/:key reads back only the one public key shape (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 /api/reference route 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)
- 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)
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: /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 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/: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-...) 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