Skip to main content

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.

EnvironmentBucketPublic CDN
devhwork-devpub-3da89db186e147278b4d95fd152add52.r2.dev
staginghwork-stagingpub-c2ba82193c204d6d944fe4869cf24227.r2.dev
productionhworkpub-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:

  1. Client POSTs file to POST /upload with Authorization: Bearer {jwt}``
  2. API validates auth, generates UUID, uploads to R2
  3. 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-data with file field
  • 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.ts with uploadFile(buffer, key, mimeType) method
  • Create api/src/upload/upload.controller.ts with POST /upload endpoint
  • Add UploadModule to 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.avatarUrl or Specialist.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.