GenerateSaaS

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 }
KeyTypeDefaultDescription
enabledbooleantrueMaster switch. When false, uploads 503 and UI hides.
provider"s3" | "local""s3"Storage backend.
maxFileSizeMBnumber5Per-upload cap; exceeding returns 413.
dailyUploadLimitnumber20Per-user uploads per UTC day; exceeding returns 429.

Credentials live in environment variables, not config - see Environment variables.

Providers

ProviderBacksClientPublic URL source
s3AWS S3, R2, MinIO, Backblaze (any S3-compatible)s3miniSTORAGE_PUBLIC_URL (CDN), else the endpoint
localLocal filesystem diskNode fsSTORAGE_LOCAL_PUBLIC_URL, served at GET /storage/files/*
  • s3 reads STORAGE_REGION, STORAGE_ENDPOINT, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY, STORAGE_PUBLIC_BUCKET, STORAGE_PRIVATE_BUCKET, plus optional STORAGE_PUBLIC_URL (the public bucket's CDN or custom domain).
  • local reads STORAGE_LOCAL_PATH (default ../../data) and optional STORAGE_LOCAL_PUBLIC_URL; GET /storage/files/* serves only the public/ prefix.
  • Path safety is shared. validatePathComponent in packages/storage/src/path-safety.ts blocks 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_BUCKET holds avatars and assets. Give it a custom domain or r2.dev URL so they are anonymously readable, and point STORAGE_PUBLIC_URL at that base.
  • STORAGE_PRIVATE_BUCKET holds private uploads. Leave it credentials-only; the app reads them back through readFile() behind your own auth check.
  • Public objects carry a public/ key prefix, which also tells deleteFile and readFile which 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:

CheckFailureNotes
Storage enabled503config.storage.enabled === false
Daily quota429Per-user; admins bypass
maxFileSizeMB413Per-upload byte cap
Allowed MIME type400jpeg, png, gif, webp only
Magic bytes400validateMagicBytes() confirms the bytes match the declared type, so a renamed file is rejected
Concurrent avatar upload409Mutex 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 to uploadAvatar() - 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 private and returns url: null. Send a visibility=public form field to land under the public/ 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:

LimitConfig keyRedis keyWindow / TTLCap
Per-IP burstcacheConfig.uploadRateLimitupload:rate:{ip}60s window10 / window (route constant)
Per-user dailycacheConfig.uploadDailyQuotaupload:quota:{userId}:{date}24h TTLconfig.storage.dailyUploadLimit (20)

checkDailyUploadQuota() increments the counter and compares it against dailyUploadLimit. See Caching for the Redis store wiring.

On this page