Skip to content

File Storage ​

Grant uses file storage for user-facing uploads such as profile pictures. The storage layer follows the same adapter pattern as the rest of the platform — a port defined in @grantjs/core, with swappable adapters in @grantjs/storage.

Adapters ​

AdapterProvider valueBest for
LocallocalDevelopment, single-server deployments, Docker volumes, NFS mounts
S3s3Production, multi-server deployments, S3-compatible services (AWS S3, MinIO, DigitalOcean Spaces)

Switching adapters requires only an environment variable change — no code changes. Existing files must be migrated manually.

Configuration ​

Local storage ​

bash
STORAGE_PROVIDER=local
STORAGE_LOCAL_BASE_PATH=./storage    # or /app/storage in Docker

The path can point to any mounted filesystem (local directory, Docker volume, NFS mount).

S3 storage ​

bash
STORAGE_PROVIDER=s3
STORAGE_S3_BUCKET=my-bucket
STORAGE_S3_REGION=us-east-1
STORAGE_S3_ACCESS_KEY_ID=your-access-key
STORAGE_S3_SECRET_ACCESS_KEY=your-secret-key
STORAGE_S3_ENDPOINT=https://minio.example.com   # optional: S3-compatible endpoint
STORAGE_S3_PUBLIC_URL=https://cdn.example.com    # optional: CDN base URL

When STORAGE_S3_PUBLIC_URL is set, public file URLs use this base instead of the S3 bucket URL — useful for CloudFront or other CDN distributions.

Upload constraints ​

VariableDefaultDescription
STORAGE_UPLOAD_MAX_FILE_SIZE5242880 (5 MB)Maximum upload size in bytes

Allowed MIME types and file extensions are configured in env.config.ts. By default, only image types are permitted: image/jpeg, image/png, image/gif, image/webp.

Adapter Interface ​

Both adapters implement the IFileStorageService port from @grantjs/core:

typescript
interface IFileStorageService {
  upload(file: Buffer, path: string, options?: UploadOptions): Promise<UploadResult>;
  delete(path: string): Promise<void>;
  getUrl(path: string): Promise<string>;
  exists(path: string): Promise<boolean>;
  copy(sourcePath: string, destinationPath: string): Promise<void>;
  getUploadUrl(path: string, options: UploadUrlOptions): Promise<UploadUrlResult>;
  getMetadata(path: string): Promise<StoredObjectMetadata | null>;
  download(path: string, options: { maxBytes: number }): Promise<Buffer>;
}

getUploadUrl, getMetadata and download are required of every adapter, not optional: see ADR 0007 and ADR 0009. download reads a whole object into a Buffer. maxBytes is required and enforced inside the method: an object over it throws PayloadTooLargeError before it is buffered. It throws NotFoundError when nothing is stored at the path, and refuses the same malformed paths as getUploadUrl.

The API wraps this in a FileStorageService (apps/api/src/services/file-storage.service.ts) that adds validation (file type, extension, size) and base64 decoding before delegating to the adapter.

Upload Flow ​

When a user uploads a profile picture, the flow is:

  1. Client sends the file as a base64-encoded string with content type and filename
  2. FileStorageService.validateAndDecodeUpload() validates the MIME type, extension, and size, then decodes the base64 payload
  3. A storage path is generated: users/{userId}/picture.{ext}
  4. The adapter uploads the file and returns a public URL
  5. The user record is updated with the new pictureUrl

Organization logos use the same flow with path organizations/{organizationId}/picture.{ext} and persist the URL on organizations.picture_url.

Project logos use projects/{projectId}/picture.{ext}. Project-app logos use project-apps/{projectAppId}/picture.{ext}. Both write through mint / confirm / upload (or a clear action). UpdateProject / UpdateProjectApp do not accept pictureUrl. On read, Grant hydrates a public URL from the stored path.

Project membership pictures use users/{userId}/projects/{projectId}/picture.{ext}.

TIP

The adapter is instantiated once at startup via StorageFactory.createStorageService() and receives an ILoggerFactory via dependency injection — it never imports the logger directly.


Related:

Released under the MIT License.