File Storage
Upload avatars and files to S3 or local disk with per-user quotas, per-IP rate limiting, and magic-byte validation, gated by config.storage.
@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.
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. |
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), else the endpoint |
local | Local filesystem disk | Node fs | STORAGE_LOCAL_PUBLIC_URL, served at GET /storage/files/* |
s3readsSTORAGE_REGION,STORAGE_ENDPOINT,STORAGE_ACCESS_KEY_ID,STORAGE_SECRET_ACCESS_KEY,STORAGE_PUBLIC_BUCKET,STORAGE_PRIVATE_BUCKET, plus optionalSTORAGE_PUBLIC_URL(the public bucket's CDN or custom domain).localreadsSTORAGE_LOCAL_PATH(default../../data) and optionalSTORAGE_LOCAL_PUBLIC_URL;GET /storage/files/*serves only thepublic/prefix.- Path safety is shared.
validatePathComponentinpackages/storage/src/path-safety.tsblocks path-traversal, absolute-path, and null-byte attacks for both providers.
The local provider writes through Node fs, so it is not viable on read-only or ephemeral filesystems (most serverless hosts) - use provider: "s3" there, see Vercel deployment. Switching later is a config flip plus the STORAGE_* vars: old keys keep their paths, and deleteFileFromAllProviders() cleans up across both during the transition.
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 avatars and assets. Give it a custom domain orr2.devURL so they are anonymously readable, and pointSTORAGE_PUBLIC_URLat that base.STORAGE_PRIVATE_BUCKETholds private uploads. Leave it credentials-only; the app reads them back throughreadFile()behind your own auth check.- Public objects carry a
public/key prefix, which also tellsdeleteFileandreadFilewhich bucket a key belongs to.
Never make the private bucket public. On Cloudflare R2 a custom domain exposes the whole bucket, so keeping public and private separate is what keeps private uploads private.
Local development
pnpm infra runs a MinIO container (S3-compatible) beside Postgres and Redis, and a one-shot init creates the public and private buckets and makes the public one anonymously readable. The .env.example STORAGE_* defaults already point at it, so cp .env.example .env && pnpm infra gives working uploads with no external S3 account. Swap the STORAGE_* vars for your provider (R2, S3, Backblaze) on deploy.
Upload endpoint
POST /storage/upload is guarded by authGuard and a per-IP rate limiter (10 requests / 60s). It accepts multipart/form-data with a file field plus an 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, so a renamed file is rejected |
| Concurrent avatar upload | 409 | Mutex already held for this user |
The desktop app uses the same route: its renderer hands the file's bytes to the main process over IPC, which posts the multipart request under the device's bearer token - the text-only request bridge cannot carry a file.
folder: "avatars"routes touploadAvatar()- per-user mutex, atomic DB swap, rollback on DB failure. Avatars are the always-public case. Any other folder lands under{userId}/{folder}.- Every other endpoint upload defaults to
privateand returnsurl: null. Send avisibility=publicform field to land under thepublic/prefix and get a served URL. - Programmatically,
uploadFile(file, filename, contentType, { folder, visibility })follows the same default -visibility: "private"unless you pass"public". - Avatars are resized and center-cropped to 256x256 WebP on the client, so no server-side image library runs in the request path.
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.