Environment variables
All default to an empty string in
apps/core-api/src/config.ts, so the API boots without storage. createR2Storage exposes a configured flag, and each operation throws INTERNAL “r2 storage is not configured” when it is false.
The S3 fallback in @repo/storage
The avatar package came from a starter that used S3_* names. Its S3 client accepts either set:
A missing endpoint or credential throws when the client is first used, with a message naming both variable names.
createR2Storage has no such fallback and reads only the R2_* values.
packages/storage is consumed through its built output. After changing how it reads its configuration, rebuild the package, or the running API keeps the old behaviour.UPLOAD_SERVER_URL and UPLOAD_SERVER_ACCESS_TOKEN, are declared in the env schema. Nothing in apps/core-api/src reads them outside config.ts. They belong to an earlier upload server and are unused.
Keys and URLs
createR2Storage joins R2_PATH_PREFIX and the key, and builds the public URL as R2_PUBLIC_BASE_URL plus the full key. Every write path returns the same URL for the same key. Otherwise a multipart upload and a plain put of one object would be stored once and referenced by two URLs.
scope is one of MEDIA_SCOPES: content, exercises, forms, plans. The upload lanes are shared. The exercise library, the form builder and PDF plans upload through the content routes and differ only in the key prefix.
The auto-thumb- marker (AUTO_THUMBNAIL_PREFIX) is how the editor tells a thumbnail it generated from a video apart from one the coach uploaded, so it knows which it may replace.
Upload flows
Accepted types
mediaContentType in content/content.schema.ts: image/png, image/jpeg, image/webp, image/gif, application/pdf, video/mp4, video/quicktime, video/webm.
The plans scope accepts application/pdf only.
1. Base64 in JSON
POST /v1/web/content/media
string
required
The file as base64, or as a data URL. A
data: prefix up to the first comma is stripped.string
required
One of the accepted types.
string
Original file name.
string
auto-thumbnail, for a thumbnail the editor generated.string
content (default), exercises, forms or plans.MAX_MEDIA_BYTES) after decoding, 30 MiB for a plan PDF (MAX_PLAN_PDF_BYTES), and in practice the global JSON body limit of 24 MB set in app.ts. Base64 inflates a file by about a third, so this path is for images and small files.
Errors: VALIDATION “media is empty”, “media is too large”, “plan PDF is too large”.
2. Presigned PUT
POST /v1/web/content/media-url with contentType, and optionally filename and variant. Returns uploadUrl and url.
The browser PUTs the bytes straight to uploadUrl, which is valid for 600 seconds (DEFAULT_UPLOAD_URL_TTL_SECONDS), then stores url. Nothing large passes through a Node process.
This path needs the bucket’s CORS policy to allow the browser origin.
3. Multipart through the API
This path exists because the browser cannot always reach R2 directly (the shared bucket’s CORS policy is not Perform’s to change) and neither Node process may hold a whole video. The browser slices the file, each slice travels same-origin through the web app, and each one is streamed out to R2 without being kept.
Rules:
- A part is at most 12 MiB (
MAX_MEDIA_PART_BYTES). The route mounts its ownexpress.rawparser with a 12 MB limit. S3 requires every part but the last to be at least 5 MiB. partNumberis 1 to 10000. A manifest holds at most 256 parts (MAX_PARTS_PER_UPLOAD).- Parts can be uploaded concurrently and reported in any order.
completeMultipartUploadsorts them, because S3 rejects a manifest that is not in ascending order. - The part’s metadata rides in the query string because the body is raw bytes. That also puts it inside the signed request path of the web lane.
- An abandoned multipart upload keeps its parts, and their storage cost, until aborted. The browser calls
abortwhen it gives up.
Key ownership check
The key is minted bystart, then travels through the browser and comes back on every later call. By then it is caller-controlled. assertOwnMediaKey runs on part, complete and abort:
- The key must start with
<scope>/<studioId>/for one of the media scopes. - The rest must match
[A-Za-z0-9._-]+and must not start with a dot. A prefix test alone would let a path such ascontent/<studio>/../<other>/x.mp4through.
FORBIDDEN, “media key does not belong to this studio”. Without this a studio could point its parts at another studio’s prefix and overwrite their media.
4. Trainee uploads
POST /v1/trainee/uploads, trainee bearer auth.
- The body is the raw file. The route mounts
express.rawwith a 15 MB limit and accepts any content type. x-file-typecarries the MIME type, defaultapplication/octet-stream.x-file-namecarries the file name, defaultupload.- An empty body is
BAD_REQUEST, “no file provided”. A body over the limit is rejected by the parser with HTTP 413, which the app shows as “too large”. - Returns
{ fileUrl }.
uploads.service.ts detects a HEIC file by its header and converts it to JPEG with heicToJpeg (storage/heic.ts, longest edge capped at HEIC_JPEG_MAX_EDGE, 1600 pixels). If conversion fails the original is stored, so the upload is never lost.
The extension is taken from the file name when it is 1 to 8 alphanumeric characters, otherwise from the MIME type, otherwise bin.
5. Avatars
uploadObject in packages/storage puts the object under avatars/ and returns publicAvatarUrl(path). It throws if R2_PUBLIC_BASE_URL is missing. getSignedUploadUrl in the same package creates a 60 second presigned PUT fixed to image/png.
Server-side helpers
createR2Storage also has helpers for pipelines that keep their own working set, used by the AutoFit import:
Things to keep in mind
- The bucket serves its keys publicly. Anything sensitive must be encrypted before upload. The AutoFit import does this: its raw pulls are full exports of personal data, so each object is sealed with AES-GCM and the whole prefix is deleted when the run finishes.
- Object keys are random, not guessable, but a URL that leaks is readable by anyone. Do not put anything in a key that should not be public.
- The only object deletion in the storage client is
deletePrefix. No cleanup of an object when its database row is deleted was found, so assume removed media stays in the bucket. - When mirroring a file from a third-party URL, follow
autofit-import/media-mirror.ts:httpson a public host only, the same rule applied after redirects, a content type check, and a streamed body with a hard size cap.