├── .husky └── pre-commit ├── .lintstagedrc.json ├── eslint.config.js ├── prettier.config.js ├── lib ├── constants │ ├── svg-file-type.constant.ts │ ├── provider.constants.ts │ └── file.constant.ts ├── enums │ ├── image-format.enum.ts │ └── mime-type.enum.ts ├── types │ ├── blob-client.type.ts │ ├── image-with-thumbnail.type.ts │ ├── blob-storage-properties.type.ts │ ├── image-detail.type.ts │ ├── upload-file.type.ts │ ├── storage-options.type.ts │ ├── option-upload-file.type.ts │ ├── blob-upload-headers.type.ts │ ├── account.type.ts │ └── storage-engine.options.ts ├── helpers │ ├── get-file-url.helper.ts │ └── file-name.helper.ts ├── interfaces │ ├── multer-out-file.interface.ts │ └── storage-adapter.interface.ts ├── index.ts ├── adapters │ ├── base-storage.adapter.ts │ ├── azure.adapter.ts │ └── s3.adapter.ts ├── storage.module.ts ├── storage │ └── blob-storage-engine.ts └── services │ └── storage.service.ts ├── .npmrc ├── tsconfig.build.json ├── sample ├── app.service.ts ├── configs │ ├── env.config.ts │ └── storage.config.ts ├── main.ts ├── app.module.ts └── app.controller.ts ├── .auto-changelog ├── nest-cli.json ├── .gitignore ├── .github └── workflows │ └── publish.yml ├── tsconfig.json ├── CHANGELOG.md ├── README.md └── package.json /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install lint-staged -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib/**/*.ts": ["prettier --write"] 3 | } 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@hodfords/nestjs-eslint-config'); 2 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@hodfords/nestjs-prettier-config'); 2 | -------------------------------------------------------------------------------- /lib/constants/svg-file-type.constant.ts: -------------------------------------------------------------------------------- 1 | export const SVG_FILE_TYPE = 'image/svg+xml'; 2 | -------------------------------------------------------------------------------- /lib/enums/image-format.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ImageFormatEnum { 2 | PNG = 'png', 3 | JPG = 'jpg' 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} 2 | registry=https://registry.npmjs.org/ 3 | always-auth=true -------------------------------------------------------------------------------- /lib/constants/provider.constants.ts: -------------------------------------------------------------------------------- 1 | export const CLOUD_ACCOUNT = 'CLOUD_ACCOUNT'; 2 | export const ADAPTER = 'ADAPTER'; 3 | -------------------------------------------------------------------------------- /lib/types/blob-client.type.ts: -------------------------------------------------------------------------------- 1 | export type BlobClient = { 2 | containerName: string; 3 | blobName: string; 4 | }; 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /lib/types/image-with-thumbnail.type.ts: -------------------------------------------------------------------------------- 1 | export type ImageWithThumbnailType = { 2 | thumbnail: string; 3 | origin: string; 4 | }; 5 | -------------------------------------------------------------------------------- /lib/types/blob-storage-properties.type.ts: -------------------------------------------------------------------------------- 1 | export type BlobStorageProperties = { 2 | contentType: string; 3 | contentLength: number; 4 | etag: string; 5 | }; 6 | -------------------------------------------------------------------------------- /lib/types/image-detail.type.ts: -------------------------------------------------------------------------------- 1 | export type ImageDetailType = { 2 | buffer: Buffer; 3 | mimetype: string; 4 | filename: string; 5 | originalname: string; 6 | }; 7 | -------------------------------------------------------------------------------- /sample/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/helpers/get-file-url.helper.ts: -------------------------------------------------------------------------------- 1 | import { StorageService } from '../services/storage.service'; 2 | 3 | export const getFileUrl = (blobName: string) => { 4 | return StorageService.instance.getFileUrl(blobName); 5 | }; 6 | -------------------------------------------------------------------------------- /lib/types/upload-file.type.ts: -------------------------------------------------------------------------------- 1 | export type UploadFileType = { 2 | file: Express.Multer.File | Buffer; 3 | mimetype?: string; 4 | fileName?: string; 5 | blobName?: string; 6 | isPublic?: boolean; 7 | }; 8 | -------------------------------------------------------------------------------- /.auto-changelog: -------------------------------------------------------------------------------- 1 | { 2 | "output": "CHANGELOG.md", 3 | "template": "keepachangelog", 4 | "ignoreCommitPattern": "^(?!(feat|fix|\\[feat\\]|\\[fix\\]))(.*)$", 5 | "commitLimit": false, 6 | "commitUrl": "https://github.com/hodfords-solutions/nestjs-storage/commit/{id}" 7 | } -------------------------------------------------------------------------------- /sample/configs/env.config.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | AZURE: { 3 | ACCOUNT_NAME: '', 4 | ACCOUNT_KEY: '', 5 | CONTAINER_NAME: '', 6 | SAS_EXPIRED_IN: 3600, 7 | CONTAINER_LEVEL: 'blob', 8 | REGION: '' 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /lib/types/storage-options.type.ts: -------------------------------------------------------------------------------- 1 | import { AzureAccountType, S3AccountType } from './account.type'; 2 | 3 | export type StorageOptions = 4 | | { 5 | account: AzureAccountType; 6 | disk: 'azure'; 7 | } 8 | | { account: S3AccountType; disk: 's3' }; 9 | -------------------------------------------------------------------------------- /lib/interfaces/multer-out-file.interface.ts: -------------------------------------------------------------------------------- 1 | export interface MulterOutFile extends Express.Multer.File { 2 | url: string; 3 | etag: string; 4 | metadata: any; 5 | blobName: string; 6 | blobType: string; 7 | blobSize: number; 8 | container: string; 9 | } 10 | -------------------------------------------------------------------------------- /lib/types/option-upload-file.type.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeEnum } from '../enums/mime-type.enum'; 2 | import { ImageFormatEnum } from '../enums/image-format.enum'; 3 | 4 | export type OptionUploadFileType = { 5 | imageFormat: ImageFormatEnum; 6 | mimeTypeConverts: MimeTypeEnum[]; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/types/blob-upload-headers.type.ts: -------------------------------------------------------------------------------- 1 | export type BlobUploadHeaders = { 2 | blobCacheControl?: string; 3 | blobContentType?: string; 4 | blobContentMD5?: Uint8Array; 5 | blobContentEncoding?: string; 6 | blobContentLanguage?: string; 7 | blobContentDisposition?: string; 8 | }; 9 | -------------------------------------------------------------------------------- /lib/types/account.type.ts: -------------------------------------------------------------------------------- 1 | export type AccountType = { 2 | containerName: string; 3 | expiredIn: number; 4 | }; 5 | 6 | export type AzureAccountType = AccountType & { 7 | name: string; 8 | key: string; 9 | }; 10 | 11 | export type S3AccountType = AzureAccountType & { 12 | region?: string; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/constants/file.constant.ts: -------------------------------------------------------------------------------- 1 | export const SUPPORTED_FORMAT_CONVERT_TO_PNG_TYPES = [ 2 | 'image/png', 3 | 'image/jpeg', 4 | 'image/jpg', 5 | 'image/svg+xml', 6 | 'image/bmp', 7 | 'image/heic', 8 | 'image/heif', 9 | 'image/tiff' 10 | ]; 11 | 12 | export const JPEG_MIME_TYPE = 'image/jpeg'; 13 | -------------------------------------------------------------------------------- /lib/enums/mime-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum MimeTypeEnum { 2 | IMAGE_PNG = 'image/png', 3 | IMAGE_IPEG = 'image/jpeg', 4 | IMAGE_JPG = 'image/jpg', 5 | IMAGE_SVG = 'image/svg+xml', 6 | IMAGE_BMP = 'image/bmp', 7 | IMAGE_HEIC = 'image/heic', 8 | IMAGE_HEIF = 'image/heif', 9 | IMAGE_TIFF = 'image/tiff' 10 | } 11 | -------------------------------------------------------------------------------- /lib/types/storage-engine.options.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | export type BlobNameResolver = (req: Request, file: Express.Multer.File) => string; 4 | 5 | export type StorageEngineOptions = { 6 | blobNameFn: BlobNameResolver; 7 | accessKey: string; 8 | accountName: string; 9 | containerName: string; 10 | }; 11 | -------------------------------------------------------------------------------- /sample/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | await app.listen(2008); 8 | } 9 | bootstrap().then(); 10 | -------------------------------------------------------------------------------- /sample/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { storageConfig } from './configs/storage.config'; 5 | 6 | @Module({ 7 | imports: [storageConfig], 8 | controllers: [AppController], 9 | providers: [AppService] 10 | }) 11 | export class AppModule {} 12 | -------------------------------------------------------------------------------- /sample/configs/storage.config.ts: -------------------------------------------------------------------------------- 1 | import { StorageModule } from 'lib/storage.module'; 2 | import { env } from './env.config'; 3 | 4 | export const storageConfig = StorageModule.forRoot({ 5 | account: { 6 | name: env.AZURE.ACCOUNT_NAME, 7 | key: env.AZURE.ACCOUNT_KEY, 8 | containerName: env.AZURE.CONTAINER_NAME, 9 | expiredIn: env.AZURE.SAS_EXPIRED_IN, 10 | region: env.AZURE.REGION 11 | }, 12 | disk: 's3' 13 | }); 14 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "sample", 4 | "projects": { 5 | "nestjs-storage": { 6 | "type": "library", 7 | "root": "lib", 8 | "entryFile": "index", 9 | "sourceRoot": "lib" 10 | } 11 | }, 12 | "compilerOptions": { 13 | "webpack": false, 14 | "assets": [ 15 | { 16 | "include": "../lib/public/**", 17 | "watchAssets": true 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage.module'; 2 | export * from './services/storage.service'; 3 | export * from './adapters/base-storage.adapter'; 4 | export * from './adapters/azure.adapter'; 5 | export * from './helpers/get-file-url.helper'; 6 | export * from './helpers/file-name.helper'; 7 | export * from './types/blob-client.type'; 8 | export * from './interfaces/multer-out-file.interface'; 9 | export * from './types/storage-engine.options'; 10 | export * from './storage/blob-storage-engine'; 11 | export * from './types/blob-storage-properties.type'; 12 | export * from './types/blob-upload-headers.type'; 13 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | lint: 8 | uses: hodfords-solutions/actions/.github/workflows/lint.yaml@main 9 | build: 10 | uses: hodfords-solutions/actions/.github/workflows/publish.yaml@main 11 | with: 12 | build_path: dist/lib 13 | secrets: 14 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 15 | update-docs: 16 | uses: hodfords-solutions/actions/.github/workflows/update-doc.yaml@main 17 | needs: build 18 | secrets: 19 | DOC_SSH_PRIVATE_KEY: ${{ secrets.DOC_SSH_PRIVATE_KEY }} 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "esModuleInterop": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/helpers/file-name.helper.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import path from 'path'; 3 | import slugify from 'slugify'; 4 | 5 | export function generateUniqueName(filePath: string): string { 6 | const uuid = uuidv4(); 7 | const maxLength = 255; 8 | // eslint-disable-next-line prefer-const 9 | let { dir, name, ext } = path.parse(filePath); 10 | name = slugify(name, { 11 | replacement: '-', 12 | lower: true, 13 | strict: true, 14 | trim: true 15 | }); 16 | const expectedFileNameLength = name.length + uuid.length + 1 + ext.length; 17 | 18 | if (expectedFileNameLength > maxLength) { 19 | const fileName = `${uuid}-${name.substring(0, maxLength - uuid.length - ext.length - 1)}${ext}`; 20 | return path.join(dir, fileName); 21 | } 22 | 23 | const fileName = `${uuid}-${name}${ext}`; 24 | return path.join(dir, fileName); 25 | } 26 | -------------------------------------------------------------------------------- /lib/adapters/base-storage.adapter.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | export abstract class BaseStorageAdapter { 4 | getFileNameToUpload(file: Express.Multer.File): string { 5 | const originalName = file.originalname; 6 | const dashIndex = originalName.indexOf('_'); 7 | return originalName.substring(dashIndex + 1); 8 | } 9 | 10 | async streamToBuffer(readableStream: NodeJS.ReadableStream | undefined): Promise { 11 | return new Promise((resolve: (value: Buffer | null) => void) => { 12 | if (!readableStream) { 13 | return resolve(null); 14 | } 15 | const chunks: Buffer[] = []; 16 | readableStream.on('data', (chunk: any) => { 17 | const data = chunk instanceof Buffer ? chunk : Buffer.from(chunk); 18 | chunks.push(data); 19 | }); 20 | readableStream.on('end', () => resolve(Buffer.concat(chunks))); 21 | readableStream.on('error', () => resolve(null)); 22 | }); 23 | } 24 | 25 | retrieveFileName(blobName: string): string { 26 | return blobName.slice(uuidv4().length + 1, blobName.length); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/storage.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module } from '@nestjs/common'; 2 | import { AzureAdapter } from './adapters/azure.adapter'; 3 | import { ADAPTER, CLOUD_ACCOUNT } from './constants/provider.constants'; 4 | import { StorageService } from './services/storage.service'; 5 | import { StorageOptions } from './types/storage-options.type'; 6 | import { S3Adapter } from './adapters/s3.adapter'; 7 | 8 | @Global() 9 | @Module({}) 10 | export class StorageModule { 11 | public static forRoot(options: StorageOptions): DynamicModule { 12 | const accountOptionsProvider = { 13 | provide: CLOUD_ACCOUNT, 14 | useValue: options.account 15 | }; 16 | let adapterProvider; 17 | if (options.disk === 's3') { 18 | adapterProvider = { 19 | provide: ADAPTER, 20 | useClass: S3Adapter 21 | }; 22 | } else { 23 | adapterProvider = { 24 | provide: ADAPTER, 25 | useClass: AzureAdapter 26 | }; 27 | } 28 | 29 | return { 30 | providers: [adapterProvider, StorageService, accountOptionsProvider], 31 | exports: [StorageService, accountOptionsProvider], 32 | module: StorageModule 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/interfaces/storage-adapter.interface.ts: -------------------------------------------------------------------------------- 1 | import stream, { Readable } from 'stream'; 2 | import { BlobClient } from '../types/blob-client.type'; 3 | import { UploadFileType } from '../types/upload-file.type'; 4 | import { BlobStorageProperties } from '../types/blob-storage-properties.type'; 5 | import { BlobUploadHeaders } from '../types/blob-upload-headers.type'; 6 | 7 | export interface StorageAdapter { 8 | getFileNameToUpload(file: Express.Multer.File): string; 9 | streamToBuffer(readableStream: NodeJS.ReadableStream | undefined): Promise; 10 | retrieveFileName(blobName: string): string; 11 | uploadStream(stream: Readable, fileName: string): Promise; 12 | copyFileFromUrl(url: string, blobName: string, isPublic?: boolean); 13 | uploadFile(data: UploadFileType): Promise; 14 | deleteIfExists(blobName: string): Promise; 15 | generatePresignedUrl(blobName: string, expiresOn?: any, options?: any): string | Promise; 16 | uploadBlobreadable(readable: stream.Readable, blobName: string, httpHeaders?: BlobUploadHeaders); 17 | createBufferFromBlob(blobName: string): Promise; 18 | getFileStream(blobName: string): Promise; 19 | getFileBuffer(blobName: string): Promise; 20 | getProperties(blobName: string): Promise; 21 | deleteFile(blobName: string): Promise; 22 | getPublicUrl(blobName: string): string; 23 | } 24 | -------------------------------------------------------------------------------- /sample/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Delete, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Param, 8 | Post, 9 | UploadedFile, 10 | UseInterceptors 11 | } from '@nestjs/common'; 12 | import { FileInterceptor } from '@nestjs/platform-express'; 13 | import { AppService } from './app.service'; 14 | import { BlobClient, getFileUrl, StorageService } from 'lib'; 15 | import axios from 'axios'; 16 | 17 | @Controller() 18 | export class AppController { 19 | constructor( 20 | private readonly appService: AppService, 21 | private storageService: StorageService 22 | ) {} 23 | 24 | @Get() 25 | @HttpCode(HttpStatus.OK) 26 | getHello(): string { 27 | return this.appService.getHello(); 28 | } 29 | 30 | @Post('upload') 31 | @UseInterceptors(FileInterceptor('file')) 32 | uploadFile(@UploadedFile() file: Express.Multer.File): Promise { 33 | return this.storageService.uploadFile({ file }); 34 | } 35 | 36 | @Get(':blobName') 37 | getFile(@Param('blobName') blobName: string): string | Promise { 38 | return getFileUrl(blobName); 39 | } 40 | 41 | @Delete(':blobName') 42 | deleteFile(@Param('blobName') blobName: string): Promise { 43 | return this.storageService.deleteIfExists(blobName); 44 | } 45 | 46 | @Post('upload-stream') 47 | async uploadStream(): Promise { 48 | const url = 'https://picsum.photos/200/300'; 49 | const response = await axios.get(url, { responseType: 'stream', timeout: 60000 }); 50 | return this.storageService.uploadStream(response.data, 'images/test.png'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 9 | 10 | ## [8.0.0](https://hodfords-solutions/hodfords-solutions/nestjs-storage.git/compare/1.0.5...8.0.0) - 2024-01-02 11 | 12 | ## 1.0.5 - 2023-09-05 13 | 14 | ### Commits 15 | 16 | - feat: init project [`7a8cbcd`](https://github.com/hodfords-solutions/nestjs-storage/commit/7a8cbcde75c940d2ff53fbc149c70e5d45ce0f16) 17 | - feat: support proxy for s3 [`180963e`](https://github.com/hodfords-solutions/nestjs-storage/commit/180963eaff45bd69b6122cb52f7989b7481f2d84) 18 | - feat: change the way to generate file name [`8c4ecd0`](https://github.com/hodfords-solutions/nestjs-storage/commit/8c4ecd087337bfd5e286f630dfd1b1f9df8c287d) 19 | - feat: change the way to generate file name [`76147e7`](https://github.com/hodfords-solutions/nestjs-storage/commit/76147e7eec83016e9999b2772a0942c26c2735fb) 20 | - fix: update version, change region to s3 only [`f73349c`](https://github.com/hodfords-solutions/nestjs-storage/commit/f73349c7e50febb832953e1c797db030e93a6395) 21 | - fix: update versio n [`470f5f0`](https://github.com/hodfords-solutions/nestjs-storage/commit/470f5f0166004c7f35dc57f8676036f7cee73da3) 22 | - fix: add s3 region [`0ce7e7e`](https://github.com/hodfords-solutions/nestjs-storage/commit/0ce7e7e7a50f25574b01a6b3ad33c7e19d5b9c87) 23 | - feat: init project [`641a9d6`](https://github.com/hodfords-solutions/nestjs-storage/commit/641a9d619225845eb5ef958d7ea14f521063ce8c) 24 | - feat: init project [`c95373b`](https://github.com/hodfords-solutions/nestjs-storage/commit/c95373b5de2fe4b7633f9a89c6ae2634eddc112a) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 |

6 | Nestjs-Storage provides a powerful filesystem abstraction. The Nestjs-Storage integration provides simple drivers for working with local filesystems, Amazon S3, Azure. Even better, it's amazingly simple to switch between these storage options between your local development machine and production server as the API remains the same for each system. 7 |

8 | 9 | ## Installation 🤖 10 | To begin using it, we first install the required dependencies. 11 | ``` 12 | npm install @hodfords/nestjs-storage 13 | ``` 14 | 15 | ## Configuration 🚀 16 | To activate storage, import the `StorageModule` into the root `AppModule` and run the `forRoot()` static method as shown below: 17 | 18 | Azure configuration: 19 | ```typescript 20 | import { Module } from '@nestjs/common'; 21 | import { StorageModule } from '@hodfords/nestjs-storage'; 22 | 23 | @Module({ 24 | imports: [ 25 | StorageModule.forRoot({ 26 | account: { 27 | name: env.AZURE.ACCOUNT_NAME, 28 | key: env.AZURE.ACCOUNT_KEY, 29 | containerName: env.AZURE.CONTAINER_NAME, 30 | expiredIn: env.AZURE.SAS_EXPIRED_IN 31 | }, 32 | disk: 'azure' 33 | }) 34 | ], 35 | }) 36 | export class AppModule {} 37 | ``` 38 | 39 | Aws S3 configuration: 40 | ```typescript 41 | import { Module } from '@nestjs/common'; 42 | import { StorageModule } from '@hodfords/nestjs-storage'; 43 | 44 | @Module({ 45 | imports: [ 46 | StorageModule.forRoot({ 47 | account: { 48 | name: env.AWS.API_KEY, 49 | key: env.AWS.API_SECRET, 50 | containerName: env.AWS.BUCKET, 51 | expiredIn: env.AZURE.SAS_EXPIRED_IN, 52 | region: env.AWS.REGION 53 | }, 54 | disk: 's3' 55 | }) 56 | ], 57 | }) 58 | export class AppModule {} 59 | ``` 60 | 61 | ### Driver Prerequisites: 62 | - **Azure**: `npm install @azure/storage-blob` 63 | - **Aws S3**: `npm install @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner` 64 | 65 | ## Usage 🚀 66 | 67 | Inject storage instance into your service or controller and use it as shown below: 68 | 69 | ```typescript 70 | import { StorageService } from "@hodfords/nestjs-storage"; 71 | 72 | @Injectable() 73 | export class AppService implements OnModuleInit { 74 | 75 | constructor(private storageService: StorageService) { 76 | } 77 | } 78 | ``` 79 | 80 | ### Delete file 81 | The delete method accepts a single filename 82 | 83 | ```typescript 84 | await this.storageService.deleteFile('path/to/file'); 85 | ``` 86 | 87 | This method may throw an exception if the file does not exist. You can ignore this exception by using the `deleteIfExists` method. 88 | 89 | ```typescript 90 | await this.storageService.deleteIfExists('path/to/file'); 91 | ``` 92 | 93 | ## License 94 | This project is licensed under the MIT License 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hodfords/nestjs-storage", 3 | "version": "11.0.1", 4 | "description": "Provides integration with third-party cloud storage solutions in NestJS apps", 5 | "homepage": "https://github.com/hodfords-solutions/nestjs-storage#readme", 6 | "bugs": { 7 | "url": "https://github.com/hodfords-solutions/nestjs-storage/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/hodfords-solutions/nestjs-storage.git" 12 | }, 13 | "license": "UNLICENSED", 14 | "author": "", 15 | "scripts": { 16 | "prebuild": "rimraf dist", 17 | "build": "nest build", 18 | "format": "prettier --write \"sample/*/**/*.ts\" \"lib/**/*.ts\"", 19 | "postbuild": "cp package.json dist/lib && cp README.md dist/lib && cp .npmrc dist/lib", 20 | "lint": "eslint \"{sample/*,apps,lib,test}/**/*.ts\" --fix", 21 | "prepare": "is-ci || husky", 22 | "release:patch": "git add CHANGELOG.md && npm version patch --tag-version-prefix='' -f -m 'chore: release to %s'", 23 | "release:push": "git push --no-verify && git push --tags --no-verify", 24 | "start": "nest start", 25 | "start:debug": "nest start --debug --watch", 26 | "start:dev": "npm run prebuild && nest start --watch", 27 | "test": "jest", 28 | "test:cov": "jest --coverage", 29 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 30 | "test:e2e": "jest --config ./test/jest-e2e.json", 31 | "test:watch": "jest --watch", 32 | "version": "auto-changelog && git add CHANGELOG.md", 33 | "wz-command": "wz-command" 34 | }, 35 | "jest": { 36 | "collectCoverageFrom": [ 37 | "**/*.(t|j)s" 38 | ], 39 | "coverageDirectory": "./coverage", 40 | "moduleFileExtensions": [ 41 | "js", 42 | "json", 43 | "ts" 44 | ], 45 | "moduleNameMapper": { 46 | "^@hodfords/nestjs-storage(|/.*)$": "/libs/nestjs-storage/src/$1" 47 | }, 48 | "rootDir": ".", 49 | "roots": [ 50 | "/src/", 51 | "/libs/" 52 | ], 53 | "testEnvironment": "node", 54 | "testRegex": ".*\\.spec\\.ts$", 55 | "transform": { 56 | "^.+\\.(t|j)s$": "ts-jest" 57 | } 58 | }, 59 | "dependencies": { 60 | "adm-zip": "0.5.16", 61 | "dayjs": "1.11.13", 62 | "eslint-config-prettier": "^10.1.1", 63 | "express": "4.21.2", 64 | "proxy-agent": "6.5.0", 65 | "sharp": "0.33.5", 66 | "slugify": "1.6.6", 67 | "@aws-sdk/client-s3": "3.758.0", 68 | "@aws-sdk/lib-storage": "3.758.0", 69 | "@aws-sdk/s3-request-presigner": "3.758.0", 70 | "@azure/storage-blob": "12.26.0" 71 | }, 72 | "devDependencies": { 73 | "@hodfords/nestjs-eslint-config": "^11.0.1", 74 | "@hodfords/nestjs-prettier-config": "^11.0.1", 75 | "@nestjs/cli": "11.0.5", 76 | "@nestjs/common": "11.0.11", 77 | "@nestjs/core": "11.0.11", 78 | "@nestjs/platform-express": "11.0.11", 79 | "@nestjs/testing": "11.0.11", 80 | "@types/express": "5.0.0", 81 | "@types/jest": "29.5.14", 82 | "@types/multer": "1.4.12", 83 | "@types/node": "22.13.10", 84 | "@types/supertest": "6.0.2", 85 | "auto-changelog": "2.5.0", 86 | "axios": "^1.8.3", 87 | "eslint": "9.22.0", 88 | "husky": "9.1.7", 89 | "is-ci": "4.1.0", 90 | "jest": "29.7.0", 91 | "lint-staged": "15.5.0", 92 | "prettier": "3.5.3", 93 | "reflect-metadata": "0.2.2", 94 | "rimraf": "6.0.1", 95 | "rxjs": "7.8.2", 96 | "supertest": "7.0.0", 97 | "ts-jest": "29.2.6", 98 | "ts-loader": "9.5.2", 99 | "ts-node": "10.9.2", 100 | "tsconfig-paths": "4.2.0", 101 | "typescript": "5.8.2" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lib/storage/blob-storage-engine.ts: -------------------------------------------------------------------------------- 1 | import { isNil, isUndefined } from '@nestjs/common/utils/shared.utils'; 2 | import { Request } from 'express'; 3 | import { StorageEngine } from 'multer'; 4 | import { 5 | BlobGetPropertiesResponse, 6 | BlobSASPermissions, 7 | BlobServiceClient, 8 | ContainerClient, 9 | generateBlobSASQueryParameters, 10 | StorageSharedKeyCredential 11 | } from '@azure/storage-blob'; 12 | import { MulterOutFile } from '../interfaces/multer-out-file.interface'; 13 | import { StorageEngineOptions } from '../types/storage-engine.options'; 14 | 15 | export class BlobStorageEngine implements StorageEngine { 16 | private containerName: string; 17 | private containerClient: ContainerClient; 18 | private sharedKeyCredential: StorageSharedKeyCredential; 19 | 20 | constructor(private options: StorageEngineOptions) { 21 | this.containerName = options.containerName; 22 | this.sharedKeyCredential = new StorageSharedKeyCredential(options.accountName, options.accessKey); 23 | const blobServiceClient = new BlobServiceClient( 24 | `https://${options.accountName}.blob.core.windows.net`, 25 | this.sharedKeyCredential 26 | ); 27 | this.containerClient = blobServiceClient.getContainerClient(options.containerName); 28 | } 29 | 30 | /* eslint-disable @typescript-eslint/naming-convention */ 31 | async _handleFile( 32 | req: Request, 33 | file: Express.Multer.File, 34 | callback: (error?: any, info?: Partial) => void 35 | ): Promise { 36 | try { 37 | const buffer = await this.streamToBuffer(file.stream); 38 | const blobName = this.options.blobNameFn(req, file); 39 | const blobClient = this.containerClient.getBlockBlobClient(blobName); 40 | 41 | await blobClient.upload(buffer, buffer.length, { 42 | blobHTTPHeaders: { 43 | blobContentType: file.mimetype, 44 | blobContentDisposition: 'inline' 45 | } 46 | }); 47 | 48 | const blobProperties = await this.getBlobProperties(blobName); 49 | const url = this.generateBlobUrl(blobName); 50 | const intermediateFile: Partial = { 51 | url, 52 | blobName, 53 | etag: blobProperties.etag, 54 | blobType: blobProperties.blobType, 55 | metadata: blobProperties.metadata, 56 | container: this.containerName, 57 | blobSize: blobProperties.contentLength || 0 58 | }; 59 | callback(null, Object.assign({}, file, intermediateFile)); 60 | } catch (err) { 61 | callback(err); 62 | } 63 | } 64 | 65 | private async streamToBuffer(readableStream: NodeJS.ReadableStream): Promise { 66 | return new Promise((resolve: (value: Buffer) => void, reject) => { 67 | if (!readableStream) { 68 | return reject(new Error('Stream does not exist')); 69 | } 70 | const chunks: Buffer[] = []; 71 | readableStream.on('data', (data) => { 72 | chunks.push(data instanceof Buffer ? data : Buffer.from(data)); 73 | }); 74 | readableStream.on('end', () => resolve(Buffer.concat(chunks))); 75 | readableStream.on('error', (err) => reject(err)); 76 | }); 77 | } 78 | 79 | _removeFile(_req: Request, file: MulterOutFile, callback: (error: Error | null) => void): void { 80 | this.deleteBlob(file.blobName) 81 | .then(() => callback(null)) 82 | .catch((err) => callback(err)); 83 | } 84 | 85 | private generateBlobUrl(blobName: string): string { 86 | if (isUndefined(blobName) || isNil(blobName) || blobName === '') { 87 | return ''; 88 | } 89 | 90 | const expiresOn = new Date(); 91 | expiresOn.setHours(expiresOn.getHours() + 1); 92 | 93 | const sasToken = generateBlobSASQueryParameters( 94 | { 95 | containerName: this.containerName, 96 | blobName, 97 | permissions: BlobSASPermissions.parse('r'), 98 | startsOn: new Date(), 99 | expiresOn 100 | }, 101 | this.sharedKeyCredential 102 | ).toString(); 103 | 104 | const blobClient = this.containerClient.getBlockBlobClient(blobName); 105 | return `${blobClient.url}?${sasToken}`; 106 | } 107 | 108 | private async deleteBlob(blobName: string): Promise { 109 | if (isUndefined(blobName) || isNil(blobName) || blobName === '') { 110 | return Promise.resolve(); 111 | } 112 | 113 | const blobClient = this.containerClient.getBlockBlobClient(blobName); 114 | await blobClient.deleteIfExists(); 115 | } 116 | 117 | private getBlobProperties(blobName: string): Promise { 118 | const blobClient = this.containerClient.getBlockBlobClient(blobName); 119 | return blobClient.getProperties(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lib/services/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Global, Inject, Injectable } from '@nestjs/common'; 2 | import AdmZip from 'adm-zip'; 3 | import stream, { Readable } from 'stream'; 4 | import sharp from 'sharp'; 5 | import { UploadFileType } from '../types/upload-file.type'; 6 | import { StorageAdapter } from '../interfaces/storage-adapter.interface'; 7 | import { ADAPTER } from '../constants/provider.constants'; 8 | import { BlobStorageProperties } from '../types/blob-storage-properties.type'; 9 | import { BlobUploadHeaders } from '../types/blob-upload-headers.type'; 10 | import { BlobSASSignatureValues } from '@azure/storage-blob'; 11 | import { randomUUID } from 'crypto'; 12 | import { JPEG_MIME_TYPE, SUPPORTED_FORMAT_CONVERT_TO_PNG_TYPES } from '../constants/file.constant'; 13 | import { ImageDetailType } from '../types/image-detail.type'; 14 | import { OptionUploadFileType } from '../types/option-upload-file.type'; 15 | import { BlobClient } from '../types/blob-client.type'; 16 | import { ImageFormatEnum } from '../enums/image-format.enum'; 17 | import { MimeTypeEnum } from '../enums/mime-type.enum'; 18 | import { ImageWithThumbnailType } from '../types/image-with-thumbnail.type'; 19 | 20 | @Global() 21 | @Injectable() 22 | export class StorageService { 23 | static instance: StorageService; 24 | 25 | public constructor(@Inject(ADAPTER) private storage: StorageAdapter) { 26 | StorageService.instance = this; 27 | } 28 | 29 | async uploadFile(file: UploadFileType, options?: OptionUploadFileType): Promise { 30 | if (options) { 31 | const { mimeTypeConverts, imageFormat } = options; 32 | if (mimeTypeConverts.includes(file.mimetype as MimeTypeEnum)) { 33 | const imagePng = await this.convertImagesToPng(file, imageFormat); 34 | return this.storage.uploadFile({ 35 | file: imagePng.buffer, 36 | mimetype: imagePng.mimetype, 37 | fileName: file.fileName ?? imagePng.filename 38 | }); 39 | } 40 | } 41 | return this.storage.uploadFile(file); 42 | } 43 | 44 | uploadStream(stream: Readable, fileName: string): Promise { 45 | return this.storage.uploadStream(stream, fileName); 46 | } 47 | 48 | copyFileFromUrl(url: string, blobName: string, isPublic?: boolean) { 49 | if (!blobName || !url) { 50 | return; 51 | } 52 | return this.storage.copyFileFromUrl(url, blobName, isPublic); 53 | } 54 | 55 | getFileUrl( 56 | blobName: string, 57 | expiresOn?: Date, 58 | options: Partial = {} 59 | ): string | Promise { 60 | return this.storage.generatePresignedUrl(blobName, expiresOn, options); 61 | } 62 | 63 | getPublicUrl(blobName: string): string { 64 | return this.storage.getPublicUrl(blobName); 65 | } 66 | 67 | async deleteIfExists(blobName: string): Promise { 68 | await this.storage.deleteIfExists(blobName); 69 | } 70 | 71 | async deleteFile(blobName: string): Promise { 72 | await this.storage.deleteFile(blobName); 73 | } 74 | 75 | uploadBlobreadable(readable: stream.Readable, blobName: string, httpHeaders?: BlobUploadHeaders) { 76 | return this.storage.uploadBlobreadable(readable, blobName, httpHeaders); 77 | } 78 | 79 | createBufferFromBlob(blobName: string): Promise { 80 | return this.storage.createBufferFromBlob(blobName); 81 | } 82 | 83 | async zipFiles(files: { blobName: string; fileName: string }[]): Promise { 84 | const zip = new AdmZip(); 85 | 86 | const bufferToZipPromises = files.map((file) => { 87 | const { blobName, fileName } = file; 88 | return this.createBufferFromBlob(blobName).then((buffer) => { 89 | return { fileName, buffer }; 90 | }); 91 | }); 92 | 93 | const bufferToZipSettled = await Promise.allSettled(bufferToZipPromises); 94 | for (const result of bufferToZipSettled) { 95 | if (result.status === 'fulfilled') { 96 | const { fileName, buffer } = result.value; 97 | if (buffer) { 98 | zip.addFile(fileName, buffer); 99 | } 100 | } 101 | } 102 | 103 | return zip.toBuffer(); 104 | } 105 | 106 | async resizeImage(image, percentage = 75): Promise { 107 | const { width, height } = await sharp(image).metadata(); 108 | const resizedWith = Math.round(width * (percentage / 100)); 109 | const resizedHeight = Math.round(height * (percentage / 100)); 110 | 111 | return sharp(image).resize(resizedWith, resizedHeight).toBuffer(); 112 | } 113 | 114 | async uploadImageWithThumbnail(image, fileName?: string): Promise { 115 | const thumbnailBuffer = await this.resizeImage(image.buffer); 116 | const [{ blobName: origin }, { blobName: thumbnail }] = await Promise.all([ 117 | this.storage.uploadFile({ file: image, fileName }), 118 | this.storage.uploadFile({ file: thumbnailBuffer, fileName: fileName || image.originalname }) 119 | ]); 120 | 121 | return { origin, thumbnail }; 122 | } 123 | 124 | getFileStream(blobName: string): Promise { 125 | return this.storage.getFileStream(blobName); 126 | } 127 | 128 | getFileBuffer(blobName: string): Promise { 129 | return this.storage.getFileBuffer(blobName); 130 | } 131 | 132 | getRetrieveFileName(blobName: string): string { 133 | return this.storage.retrieveFileName(blobName); 134 | } 135 | 136 | getProperties(blobName: string): Promise { 137 | return this.storage.getProperties(blobName); 138 | } 139 | 140 | async convertImagesToPng(data: UploadFileType, imageFormat: ImageFormatEnum): Promise { 141 | const { file, mimetype } = data; 142 | let buffer: Buffer; 143 | if (this.validateFileImageConvert(mimetype ?? ('mimetype' in file && file.mimetype))) { 144 | throw new BadRequestException({ translate: 'error.file_not_support' }); 145 | } 146 | if (file instanceof Buffer) { 147 | buffer = await sharp(file).toFormat(imageFormat).toBuffer(); 148 | } else { 149 | if (file.buffer) { 150 | buffer = await sharp(file.buffer).toFormat(imageFormat).toBuffer(); 151 | } else { 152 | buffer = await sharp((file as Express.Multer.File).path) 153 | .toFormat(imageFormat) 154 | .toBuffer(); 155 | } 156 | } 157 | 158 | const filename = randomUUID() + '.' + imageFormat; 159 | return { 160 | buffer: buffer, 161 | mimetype: imageFormat === ImageFormatEnum.JPG ? JPEG_MIME_TYPE : `image/${imageFormat}`, 162 | filename, 163 | originalname: filename 164 | }; 165 | } 166 | 167 | validateFileImageConvert(mimetype: string): boolean { 168 | return !SUPPORTED_FORMAT_CONVERT_TO_PNG_TYPES.includes(mimetype); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /lib/adapters/azure.adapter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BlobCopyFromURLResponse, 3 | BlobSASSignatureValues, 4 | BlobServiceClient, 5 | BlobUploadCommonResponse, 6 | BlockBlobClient, 7 | BlockBlobParallelUploadOptions, 8 | ContainerClient, 9 | ContainerSASPermissions, 10 | generateBlobSASQueryParameters, 11 | StorageSharedKeyCredential 12 | } from '@azure/storage-blob'; 13 | import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; 14 | import dayjs from 'dayjs'; 15 | import stream, { Readable } from 'stream'; 16 | import { CLOUD_ACCOUNT } from '../constants/provider.constants'; 17 | import { SVG_FILE_TYPE } from '../constants/svg-file-type.constant'; 18 | import { generateUniqueName } from '../helpers/file-name.helper'; 19 | import { StorageAdapter } from '../interfaces/storage-adapter.interface'; 20 | import { AzureAccountType } from '../types/account.type'; 21 | import { BlobClient } from '../types/blob-client.type'; 22 | import { BlobStorageProperties } from '../types/blob-storage-properties.type'; 23 | import { BlobUploadHeaders } from '../types/blob-upload-headers.type'; 24 | import { UploadFileType } from '../types/upload-file.type'; 25 | import { BaseStorageAdapter } from './base-storage.adapter'; 26 | 27 | @Injectable() 28 | export class AzureAdapter extends BaseStorageAdapter implements StorageAdapter { 29 | private containerClient: ContainerClient; 30 | private blobServiceClient: BlobServiceClient; 31 | private readonly sharedKeyCredential: StorageSharedKeyCredential; 32 | 33 | public constructor(@Inject(CLOUD_ACCOUNT) private account: AzureAccountType) { 34 | super(); 35 | this.sharedKeyCredential = new StorageSharedKeyCredential(this.account.name, this.account.key); 36 | this.blobServiceClient = new BlobServiceClient( 37 | `https://${this.account.name}.blob.core.windows.net`, 38 | this.sharedKeyCredential 39 | ); 40 | this.containerClient = this.blobServiceClient.getContainerClient(this.account.containerName); 41 | } 42 | 43 | async uploadStream(stream: Readable, fileName: string): Promise { 44 | try { 45 | const blobName = generateUniqueName(fileName); 46 | const blockBlobClient = this.containerClient.getBlockBlobClient(blobName); 47 | await blockBlobClient.uploadStream(stream); 48 | return { 49 | containerName: blockBlobClient.containerName, 50 | blobName: blockBlobClient.name 51 | }; 52 | } catch (e) { 53 | throw new InternalServerErrorException(e); 54 | } 55 | } 56 | 57 | copyFileFromUrl(url: string, blobName: string): Promise { 58 | const sourceBlob = this.containerClient.getBlockBlobClient(blobName); 59 | const destinationBlob = this.containerClient.getBlockBlobClient(sourceBlob.name); 60 | return destinationBlob.syncCopyFromURL(url); 61 | } 62 | 63 | public async uploadFile(data: UploadFileType): Promise { 64 | const { file, fileName, mimetype } = data; 65 | const blobOptions = mimetype === SVG_FILE_TYPE ? { blobHTTPHeaders: { blobContentType: SVG_FILE_TYPE } } : {}; 66 | let blobName: string; 67 | let blockBlobClient: BlockBlobClient; 68 | 69 | try { 70 | if (file instanceof Buffer) { 71 | blobName = generateUniqueName(fileName || ''); 72 | blockBlobClient = this.containerClient.getBlockBlobClient(blobName); 73 | await blockBlobClient.upload(file.buffer, file.buffer.byteLength, blobOptions); 74 | } else { 75 | blobName = generateUniqueName(fileName || this.getFileNameToUpload(file as Express.Multer.File)); 76 | blockBlobClient = this.containerClient.getBlockBlobClient(blobName); 77 | if (file.buffer) { 78 | await blockBlobClient.upload(file.buffer, file.buffer.byteLength, blobOptions); 79 | } else { 80 | const options: BlockBlobParallelUploadOptions = { 81 | blobHTTPHeaders: { 82 | blobContentType: (file as Express.Multer.File).mimetype ?? 'application/octet-stream' 83 | } 84 | }; 85 | await blockBlobClient.uploadFile((file as Express.Multer.File).path, options); 86 | } 87 | } 88 | 89 | return { 90 | containerName: blockBlobClient.containerName, 91 | blobName: blockBlobClient.name 92 | }; 93 | } catch (e) { 94 | throw new InternalServerErrorException(e); 95 | } 96 | } 97 | 98 | async deleteIfExists(blobName: string): Promise { 99 | if (typeof blobName === 'undefined' || blobName === null || blobName === '') { 100 | return; 101 | } 102 | 103 | const blockBlobClient = this.containerClient.getBlockBlobClient(blobName); 104 | await blockBlobClient.deleteIfExists(); 105 | } 106 | 107 | public generatePresignedUrl( 108 | blobName: string, 109 | expiresOn = dayjs().add(this.account.expiredIn, 'second').toDate(), 110 | options: Partial = {} 111 | ): string { 112 | const sasOptions: BlobSASSignatureValues = { 113 | containerName: this.account.containerName, 114 | blobName: blobName, 115 | permissions: ContainerSASPermissions.parse('r'), 116 | startsOn: new Date(), 117 | expiresOn: expiresOn, 118 | ...options 119 | }; 120 | const sasToken = generateBlobSASQueryParameters(sasOptions, this.sharedKeyCredential).toString(); 121 | return `${this.containerClient.getBlockBlobClient(blobName).url}?${sasToken}`; 122 | } 123 | 124 | uploadBlobreadable( 125 | readable: stream.Readable, 126 | blobName: string, 127 | httpHeaders?: BlobUploadHeaders 128 | ): Promise { 129 | const bufferSize = 800; 130 | const maxConcurrency = 5; 131 | 132 | const blockBlobClient = this.containerClient.getBlockBlobClient(blobName); 133 | return blockBlobClient.uploadStream(readable, bufferSize, maxConcurrency, { 134 | blobHTTPHeaders: httpHeaders 135 | }); 136 | } 137 | 138 | async createBufferFromBlob(blobName: string): Promise { 139 | const blockBlobClient = this.containerClient.getBlockBlobClient(blobName); 140 | const blobDownloaded = await blockBlobClient.download(); 141 | 142 | return this.streamToBuffer(blobDownloaded.readableStreamBody); 143 | } 144 | 145 | public async getFileStream(blobName: string): Promise { 146 | try { 147 | const blobClient = await this.containerClient.getBlobClient(blobName); 148 | return (await blobClient.download()).readableStreamBody; 149 | } catch (error) { 150 | throw new InternalServerErrorException(error); 151 | } 152 | } 153 | 154 | public async getFileBuffer(blobName: string): Promise { 155 | const fileStream = await this.getFileStream(blobName); 156 | return this.streamToBuffer(fileStream); 157 | } 158 | 159 | async getProperties(blobName: string): Promise { 160 | const blobClient = await this.containerClient.getBlobClient(blobName); 161 | const properties = await blobClient.getProperties(); 162 | return { 163 | contentType: properties.contentType || '', 164 | contentLength: properties.contentLength || 0, 165 | etag: properties.etag || '' 166 | }; 167 | } 168 | 169 | public async deleteFile(blobName: string): Promise { 170 | try { 171 | const blobClient = this.containerClient.getBlockBlobClient(blobName); 172 | await blobClient.delete(); 173 | } catch (error) { 174 | throw new InternalServerErrorException(error); 175 | } 176 | } 177 | 178 | getPublicUrl(blobName: string): string { 179 | throw new TypeError('Implement later'); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /lib/adapters/s3.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; 2 | import { Readable, Readable as ReadableStream } from 'stream'; 3 | import dayjs from 'dayjs'; 4 | import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 5 | import { CLOUD_ACCOUNT } from '../constants/provider.constants'; 6 | import { SVG_FILE_TYPE } from '../constants/svg-file-type.constant'; 7 | import { generateUniqueName } from '../helpers/file-name.helper'; 8 | import { StorageAdapter } from '../interfaces/storage-adapter.interface'; 9 | import { S3AccountType } from '../types/account.type'; 10 | import { BlobClient } from '../types/blob-client.type'; 11 | import { BlobStorageProperties } from '../types/blob-storage-properties.type'; 12 | import { UploadFileType } from '../types/upload-file.type'; 13 | import { BaseStorageAdapter } from './base-storage.adapter'; 14 | import { ProxyAgent } from 'proxy-agent'; 15 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 16 | import * as fs from 'fs'; 17 | import * as util from 'util'; 18 | import { Upload } from '@aws-sdk/lib-storage'; 19 | 20 | @Injectable() 21 | export class S3Adapter extends BaseStorageAdapter implements StorageAdapter { 22 | private readonly containerClient: S3Client; 23 | 24 | public constructor(@Inject(CLOUD_ACCOUNT) private account: S3AccountType) { 25 | super(); 26 | 27 | const s3ClientConfig: any = { 28 | region: account.region, 29 | requestHandler: process.env.http_proxy ? new ProxyAgent() : undefined 30 | }; 31 | if (account.name && account.key) { 32 | s3ClientConfig.credentials = { 33 | accessKeyId: account.name, 34 | secretAccessKey: account.key 35 | }; 36 | } 37 | 38 | this.containerClient = new S3Client(s3ClientConfig); 39 | } 40 | 41 | async uploadStream(stream: Readable, fileName: string): Promise { 42 | try { 43 | const blobName = generateUniqueName(fileName); 44 | 45 | const upload = new Upload({ 46 | client: this.containerClient, 47 | params: { 48 | Bucket: this.account.containerName, 49 | Key: blobName, 50 | Body: stream 51 | } 52 | }); 53 | 54 | await upload.done(); 55 | 56 | return { 57 | containerName: this.account.containerName, 58 | blobName 59 | }; 60 | } catch (e) { 61 | console.error(e); 62 | throw new InternalServerErrorException(e); 63 | } 64 | } 65 | 66 | async copyFileFromUrl(url: string, fileName: string, isPublic: boolean): Promise { 67 | const response = await fetch(url, { method: 'GET' }); 68 | const buffer = Buffer.from(await response.arrayBuffer()); 69 | return this.uploadFile({ file: buffer as any, fileName, isPublic: isPublic }); 70 | } 71 | 72 | private generateBlobName(fileName: string, blobName: string): string { 73 | if (blobName) { 74 | return blobName; 75 | } 76 | return generateUniqueName(fileName); 77 | } 78 | 79 | public async uploadFile(data: UploadFileType): Promise { 80 | const { file, fileName, mimetype, blobName } = data; 81 | const blobOptions = mimetype === SVG_FILE_TYPE ? { ContentType: SVG_FILE_TYPE } : {}; 82 | let uniqueBlobName: string; 83 | try { 84 | if (file instanceof Buffer) { 85 | uniqueBlobName = this.generateBlobName(fileName || '', blobName); 86 | const command = new PutObjectCommand({ 87 | ...blobOptions, 88 | Key: uniqueBlobName, 89 | Bucket: this.account.containerName, 90 | Body: file, 91 | ACL: data.isPublic ? 'public-read' : undefined 92 | }); 93 | await this.containerClient.send(command); 94 | } else { 95 | uniqueBlobName = this.generateBlobName( 96 | fileName || this.getFileNameToUpload(file as Express.Multer.File), 97 | blobName 98 | ); 99 | if (file.buffer) { 100 | const command = new PutObjectCommand({ 101 | ...blobOptions, 102 | Key: uniqueBlobName, 103 | Bucket: this.account.containerName, 104 | Body: file.buffer as Buffer, 105 | ACL: data.isPublic ? 'public-read' : undefined 106 | }); 107 | await this.containerClient.send(command); 108 | } else { 109 | const readFile = util.promisify(fs.readFile); 110 | const fileContent = await readFile((file as Express.Multer.File).path); 111 | const command = new PutObjectCommand({ 112 | Key: uniqueBlobName, 113 | Bucket: this.account.containerName, 114 | Body: fileContent, 115 | ContentType: (file as Express.Multer.File).mimetype ?? 'application/octet-stream' 116 | }); 117 | await this.containerClient.send(command); 118 | } 119 | } 120 | return { 121 | containerName: this.account.containerName, 122 | blobName: uniqueBlobName 123 | }; 124 | } catch (e) { 125 | console.error(e); 126 | throw new InternalServerErrorException(e); 127 | } 128 | } 129 | 130 | async deleteIfExists(blobName: string): Promise { 131 | if (!blobName) return; 132 | const command = new DeleteObjectCommand({ 133 | Bucket: this.account.containerName, 134 | Key: blobName 135 | }); 136 | 137 | await this.containerClient.send(command); 138 | } 139 | 140 | public generatePresignedUrl( 141 | blobName: string, 142 | expiresOn = dayjs().add(this.account.expiredIn, 'second').toDate(), 143 | options = {} 144 | ): Promise { 145 | if (!blobName) { 146 | return; 147 | } 148 | const sasOptions = { 149 | Bucket: this.account.containerName, 150 | Key: blobName, 151 | ...options 152 | }; 153 | 154 | const command = new GetObjectCommand(sasOptions); 155 | 156 | return getSignedUrl(this.containerClient, command, { 157 | expiresIn: dayjs(expiresOn).diff(dayjs(new Date()), 'second') 158 | }); 159 | } 160 | 161 | async uploadBlobreadable(readable: Readable, blobName: string) { 162 | const command = new PutObjectCommand({ 163 | Key: blobName, 164 | Bucket: this.account.containerName, 165 | Body: readable 166 | }); 167 | await this.containerClient.send(command); 168 | } 169 | 170 | async createBufferFromBlob(blobName: string): Promise { 171 | return this.streamToBuffer(await this.getFileStream(blobName)); 172 | } 173 | 174 | public async getFileStream(blobName: string): Promise { 175 | const command = new GetObjectCommand({ 176 | Key: blobName, 177 | Bucket: this.account.containerName 178 | }); 179 | 180 | const blobClient = await this.containerClient.send(command); 181 | 182 | if (blobClient.Body instanceof ReadableStream) { 183 | return blobClient.Body; 184 | } 185 | 186 | return ReadableStream.from(blobClient.Body as any); 187 | } 188 | 189 | public async getFileBuffer(blobName: string): Promise { 190 | const fileStream = await this.getFileStream(blobName); 191 | return this.streamToBuffer(fileStream); 192 | } 193 | 194 | async getProperties(blobName: string): Promise { 195 | const command = new GetObjectCommand({ 196 | Bucket: this.account.containerName, 197 | Key: blobName 198 | }); 199 | const blobClient = await this.containerClient.send(command); 200 | return { 201 | contentType: blobClient.ContentType || '', 202 | contentLength: blobClient.ContentLength || 0, 203 | etag: blobClient.ETag || '' 204 | }; 205 | } 206 | 207 | public async deleteFile(blobName: string): Promise { 208 | throw new TypeError('Implement later'); 209 | } 210 | 211 | getPublicUrl(blobName: string): string { 212 | return `https://${this.account.containerName}.s3.amazonaws.com/${blobName}`; 213 | } 214 | } 215 | --------------------------------------------------------------------------------