@tony4ork/nest-upload
v0.3.1
Published
Shared upload module for NestJS: presigned URLs, upload through the backend, validation, key generation — with no dependency on a specific provider (S3/R2/MinIO, GCS, Local, Memory).
Readme
@tony4ork/nest-upload
Shared upload module for NestJS: presigned URLs, upload through the backend, MIME/size validation, safe key generation — with no dependency on a specific provider. Switching storage (S3 ↔ GCS ↔ Local ↔ Memory) only requires changing the object passed to storage:, no controller/service business logic changes needed.
UploadModule/UploadService: shared logic, exported from the root subpath@tony4ork/nest-upload.- 4 built-in providers, each on its own subpath so unnecessary SDKs don't get pulled into the bundle:
@tony4ork/nest-upload/s3— S3, compatible with Cloudflare R2 / MinIO / DigitalOcean Spaces.@tony4ork/nest-upload/gcs— Google Cloud Storage.@tony4ork/nest-upload/local— writes to the local disk, for dev only.@tony4ork/nest-upload/testing—MemoryStorageProvider+ a shared contract test suite, for unit tests.
Installation
# pnpm
pnpm add @tony4ork/nest-upload
# yarn
yarn add @tony4ork/nest-upload
# npm
npm install @tony4ork/nest-uploadOptional peer dependencies, only needed for the provider(s) you actually use:
# Only if you use the S3 provider:
pnpm add @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
# Only if you sign download URLs for a private CloudFront distribution (see below):
pnpm add @aws-sdk/cloudfront-signer
# Only if you use the GCS provider:
pnpm add @google-cloud/storage@nestjs/common (^10.0.0 || ^11.0.0) is a required peer dependency. The Local and Memory providers need nothing extra installed.
Quick start
- Install the package (and the SDK for the provider you plan to use, see above).
- Create an integration file that builds
UploadModule.forRoot()/forRootAsync()with your chosenstorageprovider (examples below, one per provider). - Import that module in
AppModule. - Inject
UploadServicein a controller/service and callcreatePresignedUpload,upload,confirmUpload,getDownloadUrl, ordelete— see Usage flows below.
Configuration
S3 (production) — forRootAsync with ConfigService
// src/integrations/upload.integration.ts
import { ConfigService } from '@nestjs/config';
import { UploadModule } from '@tony4ork/nest-upload';
import { S3StorageProvider } from '@tony4ork/nest-upload/s3';
export const UploadIntegration = UploadModule.forRootAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
storage: new S3StorageProvider({
bucket: config.getOrThrow('UPLOAD_S3_BUCKET'),
clientConfig: {
region: config.get('UPLOAD_S3_REGION', 'auto'),
endpoint: config.get('UPLOAD_S3_ENDPOINT'), // leave unset to use real AWS S3
forcePathStyle: config.get('UPLOAD_S3_FORCE_PATH_STYLE') === 'true',
// Omit credentials to let the SDK use the default credential chain (IAM role).
},
publicBaseUrl: config.get('UPLOAD_S3_PUBLIC_BASE_URL'), // CDN/CloudFront, leave unset if the bucket is private
}),
keyPrefix: `${config.get('NODE_ENV')}/${config.getOrThrow('SERVICE_NAME')}`,
presignExpiresIn: 300,
maxFileSize: 20 * 1024 * 1024, // 20MB
allowedMimeTypes: ['image/*', 'application/pdf'],
}),
});// app.module.ts
@Module({ imports: [UploadIntegration, /* ... */] })
export class AppModule {}Cloudflare R2 / MinIO / DigitalOcean Spaces
S3StorageProvider uses @aws-sdk/client-s3, so it works with any S3-compatible service — just set endpoint + forcePathStyle: true:
new S3StorageProvider({
bucket: 'my-bucket',
clientConfig: {
region: 'auto', // R2 uses 'auto'; MinIO/Spaces accept any region, e.g. 'us-east-1'
endpoint: 'https://<account-id>.r2.cloudflarestorage.com', // or http://localhost:9000 for MinIO
forcePathStyle: true, // required for MinIO; also recommended for R2/Spaces
credentials: { accessKeyId: '...', secretAccessKey: '...' },
},
});Private CloudFront distribution (signed URLs)
If the CloudFront distribution in front of the bucket is private (Origin Access Control + a trusted key group, no public read), pass cloudfrontSigner with a CloudFront key pair. This changes getDownloadUrl's behavior: getPublicUrl always returns undefined, so it falls back to presignDownload, which now signs publicBaseUrl (the CloudFront domain) instead of the S3 bucket directly:
pnpm add @aws-sdk/cloudfront-signernew S3StorageProvider({
bucket: config.getOrThrow('UPLOAD_S3_BUCKET'),
clientConfig: { region: config.get('UPLOAD_S3_REGION') },
publicBaseUrl: config.getOrThrow('UPLOAD_CLOUDFRONT_DOMAIN'), // e.g. https://dxxxx.cloudfront.net, required
cloudfrontSigner: {
keyPairId: config.getOrThrow('UPLOAD_CLOUDFRONT_KEY_PAIR_ID'), // id of the public key registered in CloudFront
privateKey: config.getOrThrow('UPLOAD_CLOUDFRONT_PRIVATE_KEY'), // PEM private key paired with it — keep this secret
},
});Only downloads are signed this way — uploads still go straight to S3 via createPresignedUpload/upload, which is standard practice (CloudFront isn't meant to sit in front of writes). Generate the CloudFront key pair (RSA, PEM) and register the public key + a key group on the distribution in the AWS console/CLI first; the private key never leaves your backend.
GCS
// src/integrations/upload.integration.ts
import { ConfigService } from '@nestjs/config';
import { UploadModule } from '@tony4ork/nest-upload';
import { GcsStorageProvider } from '@tony4ork/nest-upload/gcs';
export const UploadIntegration = UploadModule.forRootAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
storage: new GcsStorageProvider({
bucket: config.getOrThrow('UPLOAD_GCS_BUCKET'),
storageOptions: {
projectId: config.get('UPLOAD_GCS_PROJECT_ID'),
// Omit credentials to let the SDK use Application Default Credentials
// (a service account attached to GKE/Cloud Run/GCE, or GOOGLE_APPLICATION_CREDENTIALS).
},
publicBaseUrl: config.get('UPLOAD_GCS_PUBLIC_BASE_URL'), // CDN/public bucket, leave unset if the bucket is private
}),
keyPrefix: `${config.get('NODE_ENV')}/${config.getOrThrow('SERVICE_NAME')}`,
allowedMimeTypes: ['image/*', 'application/pdf'],
}),
});Presigned GCS URLs (V4 signing) require the service account's client_email + private_key to self-sign locally — use a JSON key file via GOOGLE_APPLICATION_CREDENTIALS, or pass storageOptions.credentials explicitly if running on an environment without ADC support (e.g. some non-GCP PaaS using Workload Identity Federation).
Local (dev)
import { LocalStorageProvider } from '@tony4ork/nest-upload/local';
UploadModule.forRoot({
storage: new LocalStorageProvider({ rootDir: './uploads', publicBaseUrl: 'http://localhost:3000/uploads' }),
});Test (Jest)
import { Test } from '@nestjs/testing';
import { UploadModule } from '@tony4ork/nest-upload';
import { MemoryStorageProvider } from '@tony4ork/nest-upload/testing';
const moduleRef = await Test.createTestingModule({
imports: [UploadModule.forRoot({ storage: new MemoryStorageProvider() })],
}).compile();isGlobal
forRoot/forRootAsync accept an extra isGlobal?: boolean (defaults to false) to register UploadModule as a global module, so it doesn't need to be re-imported in every feature module.
Usage flow: presign → client upload → confirm
The client uploads directly to storage (bypassing the backend), which suits large files.
@Controller('avatars')
export class AvatarController {
constructor(private readonly uploadService: UploadService) {}
@Post('presign')
async presign(@Body() dto: PresignAvatarDto) {
// { folder: 'avatars', fileName, contentType, size } — the key is generated server-side, never from the original fileName.
return this.uploadService.createPresignedUpload({
folder: 'avatars',
fileName: dto.fileName,
contentType: dto.contentType,
size: dto.size,
});
// -> { key, expiresIn, url, method: 'PUT', headers: { 'Content-Type': ... } }
}
@Post('confirm')
async confirm(@Body('key') key: string) {
// Re-validates the actual size/contentType on storage; on mismatch, deletes the object and throws.
return this.uploadService.confirmUpload(key);
// -> { key, size, contentType, publicUrl? }
}
}The client must use the returned method/headers/fields as-is to upload (never guess them):
const presigned = await api.post('/avatars/presign', { fileName, contentType, size });
await fetch(presigned.url, { method: presigned.method, headers: presigned.headers, body: file });
await api.post('/avatars/confirm', { key: presigned.key });Usage flow: upload through the backend
The file goes through the server first, which suits cases where extra processing is needed (per-row auth, antivirus at another layer, etc.) or small files.
@Controller('avatars')
export class AvatarController {
constructor(private readonly uploadService: UploadService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
async upload(@UploadedFile() file: Express.Multer.File) {
return this.uploadService.upload({
folder: 'avatars',
fileName: file.originalname,
contentType: file.mimetype,
size: file.size,
body: file.buffer, // or a stream if using a Multer storage engine other than memory
});
// -> { key, size, contentType, publicUrl? }
}
}If body is a Buffer, size can be omitted — upload() derives it from the buffer's length so maxFileSize still applies. If body is a stream (e.g. a disk-storage Multer engine) and size isn't passed, maxFileSize cannot be enforced before the data is already written to storage — always also set limits: { fileSize } on FileInterceptor/Multer so oversized uploads are rejected before they reach UploadService.
API
| Method | Description |
|---|---|
| createPresignedUpload({ folder, fileName, contentType, size? }) | Validates → generates a key → returns the URL/headers for the client to PUT directly (or POST a form). Throws UnsupportedOperationError if the provider doesn't support presign. |
| confirmUpload(key) | Checks that the object exists and matches the declared size/contentType. On mismatch, deletes the object then throws. Throws UploadNotFoundError if it doesn't exist. |
| upload({ folder, fileName, contentType, size?, body }) | Validates → generates a key → writes directly through the backend. |
| getDownloadUrl(key, expiresIn = 300) | Prefers getPublicUrl (if the provider is configured with a public URL), otherwise falls back to presignDownload. Throws UnsupportedOperationError if neither is available. |
| delete(key) | Deletes the object, idempotent. |
Options (UploadModuleOptions)
| Option | Default | Notes |
|---|---|---|
| storage | (required) | An instance implementing StorageProvider. |
| keyPrefix | — | Prepended to every key, e.g. prod/order-service. |
| presignExpiresIn | 300 | Seconds, used when expiresIn isn't passed explicitly. |
| maxFileSize | unlimited | Bytes. When set, createPresignedUpload requires size to be passed (throws UploadValidationError otherwise) — without it, the limit can't be enforced before the client uploads straight to storage. |
| allowedMimeTypes | unlimited | Supports wildcards like 'image/*'. |
| keyStrategy | default key generator | Overrides how keys are generated, receives { folder, fileName, contentType } with an already-sanitized folder. |
Key generation
Default: {keyPrefix}/{folder}/{yyyy}/{mm}/{uuidv4}.{ext} (UTC). The original file name is never used. folder only allows [a-z0-9-_/], must not be empty, must not contain .., and must not start/end with / — violations throw UploadValidationError. If the file's extension doesn't match contentType (e.g. .exe with image/png), the key uses the extension derived from contentType instead.
Errors
| Error | HTTP | When |
|---|---|---|
| UploadValidationError | 400 | Invalid MIME/size/folder. |
| UploadNotFoundError | 404 | confirmUpload called on a key that doesn't exist. |
| UnsupportedOperationError | 501 | The current provider doesn't support the operation (e.g. Local has no presign). |
| StorageProviderError | — | The provider's original SDK error, wrapped (the raw error never leaks out). Carries provider, operation, cause. |
UploadValidationError/UploadNotFoundError/UnsupportedOperationError all extend Nest's HttpException, so they can be used directly without a separate catch.
Writing a new provider (Azure Blob...)
- Implement the
StorageProviderinterface (imported from@tony4ork/nest-upload):import { StorageProvider, PutObjectInput, ObjectMeta, PresignUploadInput, PresignedUpload } from '@tony4ork/nest-upload'; export class AzureBlobStorageProvider implements StorageProvider { readonly name = 'azure-blob'; async put(input: PutObjectInput): Promise<void> { /* ... */ } async head(key: string): Promise<ObjectMeta | null> { /* return null if it doesn't exist, do NOT throw */ } async delete(key: string): Promise<void> { /* idempotent */ } // presignUpload / presignDownload / getPublicUrl: optional, only implement if the service supports it. } - Wrap the original SDK error with
StorageProviderError(name, operation, cause); never let the raw SDK error leak out. - Run the shared contract test suite to make sure the provider behaves correctly per the
StorageProvidercontract:import { runStorageProviderContractTests } from '@tony4ork/nest-upload/testing'; runStorageProviderContractTests('azure-blob', () => new AzureBlobStorageProvider({ /* ... */ })); - No changes needed in
UploadModule/UploadService— just pass the new instance tostorage:when configuring the module. Seesrc/providers/s3/orsrc/providers/gcs/for reference (file layout, error wrapping, how to write a provider-specific contract test intest/contract/ortest/integration/).
Known limitations
contentTypeis trusted, not verified against the actual bytes.allowedMimeTypesand the key's extension are both derived from thecontentTypethe caller/client declares, not from sniffing the file's magic bytes. For S3/GCS presigned uploads, the signature does bind the client to sending that exactContent-Typeheader, but nothing confirms the body itself is really that format (e.g. a renamed executable sent asimage/pngwith a matching header still passes). If stricter guarantees are needed, add magic-byte sniffing (e.g. viafile-type) inconfirmUpload's business-layer caller, or scan the object after upload.
Roadmap (not yet done)
- Azure Blob provider (the interface is already ready for it, see above).
- Multiple storages at once in the same app (named disks, e.g.
disk: 'avatars'vsdisk: 'documents'using two different providers). Currently: register multipleUploadModule.forRootAsynccalls with different DI tokens yourself if needed — the package doesn't support this out of the box yet. - Saving metadata to a DB, image resizing/processing, virus scanning: out of scope for this package — handle these at your own business layer.
Test
pnpm test # unit + contract (Memory, Local) — no Docker required
pnpm test:cov # with coverage (core ≥ 90%)
docker compose -f docker-compose.test.yml up -d
pnpm test:integration # contract + real presign flow for S3 (via S3Mock) and GCS (via fake-gcs-server)
docker compose -f docker-compose.test.yml downdocker-compose.test.yml uses S3Mock instead of MinIO because the official MinIO image is no longer publicly pullable from Docker Hub (MinIO changed its distribution policy in 2024), and fake-gcs-server for GCS. To run against a real backend instead:
- S3: set
S3_TEST_ENDPOINT,S3_TEST_REGION,S3_TEST_ACCESS_KEY_ID,S3_TEST_SECRET_ACCESS_KEY,S3_TEST_BUCKET(works with MinIO/LocalStack/real S3). - GCS: set
GCS_TEST_API_ENDPOINT,GCS_TEST_PROJECT_ID,GCS_TEST_BUCKET.
test:integration runs with NODE_OPTIONS=--experimental-vm-modules (already set in the script) because @google-cloud/storage uses an internal dynamic import() that Jest's CJS VM blocks without this flag — no need to worry about this if you only run pnpm test.
