Uploads & R2 — Behavior
Canonical staged-upload flow
client api (UploadsController) redis R2
│ │ │ │
│ POST /uploads/presign │ │ │
│ { ownerType, mime, size, │ │ │
│ filename? } │ │ │
│ ─────────────────────────────► │ │ │
│ │ validateAgainstPolicy() │ │
│ │ uploadId = uuid() │ │
│ │ r2Key = <prefix>/<uid>/<id>.<ext> │
│ │ ────────────────────────────────────────► (sign URL)
│ │ ◄──────────────────────────────────────── presigned PUT URL
│ │ setex upload:<id> 900s │ │
│ │ ─────────────────────────► │ │
│ ◄───────────────────────────── │ { uploadUrl, uploadId, expiresIn: 600 }│
│ │ │ │
│ PUT uploadUrl + bytes │ │ │
│ ────────────────────────────────────────────────────────────────────────► │
│ │ │ │
│ POST /<parent>/... │ │ │
│ { uploadIds: [id], ... } │ │ │
│ ─────────────────────────────► │ │ │
│ │ lookupStaged(id, userId) │ │
│ │ ─────────────────────────► │ │
│ │ ◄───────────────────────── │ │
│ │ HeadObject(r2Key) ──────────────────────►│
│ │ ◄──────────────────────────────────────── │
│ │ persist parent row referencing r2Key │
│ │ consumeMany([id]) │ │
│ │ ─────────────────────────► │ │
│ ◄───────────────────────────── │ parent row payload │ │Steps
-
Presign (
POST /organizations/:orgId/uploads/presign)- Active membership required.
validateAgainstPolicy(ownerType, mimeType, fileSize)rejects unsupported MIME or oversized files with400.- Mints
uploadId = uuid()andr2Key = <prefix>/<userId>/<uuid>.<ext>where prefix is fixed per owner type. - Calls
r2.getPresignedUploadUrl(r2Key, mimeType, 600)— 10-minute PUT TTL. - Stages
{ r2Key, mimeType, fileSize, userId }in Redis atupload:<uploadId>with 15-minute TTL. - Returns
{ uploadUrl, uploadId, expiresIn: 600 }.
-
Direct PUT (client → R2)
- The client uploads bytes straight to R2 using the presigned URL.
- Must use the exact
Content-Typedeclared at presign — R2 enforces it against the signed request.
-
Parent write (e.g.
POST /progress-photos)- Caller passes the
uploadId(s)in the parent body. - Resource service calls
uploads.lookupStagedMany([uploadId], userId):- Reads Redis; missing →
400 Upload not found or expired. - Validates
meta.userId === caller.id; mismatch →403 Upload does not belong to caller. - HEADs the R2 object; missing →
400 File was not uploaded.
- Reads Redis; missing →
- Persists the parent row with
r2Key. - Calls
uploads.consumeMany([uploadId])to delete the Redis key (the R2 object stays).
- Caller passes the
Policy table
apps/api/src/uploads/uploads.service.ts:
ownerType | Allowed MIME | Max size |
|---|---|---|
exercise_comment | image (jpeg, png, webp, heic, heif, gif) | 10 MB |
exercise_comment | video (mp4, quicktime) | 50 MB |
progress_photo | image (jpeg, png, webp, heic, heif, gif) | 15 MB |
message (internal) | image (jpeg, png, webp, gif) | 10 MB |
The DTO additionally hard-caps fileSize at 100 MB regardless of policy.
Key naming
<prefix>/<userId>/<uploadId>.<ext>
exercise-comments/<uid>/<id>.<ext>progress-photos/<uid>/<id>.<ext>messages/uploads/<uid>/<id>.<ext>
Files are scoped to the uploader; downstream consumers preserve the namespace.
Direct R2 paths (no staging)
Some flows bypass the staged Redis pattern because the URL is read-only or the lifecycle is server-managed:
- Organization logo —
OrganizationsService.uploadLogocallsr2.getPresignedUploadUrldirectly; the resulting key is stored on the org row. - Minisite assets — uploaded via the editor; the API stores the resulting R2 key on
minisite_content.published_content. - Workout media —
WorkoutsServicemints presigned URLs for media references on the workout body. - Compliance PDFs —
FormsServicewrites signed PDFs to the dedicated compliance bucket (R2_COMPLIANCE_BUCKET_NAME) viar2.upload(...). Bucket-level immutability + 7-year lifecycle live on the bucket itself. - Export CSVs —
ExportServiceuploads the generated CSV directly toexports/<orgId>/<jobId>.csvand mints download URLs on demand. - Tokenized form signatures (public) —
forms.controller.tsexposes a@Public()POST /forms/by-token/:token/signature-uploadendpoint that mints a presigned PUT for animage/pngbody. The token authorizes the upload; no Clerk session needed.
Presigned GET URLs
Two helpers:
getPresignedUrl(key, expiresIn = 3600, opts?)— uncached, fresh URL every call. Used for short-lived (60s) downloads and when freshness matters (export emails, just-uploaded preview).getPresignedUrlCached(key, expiresIn = 3600, opts?)— Redis-cached. Cache TTL =max(60, expiresIn - 300)so the returned URL never delivers a stale already-expired signature. Cache keyr2:geturl:<bucket>:<key>:<expiresIn>so the same object can mint different-bucket URLs without collision.
invalidatePresignedUrl(key, expiresIn) purges the cache after a delete or replace.
Buckets
R2_BUCKET_NAME(default) — application uploads.R2_COMPLIANCE_BUCKET_NAME(optional) — signed compliance PDFs with bucket-level immutability + 7-year retention. Falls back to the default bucket if unset.
Failure modes
| Failure | Surface | Recovery |
|---|---|---|
| Presign called for an inactive membership | 403 Not an active member | Member reactivates; retry. |
| MIME/size policy violation | 400 Unsupported file type / 400 File too large | Client retries with valid file. |
| Direct PUT 403 (signature expired) | R2 returns 403 to client | Client re-requests presign. |
| Parent write before PUT succeeds | 400 File <id> was not uploaded (HEAD fails) | Client retries the PUT then re-submits. |
Parent write with another user’s uploadId | 403 Upload does not belong to caller | Caller must use their own staging key. |
| Staging key expired (15 min) | 400 Upload <id> not found or expired | Re-presign. R2 object becomes orphan (no sweeper yet). |
| Redis outage | 500 on presign + lookup | Operational — uploads paused until Redis recovers. |