File storage
Overview
File storage is provider-agnostic behind the abstraction in src/lib/storage/.
It targets AWS S3 or Cloudflare R2 (any S3-compatible service), chosen
in src/config/storage.config.ts.
STORAGE_PROVIDER=r2 # "s3" | "r2"
STORAGE_BUCKET=my-bucket
STORAGE_REGION=auto
STORAGE_ENDPOINT=https://<account>.r2.cloudflarestorage.com # R2 / non-AWS
STORAGE_ACCESS_KEY_ID=...
STORAGE_SECRET_ACCESS_KEY=...
Private by default
The bucket is expected to be private. Nothing is served by public-read; every download goes through a short-lived signed URL. Don't enable public-read on the bucket — it would bypass the access checks below.
The API
The storage library exposes a small, typed surface:
| Function | Purpose |
|---|---|
getUploadUrl() | Signed PUT URL for direct browser → bucket uploads |
getSignedReadUrl() | Short-lived signed GET URL for one object |
getSignedReadUrls() | Batch variant |
putObject() | Server-side upload |
getObjectBuffer() | Read an object into memory |
deleteObject() / deleteObjectsByPrefix() | Delete one / many |
Typical browser upload flow: request a signed upload URL from the server, PUT the file straight to the bucket, then store the returned key on your record.
Built-in uploads
Avatar and logo uploads are already wired, with their own route handlers:
POST /api/storage/avatar— user avatarPOST /api/storage/logo— organization logoGET /api/storage/image— the read proxy
These route handlers call checkRouteRateLimit() since they bypass tRPC, and
validate the upload (type/size) before signing.
Image reads are ownership-checked
The image proxy (/api/storage/image) doesn't just require a session — it
authorizes each read via storage.authorizeImageRead. A user may read:
- their own avatar,
- avatars of users sharing an organization with them,
- member-visible organization logos,
- anything, if they're a platform admin.
Unguessable UUID keys and the session requirement are defense in depth on top of this check, not the control itself.