Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesUploads R2Uploads & R2 — Behavior

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

  1. Presign (POST /organizations/:orgId/uploads/presign)

    • Active membership required.
    • validateAgainstPolicy(ownerType, mimeType, fileSize) rejects unsupported MIME or oversized files with 400.
    • Mints uploadId = uuid() and r2Key = <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 at upload:<uploadId> with 15-minute TTL.
    • Returns { uploadUrl, uploadId, expiresIn: 600 }.
  2. Direct PUT (client → R2)

    • The client uploads bytes straight to R2 using the presigned URL.
    • Must use the exact Content-Type declared at presign — R2 enforces it against the signed request.
  3. 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.
    • Persists the parent row with r2Key.
    • Calls uploads.consumeMany([uploadId]) to delete the Redis key (the R2 object stays).

Policy table

apps/api/src/uploads/uploads.service.ts:

ownerTypeAllowed MIMEMax size
exercise_commentimage (jpeg, png, webp, heic, heif, gif)10 MB
exercise_commentvideo (mp4, quicktime)50 MB
progress_photoimage (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 logoOrganizationsService.uploadLogo calls r2.getPresignedUploadUrl directly; 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 mediaWorkoutsService mints presigned URLs for media references on the workout body.
  • Compliance PDFsFormsService writes signed PDFs to the dedicated compliance bucket (R2_COMPLIANCE_BUCKET_NAME) via r2.upload(...). Bucket-level immutability + 7-year lifecycle live on the bucket itself.
  • Export CSVsExportService uploads the generated CSV directly to exports/<orgId>/<jobId>.csv and mints download URLs on demand.
  • Tokenized form signatures (public)forms.controller.ts exposes a @Public() POST /forms/by-token/:token/signature-upload endpoint that mints a presigned PUT for an image/png body. 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 key r2: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

FailureSurfaceRecovery
Presign called for an inactive membership403 Not an active memberMember reactivates; retry.
MIME/size policy violation400 Unsupported file type / 400 File too largeClient retries with valid file.
Direct PUT 403 (signature expired)R2 returns 403 to clientClient re-requests presign.
Parent write before PUT succeeds400 File <id> was not uploaded (HEAD fails)Client retries the PUT then re-submits.
Parent write with another user’s uploadId403 Upload does not belong to callerCaller must use their own staging key.
Staging key expired (15 min)400 Upload <id> not found or expiredRe-presign. R2 object becomes orphan (no sweeper yet).
Redis outage500 on presign + lookupOperational — uploads paused until Redis recovers.