Files are stored in a Cloudflare R2 bucket through the S3 API. Two clients exist: Both read the same R2_* variables and the same R2_PATH_PREFIX.

createR2Storage

There is no shared instance on AppContext. Each router that needs storage builds its own from the env: clients, crm, content, exercises, uploads, onboarding, autofit-import, automation-api and the signing lane (signingStorageFromEnv).

Keys and URLs

Every method takes a key relative to R2_PATH_PREFIX. joinKey(prefix, key) adds the prefix, and publicUrlOf(publicBaseUrl, fullKey) builds the public URL. All write paths use the same two helpers, so a multipart upload and a plain put of the same key return the same URL.
Objects are served publicly from R2_PUBLIC_BASE_URL. Anyone who has the URL can read the file. Keys contain a random id, which makes them unguessable but not private. Anything sensitive must be encrypted before it is stored, as the AutoFit import does with its raw pulls, or served through an authenticated route, as the signing lane does for documents.

Three ways to upload

1. JSON with base64 (small files)

The client sends the file as a base64 string inside a JSON body. The service decodes it and calls putObject. Used for images and for POST /v1/web/content/media. Limits come from the body parser: 24mb for JSON in general, 48mb on the two AI analyze routes. Base64 inflates a file by about a third, so the practical file size is lower than the parser limit.

2. Presigned PUT

createUploadUrl returns a URL the browser can PUT to directly. POST /v1/web/content/media-url exposes it. The comment in r2.ts gives the reason: a large file sent as a data URL is held in several copies at once and walks the API into its memory ceiling. The 600 second lifetime is long enough for a slow phone and short enough that a leaked URL is not a standing write grant.

3. Relayed multipart (large video)

The browser cannot always reach R2 directly, because the shared bucket’s CORS policy is not under this project’s control. The multipart lane sends the file through the API in slices without either Node process holding the whole file. A part is sent as Content-Type: application/octet-stream. The route has its own express.raw parser with a 12mb limit, and MAX_MEDIA_PART_BYTES is 12 MiB. The service signature covers the raw bytes of the part. The exercise library uploads its videos through these same routes with a different key prefix. There is one multipart relay in the codebase. Size caps in content.service.ts: MAX_MEDIA_BYTES is 99,000,000 bytes, and a plan PDF is capped at 30 MiB (MAX_PLAN_PDF_BYTES).

Trainee uploads

POST /v1/trainee/uploads (modules/uploads/) is the trainee app’s upload route.
  • The guard is authenticateTrainee. There is no requireTraineeAppAccess on this router.
  • The body is the raw file. The router adds express.raw({ type: () => true, limit: '15mb' }).
  • x-file-type defaults to application/octet-stream and x-file-name to upload.
  • An empty body is BAD_REQUEST, no file provided.
  • The file is stored under media/<uuid>.<ext> and the response is { fileUrl }.
The extension comes from the file name when it is 1 to 8 alphanumeric characters, otherwise from the MIME type (jpg, png, webp, gif, mp4, mov), otherwise bin.
The global parsers run before the router’s own. A request sent as application/octet-stream is parsed by the global express.raw with its 12mb limit, and one sent as application/json by the JSON parser. The router’s 15mb parser only applies to other content types, such as image/jpeg.
The route returns a URL only. Attaching it to a meal, a progress photo or a technique video is a second call to the endpoint that owns that record.

HEIC conversion

iPhones save photos as HEIC, and only Safari displays HEIC. A trainee’s photo looked fine on their phone and arrived broken on the coach’s screen. storage/heic.ts converts on the way in.
  • isHeic(file) reads the file’s own ftyp box, not its name. The app names photos .jpg whatever their real format. AVIF uses the same container and is left alone, because every browser displays it.
  • heicToJpeg(file) decodes in a worker thread. The decoder is WebAssembly and synchronous. A 24 megapixel photo holds a thread for most of a second and about 100 MB of pixels. One conversion runs at a time, each in a fresh worker that is terminated afterwards, because WebAssembly memory never shrinks while its thread lives.
  • Output is JPEG at quality 80, with the longest edge at most 1600 px (HEIC_JPEG_MAX_EDGE), the same size the trainee app shrinks photos to.
  • Files that declare more than 64 megapixels (HEIC_MAX_PIXELS) are stored as uploaded. Decoding allocates for the declared size, so a small file can claim a huge image.
  • Conversion times out after 60 seconds.
In the upload service a failed conversion logs a warning and stores the original. Losing the upload would be worse than a photo one browser cannot show. storage/heic-repair.ts and scripts/convert-heic-media.ts repair photos stored before conversion existed. The JPEG is written beside the original under the same id with a different extension, so a reference is repointed by swapping the extension. Originals are never overwritten, because a CDN or a browser may still hold them under the old URL.

Signed documents

The public signing lane stores source PDFs, signature images and the final signed PDF through signingStorageFromEnv(ctx.env). Stored names are fixed length random hex, so deletePrefix(key) removes exactly that one object. Documents are returned to the signer through the lane’s own routes (GET /v1/public/sign/:token/document and /signed), not by handing out the public URL.

AutoFit import snapshots

The import worker writes the raw data it pulls to R2 so a later stage can resume. Those snapshots are sealed with AES-GCM before the write, because the bucket serves its keys publicly, and the whole snapshot prefix is deleted when the run reaches DONE. Mirrored media lives under its own prefix because client rows reference it and it must outlive the run.

@repo/storage

The avatar package is a thin S3 client used by the oRPC procedures that hand upload URLs to the web app (users.avatarUploadUrl, organizations.createLogoUploadUrl, createBrandLogoUploadUrl, createSelfieExampleUploadUrl).
  • bucket is a logical name. The only one is avatars, mapped to NEXT_PUBLIC_AVATARS_BUCKET_NAME, else R2_BUCKET_NAME, else the literal avatars.
  • Keys are avatars/<path> under R2_PATH_PREFIX (joinStorageKey).
  • Each setting falls back from R2_* to S3_*: R2_ENDPOINT or S3_ENDPOINT, and so on. The client is created lazily and throws a named error when a variable is missing.
  • The S3 client uses forcePathStyle: true.

Choosing a path for new work

Whatever the path, validate the size before reading the whole body where you can, set the content type explicitly, and generate the key on the server. Never build a key from a client supplied name beyond a checked extension.