Skip to main content
Localizia

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:

FunctionPurpose
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 avatar
  • POST /api/storage/logo — organization logo
  • GET /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.