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: public files get a URL; private files (the default) 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,STORAGE_BUCKET_NAME, plus optionalSTORAGE_PUBLIC_URL(CDN) andSTORAGE_FORCE_PATH_STYLE(MinIO/path-style buckets).localreadsSTORAGE_LOCAL_PATH(default../../data) and optionalSTORAGE_LOCAL_PUBLIC_URL;LocalProviderserves only thepublic/prefix and blocks path-traversal, absolute-path, and null-byte attacks.
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.