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.
maxStoragePerUserMBnumberabsentTotal megabytes one user may hold; exceeding returns 413. Absent means no total cap. Avatars and admins are exempt.

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
maxStoragePerUserMB413Only when set; counted per 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

Three 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)
Per-user totalcacheConfig.storageUsageupload:bytes:{userId}no TTLconfig.storage.maxStoragePerUserMB (unset)

checkDailyUploadQuota() increments the counter and compares it against dailyUploadLimit. The total counter is written only while maxStoragePerUserMB is set, and is seeded from the provider's own listing the first time a user is measured - so switching the cap on counts the files they already stored. See Caching for the Redis store wiring.

Deleting an account

Deleting an account - by the user or by an administrator - sweeps its objects: the avatar first, then every object under {userId}/ and public/{userId}/, listed through listFiles(prefix) and deleted in batches. A backend that cannot list is logged with the prefix and walked past - the erasure itself never fails on storage.

On this page