Browse documentation

Source: apps/server/services/storage. Two drivers behind one interface, plus a reservation store that makes presigned uploads safe.

Drivers

IStorageDriver (storage.types.ts) is implemented twice:

DriverFileNotes
Local filesystemlocal.driver.tsSTORAGE_DRIVER=local. getSignedUploadUrl() is not supported — presign is an S3-only flow
S3-compatibles3.driver.tsMinIO, AWS S3, Cloudflare R2. Presigned GET/PUT via @aws-sdk/s3-request-presigner

Common surface: put, delete, getObject, headObject, getSignedDownloadUrl, getSignedUploadUrl.

TTLValue
Download URL3600 s
Upload URL900 s

Direct upload

POST /api/files  (multipart)
  → validateUploadSize   0 … MAX_UPLOAD_BYTES
  → mimeAllowed          bucket allowlist, empty = allow all, supports "type/*"
  → exactSizeStream      streams while counting bytes
  → driver.put

exactSizeStream (sized-stream.ts) is the enforcement point, not a check before the fact: it counts bytes while streaming, aborts the moment the declared size is exceeded, and rejects a stream that ends short:

Upload exceeded its declared size of <n> bytes
Upload ended at <m> bytes; expected <n>

It is an async generator wrapped with Readable.from, so normal Node backpressure is retained — a large upload never buffers in memory to be measured.

validateUploadSize requires a safe integer in [0, MAX_UPLOAD_BYTES] (MAX_UPLOAD_MB, default 25). Per-bucket limits from the storage.limits group are clamped to that ceiling.

Presigned upload: the reservation state machine

upload-reservation.store.ts exists because a presigned PUT bypasses the API entirely — the object appears in the bucket without the server seeing a byte. The database therefore tracks the intent:

reserve ──▶ File(status: pending, uploadExpiresAt)
   │
   ├── finalize ──▶ claim (pending → finalizing) ──▶ headObject ──▶ ready
   │                                   │
   │                                   └── failure ──▶ revert
   ├── activate ──▶ ready              (direct upload path)
   ├── release  ──▶ row removed
   └── expiry   ──▶ cleanupExpired removes row + object

Key properties, all deliberate:

  • Claims commit before storage I/O. The database lock is held only for the state transition, never across a network round trip to S3.
  • Conditional transitions mean finalize and the cleanup sweep can never act on the same reservation — one of them loses the claim.
  • finalize verifies the object with headObject and records the real size and MIME, so a client cannot lie about what it uploaded.
  • A failed finalize reverts the row instead of leaving it stuck in finalizing.

The FileStatus enum (pending, finalizing, deleting, ready) is exactly this machine, and @@index([status, uploadExpiresAt]) is what makes the sweep cheap.

Quotas

Bucket.quotaBytes is enforced inside the reservation transaction: the bucket row is locked (lockBucket), writability is asserted (assertBucketWritable), and the reservation is rejected when it would exceed the quota. Locking the bucket is what prevents two concurrent uploads from each seeing room for the last megabyte.

Cleanup

StorageCleanupSchedulerService runs */5 * * * * and calls cleanupExpired, limit 100 per pass, removing both the row and the orphaned object. Without it, a presign that was never finalized would hold quota forever.

Deletion

Deleting a bucket with objects creates a BucketDeletionJob rather than blocking the request — leased, batched, resumable, and queryable after the bucket is gone. See background jobs.

Exposure

Only S3_PUBLIC_BUCKET is served statically by Express. Private objects are reachable exclusively through the authenticated endpoints (/api/files/:id/raw, /api/files/:id/url) or a scoped bucket token (/api/storage/:slug/…). Per-bucket CORS is applied by bucketCors.middleware.ts, independent of the project-wide CORS group.

Storage events are delivered to per-bucket webhooks (BucketWebhook), and failures are logged with source: 'storage'.

User-facing view: Storage and the Studio storage screen.