File Storage
Upload avatars and files to S3 or local disk with quotas, rate limiting, and magic-byte validation.
@repo/storage handles file uploads behind one provider-agnostic API, gated by config.storage.enabled in packages/config/src/index.ts. When enabled it persists files to an S3-compatible bucket or the local filesystem; when off, isStorageEnabled() returns false, POST /storage/upload responds 503, and upload UI is hidden.
What you get
- Avatar uploads:
uploadAvatar()with per-user mutex, atomic swap, and rollback on DB failure. - Quotas: per-user daily upload cap (
429over limit; admins bypass). - Rate limiting: per-IP burst limit on the upload endpoint.
- MIME + magic-byte validation: only real
jpeg/png/gif/webppass; renamed files are rejected. - Public vs private: two buckets. Public files (avatars, assets) go to the public bucket and get a URL; private files (the default) go to the private bucket and return
url: null.
Configuration
config.storage is a discriminated union - { enabled: false } or the full shape. The shipped default is on:
storage: { enabled: true, provider: "s3", maxFileSizeMB: 5, dailyUploadLimit: 20 }| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master switch. When false, uploads 503 and UI hides. |
provider | "s3" | "local" | "s3" | Storage backend (see below). |
maxFileSizeMB | number | 5 | Per-upload cap; exceeding returns 413. |
dailyUploadLimit | number | 20 | Per-user uploads per UTC day; exceeding returns 429. |
Credentials live in environment variables, not config - see Environment variables.
Providers
| Provider | Backs | Client | Public URL source |
|---|---|---|---|
s3 | AWS S3, R2, MinIO, Backblaze (any S3-compatible) | s3mini | STORAGE_PUBLIC_URL (CDN) → endpoint |
local | local filesystem disk | Node fs | STORAGE_LOCAL_PUBLIC_URL → GET /storage/files/* |
s3readsSTORAGE_REGION,STORAGE_ENDPOINT,STORAGE_ACCESS_KEY_ID,STORAGE_SECRET_ACCESS_KEY, and two buckets -STORAGE_PUBLIC_BUCKET(publicly readable: avatars, assets) andSTORAGE_PRIVATE_BUCKET(credentials-only: private uploads) - plus optionalSTORAGE_PUBLIC_URL(the public bucket's CDN or custom domain).localreadsSTORAGE_LOCAL_PATH(default../../data) and optionalSTORAGE_LOCAL_PUBLIC_URL;LocalProviderserves only thepublic/prefix and blocks path-traversal, absolute-path, and null-byte attacks.
Public and private buckets
The s3 provider uses two buckets so the bucket boundary - not a key prefix - is the privacy guarantee:
STORAGE_PUBLIC_BUCKETholds public files (avatars, assets). Give it a custom domain orr2.devURL so the files are anonymously readable, and pointSTORAGE_PUBLIC_URLat that base.STORAGE_PRIVATE_BUCKETholds private uploads. Leave it credentials-only; the app reads those files back throughreadFile()behind your own auth check.
Public objects are stored under a public/ key prefix, which also tells deleteFile/readFile which bucket a key belongs to. Don't make the private bucket public: on Cloudflare R2 a custom domain exposes the whole bucket, so keeping public and private in separate buckets is what keeps private uploads private.
The local provider writes to disk via Node fs, so it is not viable on platforms with read-only or ephemeral filesystems (most serverless hosts). Use provider: "s3" there - see Vercel deployment.
Upload endpoint
The shared backend exposes POST /storage/upload, guarded by authGuard and a per-IP rate limiter (10 requests / 60s). It accepts multipart/form-data with a file field plus optional folder, then enforces, in order:
| Check | Failure | Notes |
|---|---|---|
| Storage enabled | 503 | config.storage.enabled === false |
| Daily quota | 429 | Per-user; admins bypass |
maxFileSizeMB | 413 | Per-upload byte cap |
| Allowed MIME type | 400 | jpeg, png, gif, webp only |
| Magic bytes | 400 | validateMagicBytes() confirms the bytes match the declared type |
| Concurrent avatar upload | 409 | Mutex already held for this user |
A folder of avatars routes to uploadAvatar() (per-user mutex + atomic DB swap); any other folder lands under {userId}/{folder}. Endpoint uploads are always public - they return a served URL.
Programmatically, uploadFile(file, filename, contentType, { folder, visibility }) defaults to visibility: "private" (returns url: null); pass "public" to land under a public/ prefix and get a URL.
Rate limiting and quotas
Two Redis-backed limits, keyed from cacheConfig in packages/config/src/cache.ts:
| Limit | Config key | Redis key | Window / TTL | Cap |
|---|---|---|---|---|
| Per-IP burst | cacheConfig.uploadRateLimit | upload:rate:{ip} | 60s window | 10 / window (route constant) |
| Per-user daily | cacheConfig.uploadDailyQuota | upload:quota:{userId}:{date} | 24h TTL | config.storage.dailyUploadLimit (20) |
checkDailyUploadQuota() increments the counter and compares it against dailyUploadLimit. See Caching for the Redis store wiring.
Frequently asked questions
Why does uploadFile() return url: null?
The programmatic helper defaults to visibility: "private". Pass { visibility: "public" } to land under the public/ prefix and get a served URL. (The POST /storage/upload endpoint always uploads public, so it never returns null.)
Can I switch from local to s3 after launch?
Yes - flip config.storage.provider and set the STORAGE_* env vars. Old keys keep their paths; deleteFileFromAllProviders() cleans up across both during the transition.
How are images resized? Avatars are resized and center-cropped to 256x256 WebP on the client before upload, so no server-side image library runs in the request path.