Overview
Architecture of the file upload and storage system — drivers, folder structure, and the full upload flow.
Uploads & Storage — Overview
Audience: backend, devops Scope:
packages/storage,apps/api/src/modules/upload,apps/api/src/utils/multer.ts
Architecture
The storage system uses a local-first, remote-preferred pattern. Files always land on local disk first (via Multer), then the StorageManager pushes them to the configured remote driver and cleans up the local copy on success.
Driver System
The active driver is resolved at startup from the STORAGE_DRIVER env var, or detected automatically from which credentials are present.
STORAGE_DRIVER | Behaviour |
|---|---|
local | Always write to local disk, never attempt remote |
bucket | Always use S3/MinIO — throws on startup if bucket config is missing |
auto (default) | Uses bucket if credentials are present, otherwise local |
autoin production — always setSTORAGE_DRIVER=bucketexplicitly in production.autois for local development where bucket credentials may not be available.
File Validation
Two layers of validation run before a file is accepted (added in Subphase 9 of the auth-rbac-security-hardening initiative):
fileFilter(Multer,apps/api/src/utils/multer.ts) — rejects a file at upload time if its extension or client-claimed MIME type isn't in the allowlist for the resolvedUploadType. This is a fast, first-pass check — it runs before any bytes are written to disk.- Magic-byte verification (
StorageManager.handleUpload(),packages/storage) — after the file lands on disk, its actual byte signature is sniffed (viafile-type) and compared against the same allowlist, ignoring the client-claimed MIME type entirely. A file that passes thefileFiltercheck by lying about its extension/MIME (e.g. an executable renamed to.png) is caught here and rejected with400 Bad Request; the temporarily-written local file is deleted before the error propagates.
Both layers share one allowlist (packages/storage/src/upload-allowlist.ts) so they can never drift apart.
Virus/malware scanning is explicitly NOT implemented. A validly-typed file (correct magic bytes, correct extension) that still carries a malicious payload will not be caught by either layer above. This is a known, deliberately-deferred gap — see the Subphase 9 handoff for the reasoning (no existing scanning infrastructure in this deployment; adding it is an infra/ops decision, not a code point-fix).
Folder Structure
Files are routed to different sub-folders based on UploadType, which determines both the local path Multer writes to and the S3 object key prefix.
{uploadRoot}/ ← local disk (UPLOAD_LOCATION env)
bucket/ ← MinIO bucket (STORAGE_BUCKET_NAME env)
├── public/ ← publicly readable without authentication
│ ├── avatar/ ← user profile pictures
│ ├── landing/ ← landing page banners / hero images
│ └── thumbnail/ ← video / content thumbnails
└── uploads/ ← private — signed URLs only, ownership-checked
├── image/{userId}/ ← general private images, scoped per uploader
├── video/{userId}/ ← private videos, scoped per uploader
└── file/{userId}/ ← documents, scoped per uploaderOwnership scoping applies only to newly-uploaded private files. Files uploaded before Subphase 9 shipped have no
{userId}segment in their key and are grandfathered through with no ownership check (a warning is logged on access) — see API Reference for the access-control behavior this implies.
The split is enforced in two places:
- Multer destination (
apps/api/src/utils/multer.ts) — readsreq.query.uploadTypeand writes to the correct local sub-folder at request time. - S3StorageDriver (
packages/storage/src/drivers/s3-driver.ts) — derives the S3 key from the local path, preserving thepublic/oruploads/prefix.
Bucket Policy
On every app startup, UploadService.onModuleInit() calls StorageManager.applyBucketPolicy(), which applies a MinIO bucket policy that:
- Allows anonymous
s3:GetObjectonpublic/*— direct URLs work without any credentials. - Leaves
uploads/**fully private — only accessible via short-lived pre-signed URLs.
The policy only adds
Allow s3:GetObjectforpublic/*. There is no explicit Deny for writes — anonymous writes are denied by default in MinIO when no explicit Allow exists for them.
Signed URL Generation
Pre-signed URLs are generated using HMAC-SHA256 locally — no network round-trip to MinIO is required. The SDK signs the URL on the server and returns it to the client.
For public files (public/* prefix), no signing is needed — the API returns the direct CDN/bucket URL immediately.
Optional: Read-only Credential Separation
For production, configure a second MinIO service account with only s3:GetObject rights and set STORAGE_BUCKET_READONLY_* credentials. The upload module will use this read-only account exclusively for signed URL generation. Even if a signed URL leaks, it cannot be used to upload or delete files.
If
STORAGE_BUCKET_READONLY_ACCESS_KEYis not set, the primaryStorageManageris used for signing instead — functionally identical, just without the credential isolation.