R2 File Storage Spec — human.work
Overview
Cloudflare R2 for all user-uploaded files. One bucket, folder-based segregation. UUID filenames. Served via Cloudflare CDN with access control.
Buckets: One bucket per environment — isolated credentials, no cross-env access.
| Environment | Bucket | Public CDN |
|---|---|---|
| dev | hwork-dev | pub-3da89db186e147278b4d95fd152add52.r2.dev |
| staging | hwork-staging | pub-c2ba82193c204d6d944fe4869cf24227.r2.dev |
| production | hwork | pub-19424a4a2ab14b6891cc2ed5007534c1.r2.dev |
Folder Structure
hwork[-dev|-staging]/
├── orgs/
│ └── {orgId}/
│ ├── specialists/
│ │ └── {specialistId}/
│ │ └── avatar/
│ │ └── {uuid}.{ext} # Specialist persona avatars
│ └── attachments/
│ └── {convId}/
│ └── {uuid}.{ext} # Message attachments
└── users/
└── {userId}/
└── avatar/
└── {uuid}.{ext} # Expert/AM profile photos
Key decisions
- UUID filenames — original filenames discarded. Prevents enumeration, path traversal, and collisions.
- Separate buckets per env — dev/staging/production each have their own bucket and their own R2 API key. A dev credential gives zero access to staging or production data.
- Org-scoped folders — everything client-related lives under
orgs/{orgId}/. Easy to audit, easy to delete on org churn. - No per-user subfolders for attachments — conversations are shared within an org, so attachments live under
orgs/{orgId}/attachments/{convId}/.
Access Control
Public read (via CDN): Specialist avatars, Expert profile photos — these are displayed to end users. Served via https://cdn.h852.work/{path}`` (or cdn.h.work in production).
Private (signed URLs): Message attachments — only accessible to members of the org. Backend generates a signed URL valid for 1 hour.
Write: Only the API writes to R2. Never direct browser-to-R2 uploads (prevents auth bypass). Flow:
- Client POSTs file to
POST /uploadwithAuthorization: Bearer{jwt}`` - API validates auth, generates UUID, uploads to R2
- API returns the CDN URL (public) or a path (private, to generate signed URLs later)
API Design
POST /upload
- Auth: JWT required
- Body:
multipart/form-datawithfilefield - Query params:
type=avatar | attachment | specialist-avatar - Returns:
{ url: string, key: string }
The type param determines:
- Which folder the file goes in
- Whether it's public or private
- Max file size (avatars: 2MB, attachments: 25MB)
- Allowed MIME types (avatars: image/, attachments: image/ | application/pdf | text/*)
Org-scoped upload validation
- For
type=attachment: JWT must include orgId matching the convId's org (RLS check) - For
type=avatar: only the user themselves or superadmin can upload their own avatar - For
type=specialist-avatar: only superadmin or the specialist's assigned AM
Implementation Plan
Phase 1 — Backend (MVP)
- Create R2 bucket in Cloudflare dashboard
- Set up Cloudflare CDN subdomain:
cdn.h852.work→ R2 bucket (public read for avatars folder) - Add env vars:
R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET_NAME,CDN_BASE_URL - Install
@aws-sdk/client-s3(R2 is S3-compatible) - Create
api/src/upload/upload.service.tswithuploadFile(buffer, key, mimeType)method - Create
api/src/upload/upload.controller.tswithPOST /uploadendpoint - Add
UploadModuleto AppModule
Phase 2 — Avatar upload UI
- Expert/AM profile settings: file input → preview → upload → save URL
- Specialist management: avatar upload in SuperAdmin Specialists page
- Store returned CDN URL in
User.avatarUrlorSpecialist.avatarUrl
Phase 3 — Message attachments (future)
- Attachment upload in MessageComposer
- Signed URL generation for download
- Display in MessageBubble
Env Vars
# Cloudflare R2 — values differ per Railway environment
R2_ACCOUNT_ID=a839d58d7ee4202937b2c70402531510
R2_ACCESS_KEY_ID=<env-specific key> # PENDING — create in CF dashboard
R2_SECRET_ACCESS_KEY=<env-specific> # PENDING — create in CF dashboard
R2_BUCKET_NAME=hwork # hwork-dev | hwork-staging | hwork
R2_PUBLIC_URL=https://pub-19424a4a2ab14b6891cc2ed5007534c1.r2.dev # per-env
Security Notes
- Never expose R2 credentials to the frontend
- UUID filenames prevent guessing other users' files
- Org-scoped folders enable easy data deletion on client churn (delete
orgs/{orgId}/) - CDN caches public files — cache invalidation needed on avatar update (use cache-busting query param or versioned filenames)
- Attachment downloads always go through the API (signed URLs), never direct R2 access
GitHub Issue Reference
Issue #156 — Avatar image upload
Status
Phase 1 backend complete. PR #178 open (feat/r2-file-storage → dev).
Blocked on: R2 S3 API keys — must be created in CF dashboard (one key per env scoped to its bucket). Once keys are in Railway, the API is live.