@ackplus/nest-file-storage 1.1.22 โ†’ 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,52 +1,72 @@
1
1
  # @ackplus/nest-file-storage
2
2
 
3
- A flexible and feature-rich file storage solution for NestJS applications with support for Local, AWS S3, and Azure Blob Storage.
3
+ `@ackplus/nest-file-storage` is a NestJS file upload and storage library for Express-based applications. It connects Multer uploads to Local storage, AWS S3, or Azure Blob Storage and gives you one common runtime API for upload, read, delete, copy, and URL generation.
4
4
 
5
- ## โœจ Features
5
+ This README is the canonical developer guide for using the package in an application.
6
6
 
7
- - ๐Ÿ“ฆ **Multiple Storage Providers** - Local, AWS S3, and Azure Blob Storage support
8
- - ๐Ÿ”„ **Easy Switching** - Switch between storage providers with minimal configuration
9
- - ๐ŸŽฏ **NestJS Integration** - Seamless integration with NestJS decorators and interceptors
10
- - ๐Ÿ“ **File Operations** - Upload, download, delete, copy files with ease
11
- - ๐Ÿ” **Signed URLs** - Generate presigned URLs for secure file access (S3)
12
- - ๐ŸŽจ **Customizable** - Custom file naming, directory structure, and transformations
13
- - ๐Ÿ“ **TypeScript** - Full TypeScript support with type safety
14
- - ๐Ÿงช **Test-Friendly** - Easy to mock and test
7
+ ## What You Get
15
8
 
16
- ## ๐Ÿ“ฆ Installation
9
+ - `NestFileStorageModule` to register the default storage provider.
10
+ - `FileStorageInterceptor()` to accept multipart uploads in controllers.
11
+ - `FileStorageService` to work with files programmatically.
12
+ - Built-in storage adapters for `local`, `s3`, and `azure`.
13
+ - Request-body mapping so uploaded file keys or metadata can flow into your DTO/service layer.
14
+
15
+ ## Compatibility And Prerequisites
16
+
17
+ This library is designed for NestJS on the Express platform.
18
+
19
+ Required in the consuming app:
20
+
21
+ - `@nestjs/common`
22
+ - `@nestjs/core`
23
+ - `@nestjs/platform-express`
24
+ - `multer`
25
+ - `reflect-metadata`
26
+
27
+ Peer dependency ranges exported by the package:
28
+
29
+ - `@nestjs/common`: `^10.0.0 || ^11.0.0`
30
+ - `@nestjs/core`: `^10.0.0 || ^11.0.0`
31
+ - `multer`: `^1.4.5-lts.1 || ^2.0.0`
32
+ - `reflect-metadata`: `^0.2.2`
33
+
34
+ Optional provider packages:
35
+
36
+ - AWS S3: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`
37
+ - Azure Blob Storage: `@azure/storage-blob`
38
+
39
+ ## Installation
17
40
 
18
41
  ```bash
19
- npm install @ackplus/nest-file-storage
20
- # or
21
- pnpm add @ackplus/nest-file-storage
22
- # or
23
- yarn add @ackplus/nest-file-storage
42
+ pnpm add @ackplus/nest-file-storage multer reflect-metadata
24
43
  ```
25
44
 
26
- **For AWS S3 support:**
45
+ If your Nest app does not already include the Express platform package:
27
46
 
28
47
  ```bash
29
- npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
48
+ pnpm add @nestjs/platform-express
30
49
  ```
31
50
 
32
- **For Azure Blob Storage support:**
51
+ For AWS S3 support:
33
52
 
34
53
  ```bash
35
- npm install @azure/storage-blob
54
+ pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
36
55
  ```
37
56
 
38
- ## ๐Ÿš€ Quick Start
57
+ For Azure Blob Storage support:
39
58
 
40
- ### Step 1: Configure Module
59
+ ```bash
60
+ pnpm add @azure/storage-blob
61
+ ```
41
62
 
42
- Choose your storage provider and configure the module:
63
+ ## Register The Module
43
64
 
44
- #### Local Storage
65
+ ### Local storage
45
66
 
46
- ```typescript
47
- // app.module.ts
67
+ ```ts
48
68
  import { Module } from '@nestjs/common';
49
- import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-storage';
69
+ import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
50
70
 
51
71
  @Module({
52
72
  imports: [
@@ -62,22 +82,22 @@ import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-stora
62
82
  export class AppModule {}
63
83
  ```
64
84
 
65
- #### AWS S3
85
+ ### AWS S3
66
86
 
67
- ```typescript
68
- // app.module.ts
87
+ ```ts
69
88
  import { Module } from '@nestjs/common';
70
- import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-storage';
89
+ import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
71
90
 
72
91
  @Module({
73
92
  imports: [
74
93
  NestFileStorageModule.forRoot({
75
94
  storage: FileStorageEnum.S3,
76
95
  s3Config: {
77
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
78
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
79
- region: process.env.AWS_REGION,
80
- bucket: process.env.AWS_BUCKET,
96
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
97
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
98
+ region: process.env.AWS_REGION!,
99
+ bucket: process.env.AWS_BUCKET!,
100
+ cloudFrontUrl: process.env.AWS_CLOUDFRONT_URL,
81
101
  },
82
102
  }),
83
103
  ],
@@ -85,21 +105,20 @@ import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-stora
85
105
  export class AppModule {}
86
106
  ```
87
107
 
88
- #### Azure Blob Storage
108
+ ### Azure Blob Storage
89
109
 
90
- ```typescript
91
- // app.module.ts
110
+ ```ts
92
111
  import { Module } from '@nestjs/common';
93
- import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-storage';
112
+ import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
94
113
 
95
114
  @Module({
96
115
  imports: [
97
116
  NestFileStorageModule.forRoot({
98
117
  storage: FileStorageEnum.AZURE,
99
118
  azureConfig: {
100
- account: process.env.AZURE_STORAGE_ACCOUNT,
101
- accountKey: process.env.AZURE_STORAGE_KEY,
102
- container: process.env.AZURE_CONTAINER,
119
+ account: process.env.AZURE_STORAGE_ACCOUNT!,
120
+ accountKey: process.env.AZURE_STORAGE_KEY!,
121
+ container: process.env.AZURE_CONTAINER!,
103
122
  },
104
123
  }),
105
124
  ],
@@ -107,557 +126,590 @@ import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-stora
107
126
  export class AppModule {}
108
127
  ```
109
128
 
110
- ### Step 2: Upload Files in Controller
129
+ ### Async configuration
130
+
131
+ ```ts
132
+ import { Module } from '@nestjs/common';
133
+ import { ConfigModule, ConfigService } from '@nestjs/config';
134
+ import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
135
+
136
+ @Module({
137
+ imports: [
138
+ ConfigModule.forRoot(),
139
+ NestFileStorageModule.forRootAsync({
140
+ imports: [ConfigModule],
141
+ inject: [ConfigService],
142
+ useFactory: async (config: ConfigService) => ({
143
+ storage: FileStorageEnum.S3,
144
+ s3Config: {
145
+ accessKeyId: config.getOrThrow('AWS_ACCESS_KEY_ID'),
146
+ secretAccessKey: config.getOrThrow('AWS_SECRET_ACCESS_KEY'),
147
+ region: config.getOrThrow('AWS_REGION'),
148
+ bucket: config.getOrThrow('AWS_BUCKET'),
149
+ },
150
+ }),
151
+ }),
152
+ ],
153
+ })
154
+ export class AppModule {}
155
+ ```
156
+
157
+ ### Example: build module options from one shared storage config
158
+
159
+ If your app stores provider credentials in one common config record, map that record into the module options once and return the correct provider config.
160
+
161
+ ```ts
162
+ import * as path from 'path';
163
+ import { FileStorageEnum } from '@ackplus/nest-file-storage';
164
+
165
+ function buildFileStorageOptions(config: {
166
+ type: FileStorageEnum;
167
+ endpoint?: string;
168
+ accessKeyId?: string;
169
+ secretAccessKey?: string;
170
+ region?: string;
171
+ bucket?: string;
172
+ cloudFrontUrl?: string;
173
+ }, appConfig: { appUrl: string }) {
174
+ if (config.type === FileStorageEnum.S3) {
175
+ return {
176
+ storage: FileStorageEnum.S3,
177
+ s3Config: {
178
+ endpoint: config.endpoint,
179
+ accessKeyId: config.accessKeyId!,
180
+ secretAccessKey: config.secretAccessKey!,
181
+ region: config.region!,
182
+ bucket: config.bucket!,
183
+ cloudFrontUrl: config.cloudFrontUrl,
184
+ },
185
+ };
186
+ }
187
+
188
+ if (config.type === FileStorageEnum.AZURE) {
189
+ return {
190
+ storage: FileStorageEnum.AZURE,
191
+ azureConfig: {
192
+ account: config.accessKeyId!,
193
+ accountKey: config.secretAccessKey!,
194
+ container: config.bucket!,
195
+ },
196
+ };
197
+ }
198
+
199
+ return {
200
+ storage: FileStorageEnum.LOCAL,
201
+ localConfig: {
202
+ rootPath: path.join(process.cwd(), 'public'),
203
+ baseUrl: `${appConfig.appUrl}/public`,
204
+ },
205
+ };
206
+ }
207
+ ```
208
+
209
+ This pattern is useful when:
210
+
211
+ - S3 uses `endpoint`, `accessKeyId`, `secretAccessKey`, `region`, `bucket`, and optional `cloudFrontUrl`.
212
+ - Azure reuses the same credential source but maps `accessKeyId -> account`, `secretAccessKey -> accountKey`, and `bucket -> container`.
213
+ - Local storage falls back to a public directory such as `process.cwd()/public`.
214
+
215
+ ## Local Storage Note: `baseUrl` Does Not Serve Files
216
+
217
+ For local storage, `baseUrl` only controls the URL string returned by `getUrl()` and upload metadata. It does not expose the directory over HTTP by itself.
218
+
219
+ If you want browser-accessible local files, add static serving in your Nest app:
220
+
221
+ ```bash
222
+ pnpm add @nestjs/serve-static
223
+ ```
224
+
225
+ ```ts
226
+ import { Module } from '@nestjs/common';
227
+ import { ServeStaticModule } from '@nestjs/serve-static';
228
+ import { join } from 'path';
229
+
230
+ @Module({
231
+ imports: [
232
+ ServeStaticModule.forRoot({
233
+ rootPath: join(process.cwd(), 'uploads'),
234
+ serveRoot: '/uploads',
235
+ }),
236
+ ],
237
+ })
238
+ export class AppModule {}
239
+ ```
111
240
 
112
- ```typescript
113
- // upload.controller.ts
114
- import { Controller, Post, UseInterceptors } from '@nestjs/common';
241
+ Without this, local files can still be downloaded through your own controller endpoints.
242
+
243
+ ## Upload Files In Controllers
244
+
245
+ `FileStorageInterceptor()` wraps Multer and uploads files into the configured storage engine before your route handler runs.
246
+
247
+ All upload routes must accept `multipart/form-data`.
248
+
249
+ ### Default request-body mapping
250
+
251
+ By default, the interceptor writes storage keys into `request.body`.
252
+
253
+ | Upload mode | Accepted form field(s) | Default `body` value |
254
+ | --- | --- | --- |
255
+ | `FileStorageInterceptor('file')` | `file` | `body.file: string` |
256
+ | `type: 'array'` | repeated `files` or `files[0]`, `files[1]`, ... | `body.files: string[]` |
257
+ | `type: 'fields'` | each configured field name | `body[fieldName]: string[]` |
258
+
259
+ Important behavior for `fields` mode:
260
+
261
+ - Every configured field is mapped to an array.
262
+ - That stays true even when `maxCount: 1`.
263
+
264
+ DTO note:
265
+
266
+ - Because the interceptor writes into `request.body` before your route handler executes, your DTO shape should match the mapped value.
267
+ - Typical DTO fields are `string` for single uploads, `string[]` for array uploads, and custom object shapes when you use `mapToRequestBody`.
268
+
269
+ ### Single file upload
270
+
271
+ ```ts
272
+ import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
115
273
  import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
116
274
 
117
- @Controller('upload')
118
- export class UploadController {
119
- // Single file upload
275
+ @Controller('files')
276
+ export class FilesController {
120
277
  @Post('single')
121
278
  @UseInterceptors(FileStorageInterceptor('file'))
122
- uploadSingle(@Body() body: any) {
123
- // File key is automatically added to body.file
279
+ uploadSingle(@Body() body: { file: string }) {
124
280
  return {
125
- message: 'File uploaded successfully',
126
- fileKey: body.file,
281
+ key: body.file,
127
282
  };
128
283
  }
284
+ }
285
+ ```
129
286
 
130
- // Multiple files upload
287
+ ### Multiple files in one field
288
+
289
+ ```ts
290
+ import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
291
+ import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
292
+
293
+ @Controller('files')
294
+ export class FilesController {
131
295
  @Post('multiple')
132
296
  @UseInterceptors(
133
297
  FileStorageInterceptor({
134
298
  type: 'array',
135
299
  fieldName: 'files',
136
300
  maxCount: 10,
137
- })
301
+ }),
138
302
  )
139
- uploadMultiple(@Body() body: any) {
140
- // File keys are automatically added to body.files as array
303
+ uploadMultiple(@Body() body: { files: string[] }) {
141
304
  return {
142
- message: 'Files uploaded successfully',
143
- fileKeys: body.files,
305
+ keys: body.files,
306
+ count: body.files.length,
144
307
  };
145
308
  }
309
+ }
310
+ ```
146
311
 
147
- // Multiple fields
312
+ ### Multiple named fields
313
+
314
+ ```ts
315
+ import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
316
+ import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
317
+
318
+ @Controller('files')
319
+ export class FilesController {
148
320
  @Post('fields')
149
321
  @UseInterceptors(
150
322
  FileStorageInterceptor({
151
323
  type: 'fields',
152
324
  fields: [
153
325
  { name: 'avatar', maxCount: 1 },
154
- { name: 'photos', maxCount: 5 },
326
+ { name: 'attachments', maxCount: 5 },
155
327
  ],
156
- })
328
+ }),
157
329
  )
158
- uploadFields(@Body() body: any) {
159
- return {
160
- message: 'Files uploaded successfully',
161
- avatar: body.avatar,
162
- photos: body.photos,
163
- };
330
+ uploadFields(@Body() body: { avatar: string[]; attachments: string[] }) {
331
+ return body;
164
332
  }
165
333
  }
166
334
  ```
167
335
 
168
- ### Step 3: Use File Storage Service
336
+ ## Validation
169
337
 
170
- ```typescript
171
- // file.service.ts
172
- import { Injectable } from '@nestjs/common';
173
- import { FileStorageService } from '@ackplus/nest-file-storage';
338
+ Use the interceptor options for validation, not the module registration.
174
339
 
175
- @Injectable()
176
- export class FileService {
177
- // Get file
178
- async getFile(key: string): Promise<Buffer> {
179
- const storage = await FileStorageService.getStorage();
180
- return await storage.getFile(key);
181
- }
340
+ ### File size and mime type validation
182
341
 
183
- // Delete file
184
- async deleteFile(key: string): Promise<void> {
185
- const storage = await FileStorageService.getStorage();
186
- await storage.deleteFile(key);
187
- }
188
-
189
- // Copy file
190
- async copyFile(oldKey: string, newKey: string) {
191
- const storage = await FileStorageService.getStorage();
192
- return await storage.copyFile(oldKey, newKey);
193
- }
342
+ Use `multerOptions()` when you want Multer to reject invalid uploads before your route handler runs.
194
343
 
195
- // Get public URL
196
- async getFileUrl(key: string): Promise<string> {
197
- const storage = await FileStorageService.getStorage();
198
- return storage.getUrl(key);
199
- }
344
+ ```ts
345
+ import {
346
+ BadRequestException,
347
+ Body,
348
+ Controller,
349
+ Post,
350
+ UseInterceptors,
351
+ } from '@nestjs/common';
352
+ import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
200
353
 
201
- // Get signed URL (S3 only)
202
- async getSignedUrl(key: string): Promise<string> {
203
- const storage = await FileStorageService.getStorage();
204
- if ('getSignedUrl' in storage) {
205
- return await storage.getSignedUrl(key, { expiresIn: 3600 });
206
- }
207
- return storage.getUrl(key);
354
+ @Controller('images')
355
+ export class ImagesController {
356
+ @Post()
357
+ @UseInterceptors(
358
+ FileStorageInterceptor('image', {
359
+ multerOptions: () => ({
360
+ limits: {
361
+ fileSize: 5 * 1024 * 1024,
362
+ },
363
+ fileFilter: (_req, file, cb) => {
364
+ const allowed = ['image/jpeg', 'image/png', 'image/webp'];
365
+ if (!allowed.includes(file.mimetype)) {
366
+ return cb(new BadRequestException('Only JPEG, PNG, and WebP files are allowed'), false);
367
+ }
368
+ cb(null, true);
369
+ },
370
+ }),
371
+ fileDist: () => 'images',
372
+ }),
373
+ )
374
+ upload(@Body() body: { image: string }) {
375
+ return body;
208
376
  }
209
377
  }
210
378
  ```
211
379
 
212
- ## ๐Ÿ“š Configuration Options
380
+ ### Cross-field or post-upload validation
213
381
 
214
- ### Local Storage Options
382
+ Use `afterUpload()` when validation depends on the final uploaded file list or multiple fields together.
215
383
 
216
- ```typescript
217
- interface LocalStorageOptions {
218
- rootPath: string; // Directory to store files
219
- baseUrl: string; // Base URL for file access
220
- prefix?: string; // Optional prefix for file keys
221
- fileName?: (file: any, req: Request) => string; // Custom file naming
222
- fileDist?: (file: any, req: Request) => string; // Custom directory structure
223
- transformUploadedFileObject?: (file: any) => any; // Transform uploaded file object
224
- }
225
- ```
384
+ ```ts
385
+ import { BadRequestException, Body, Controller, Post, UseInterceptors } from '@nestjs/common';
386
+ import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
226
387
 
227
- ### S3 Storage Options
228
-
229
- ```typescript
230
- interface S3StorageOptions {
231
- accessKeyId: string; // AWS access key
232
- secretAccessKey: string; // AWS secret key
233
- region: string; // AWS region
234
- bucket: string; // S3 bucket name
235
- endpoint?: string; // Custom S3 endpoint (for S3-compatible services)
236
- cloudFrontUrl?: string; // CloudFront distribution URL
237
- prefix?: string; // Optional prefix for file keys
238
- fileName?: (file: any, req: Request) => string; // Custom file naming
239
- fileDist?: (file: any, req: Request) => string; // Custom directory structure
240
- transformUploadedFileObject?: (file: any) => any; // Transform uploaded file object
388
+ @Controller('gallery')
389
+ export class GalleryController {
390
+ @Post()
391
+ @UseInterceptors(
392
+ FileStorageInterceptor(
393
+ {
394
+ type: 'fields',
395
+ fields: [
396
+ { name: 'cover', maxCount: 1 },
397
+ { name: 'images', maxCount: 10 },
398
+ ],
399
+ },
400
+ {
401
+ afterUpload: (req) => {
402
+ const files = req.files as Record<string, Express.Multer.File[]>;
403
+ const imageCount = files?.images?.length ?? 0;
404
+ if (imageCount < 2) {
405
+ throw new BadRequestException('At least 2 gallery images are required.');
406
+ }
407
+ },
408
+ },
409
+ ),
410
+ )
411
+ create(@Body() body: { cover: string[]; images: string[] }) {
412
+ return body;
413
+ }
241
414
  }
242
415
  ```
243
416
 
244
- ### Azure Storage Options
245
-
246
- ```typescript
247
- interface AzureStorageOptions {
248
- account: string; // Azure storage account name
249
- accountKey: string; // Azure storage account key
250
- container: string; // Container name
251
- prefix?: string; // Optional prefix for file keys
252
- fileName?: (file: any, req: Request) => string; // Custom file naming
253
- fileDist?: (file: any, req: Request) => string; // Custom directory structure
254
- transformUploadedFileObject?: (file: any) => any; // Transform uploaded file object
255
- }
256
- ```
417
+ Practical guidance:
257
418
 
258
- ## ๐ŸŽจ Advanced Usage
259
-
260
- ### Custom File Naming
261
-
262
- ```typescript
263
- NestFileStorageModule.forRoot({
264
- storage: FileStorageEnum.LOCAL,
265
- localConfig: {
266
- rootPath: './uploads',
267
- baseUrl: 'http://localhost:3000/uploads',
268
- fileName: (file, req) => {
269
- // Custom file name with timestamp
270
- const timestamp = Date.now();
271
- const ext = file.originalname.split('.').pop();
272
- return `${timestamp}-${file.originalname}`;
273
- },
274
- },
275
- })
276
- ```
419
+ - Use `limits.fileSize` for upload size limits.
420
+ - Use `fileFilter` for mime-type or extension checks.
421
+ - Use `afterUpload` for rules involving multiple files or fields.
422
+ - Do not rely on `file.size` inside `fileName()`; Multer size limits are the reliable way to cap upload size.
277
423
 
278
- ### Custom Directory Structure
279
-
280
- ```typescript
281
- NestFileStorageModule.forRoot({
282
- storage: FileStorageEnum.LOCAL,
283
- localConfig: {
284
- rootPath: './uploads',
285
- baseUrl: 'http://localhost:3000/uploads',
286
- fileDist: (file, req) => {
287
- // Organize by year/month/day
288
- const date = new Date();
289
- return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
290
- },
291
- },
292
- })
293
- ```
424
+ ## Control The Stored Path And Key
294
425
 
295
- ### Transform Uploaded File Object
296
-
297
- ```typescript
298
- NestFileStorageModule.forRoot({
299
- storage: FileStorageEnum.S3,
300
- s3Config: {
301
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
302
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
303
- region: process.env.AWS_REGION,
304
- bucket: process.env.AWS_BUCKET,
305
- transformUploadedFileObject: (file) => {
306
- // Return only specific fields
307
- return {
308
- key: file.key,
309
- url: file.url,
310
- size: file.size,
311
- mimetype: file.mimetype,
312
- };
313
- },
314
- },
315
- })
316
- ```
426
+ Two callbacks define where the file is stored and what key is saved:
317
427
 
318
- ### Custom File Mapping in Interceptor
428
+ - `fileDist(file, req)`: the directory or path prefix.
429
+ - `fileName(file, req)`: the final filename segment.
319
430
 
320
- ```typescript
321
- @Post('upload')
322
- @UseInterceptors(
323
- FileStorageInterceptor('file', {
324
- mapToRequestBody: (file, fieldName, req) => {
325
- // Return full file object instead of just key
326
- return file;
327
- },
328
- })
329
- )
330
- uploadFile(@Body() body: any) {
331
- // body.file now contains the full file object
332
- return {
333
- message: 'File uploaded',
334
- file: body.file,
335
- };
336
- }
431
+ The final key is effectively:
432
+
433
+ ```text
434
+ <fileDist>/<fileName>
337
435
  ```
338
436
 
339
- ### Async Configuration
340
-
341
- ```typescript
342
- // app.module.ts
343
- NestFileStorageModule.forRootAsync({
344
- imports: [ConfigModule],
345
- useFactory: async (configService: ConfigService) => ({
346
- storage: FileStorageEnum.S3,
347
- s3Config: {
348
- accessKeyId: configService.get('AWS_ACCESS_KEY_ID'),
349
- secretAccessKey: configService.get('AWS_SECRET_ACCESS_KEY'),
350
- region: configService.get('AWS_REGION'),
351
- bucket: configService.get('AWS_BUCKET'),
352
- },
353
- }),
354
- inject: [ConfigService],
355
- })
437
+ ### Default key generation
438
+
439
+ If you do not override anything:
440
+
441
+ - Local storage stores files under `rootPath/YYYY/MM/DD`.
442
+ - S3 and Azure store files under `uploads/YYYY/MM/DD`.
443
+ - The default filename is `uuid-originalname`.
444
+
445
+ Examples:
446
+
447
+ - Local key: `2026/04/23/2d0b8f6e-report.pdf`
448
+ - S3 key: `uploads/2026/04/23/2d0b8f6e-report.pdf`
449
+
450
+ ### Custom path and filename
451
+
452
+ ```ts
453
+ import { extname } from 'path';
454
+
455
+ FileStorageInterceptor('avatar', {
456
+ fileDist: (_file, req) => `users/${req.user.id}/avatars`,
457
+ fileName: (file) => `${Date.now()}${extname(file.originalname)}`,
458
+ });
356
459
  ```
357
460
 
358
- ### Dynamic Storage Type
359
-
360
- ```typescript
361
- // Override storage type per route
362
- @Post('upload-to-s3')
363
- @UseInterceptors(
364
- FileStorageInterceptor('file', {
365
- storageType: FileStorageEnum.S3,
366
- })
367
- )
368
- uploadToS3(@Body() body: any) {
369
- return { fileKey: body.file };
370
- }
461
+ This gives you keys such as:
462
+
463
+ ```text
464
+ users/42/avatars/1713876155123.png
371
465
  ```
372
466
 
373
- ## ๐Ÿ”ฅ Complete Examples
467
+ Use `fileDist` for folder structure and `fileName` for the last path segment. That is the most reliable way to control saved keys in the current implementation.
374
468
 
375
- ### Image Upload with Validation
469
+ ## Map Upload Results Into `request.body`
376
470
 
377
- ```typescript
378
- import { Controller, Post, UseInterceptors, BadRequestException } from '@nestjs/common';
471
+ The default mapping stores only keys. If you want richer metadata in the controller body, use `mapToRequestBody`.
472
+
473
+ ### Return metadata instead of just the key
474
+
475
+ ```ts
476
+ import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
379
477
  import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
380
478
 
381
- @Controller('images')
382
- export class ImageController {
383
- @Post('upload')
479
+ @Controller('documents')
480
+ export class DocumentsController {
481
+ @Post()
384
482
  @UseInterceptors(
385
- FileStorageInterceptor('image', {
386
- fileName: (file, req) => {
387
- // Validate image type
388
- const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
389
- if (!allowedTypes.includes(file.mimetype)) {
390
- throw new BadRequestException('Only image files are allowed');
391
- }
392
-
393
- // Generate unique filename
394
- const timestamp = Date.now();
395
- const ext = file.originalname.split('.').pop();
396
- return `image-${timestamp}.${ext}`;
397
- },
398
- fileDist: (file, req) => {
399
- // Organize by year/month
400
- const date = new Date();
401
- return `images/${date.getFullYear()}/${date.getMonth() + 1}`;
402
- },
403
- })
483
+ FileStorageInterceptor('document', {
484
+ mapToRequestBody: (file) => ({
485
+ key: file.key,
486
+ url: file.url,
487
+ size: file.size,
488
+ mimetype: file.mimetype,
489
+ originalName: file.originalName,
490
+ fullPath: file.fullPath,
491
+ }),
492
+ }),
404
493
  )
405
- async uploadImage(@Body() body: any) {
406
- return {
407
- message: 'Image uploaded successfully',
408
- imageKey: body.image,
409
- };
494
+ upload(@Body() body: any) {
495
+ return body.document;
410
496
  }
411
497
  }
412
498
  ```
413
499
 
414
- ### User Avatar Upload
500
+ ### Preserve an existing body field
415
501
 
416
- ```typescript
417
- import { Controller, Post, UseInterceptors, Body } from '@nestjs/common';
418
- import { FileStorageInterceptor, FileStorageService } from '@ackplus/nest-file-storage';
502
+ If the request already contains a JSON/text field with the same name and you only want to populate it when missing:
419
503
 
420
- @Controller('users')
421
- export class UserController {
422
- constructor(private readonly userService: UserService) {}
504
+ ```ts
505
+ FileStorageInterceptor('file', {
506
+ overwriteBodyField: false,
507
+ });
508
+ ```
423
509
 
424
- @Post('avatar')
425
- @UseInterceptors(
426
- FileStorageInterceptor('avatar', {
427
- fileName: (file, req) => {
428
- const userId = req.user.id; // Assuming user from auth guard
429
- const ext = file.originalname.split('.').pop();
430
- return `avatar-${userId}.${ext}`;
431
- },
432
- fileDist: () => 'avatars',
433
- })
434
- )
435
- async uploadAvatar(@Body() body: any, @Request() req) {
436
- // Delete old avatar if exists
437
- const user = await this.userService.findById(req.user.id);
438
- if (user.avatarKey) {
439
- const storage = await FileStorageService.getStorage();
440
- await storage.deleteFile(user.avatarKey);
441
- }
510
+ ## Use `FileStorageService` Programmatically
442
511
 
443
- // Update user with new avatar
444
- await this.userService.updateAvatar(req.user.id, body.avatar);
512
+ `FileStorageService.getStorage()` gives you the active storage implementation so you can upload or manage files outside controller interceptors.
445
513
 
446
- return {
447
- message: 'Avatar updated successfully',
448
- avatarKey: body.avatar,
449
- };
450
- }
451
- }
452
- ```
514
+ ```ts
515
+ import { Injectable } from '@nestjs/common';
516
+ import { FileStorageService } from '@ackplus/nest-file-storage';
453
517
 
454
- ### Document Management
518
+ @Injectable()
519
+ export class DocumentStorageService {
520
+ async upload(buffer: Buffer, key: string) {
521
+ const storage = await FileStorageService.getStorage();
522
+ return storage.putFile(buffer, key);
523
+ }
455
524
 
456
- ```typescript
457
- import { Controller, Get, Post, Delete, Param, UseInterceptors, Body } from '@nestjs/common';
458
- import { FileStorageInterceptor, FileStorageService } from '@ackplus/nest-file-storage';
525
+ async get(key: string) {
526
+ const storage = await FileStorageService.getStorage();
527
+ return storage.getFile(key);
528
+ }
459
529
 
460
- @Controller('documents')
461
- export class DocumentController {
462
- @Post('upload')
463
- @UseInterceptors(
464
- FileStorageInterceptor({
465
- type: 'array',
466
- fieldName: 'documents',
467
- maxCount: 10,
468
- }, {
469
- fileDist: () => 'documents',
470
- mapToRequestBody: (files, fieldName) => {
471
- // Return detailed file info
472
- return files;
473
- },
474
- })
475
- )
476
- async uploadDocuments(@Body() body: any) {
477
- return {
478
- message: `${body.documents.length} documents uploaded`,
479
- documents: body.documents,
480
- };
530
+ async remove(key: string) {
531
+ const storage = await FileStorageService.getStorage();
532
+ await storage.deleteFile(key);
481
533
  }
482
534
 
483
- @Get(':key/download')
484
- async downloadDocument(@Param('key') key: string, @Res() res) {
535
+ async copy(oldKey: string, newKey: string) {
485
536
  const storage = await FileStorageService.getStorage();
486
- const file = await storage.getFile(key);
487
-
488
- res.setHeader('Content-Type', 'application/octet-stream');
489
- res.setHeader('Content-Disposition', `attachment; filename="${key}"`);
490
- res.send(file);
537
+ return storage.copyFile(oldKey, newKey);
491
538
  }
492
539
 
493
- @Delete(':key')
494
- async deleteDocument(@Param('key') key: string) {
540
+ async url(key: string) {
495
541
  const storage = await FileStorageService.getStorage();
496
- await storage.deleteFile(key);
497
-
498
- return { message: 'Document deleted successfully' };
542
+ return storage.getUrl(key);
499
543
  }
500
544
 
501
- @Get(':key/url')
502
- async getDocumentUrl(@Param('key') key: string) {
545
+ async signedUrl(key: string) {
503
546
  const storage = await FileStorageService.getStorage();
504
- const url = storage.getUrl(key);
505
-
506
- return { url };
547
+ if ('getSignedUrl' in storage && storage.getSignedUrl) {
548
+ return storage.getSignedUrl(key, { expiresIn: 3600 });
549
+ }
550
+ return storage.getUrl(key);
507
551
  }
508
552
  }
509
553
  ```
510
554
 
511
- ## ๐Ÿ“š API Reference
555
+ ### Get full URL from a stored key/path
512
556
 
513
- ### FileStorageService
557
+ If you save only the storage key in your database, generate the public URL later with `getUrl(key)`. This works across Local, S3, and Azure.
514
558
 
515
- ```typescript
516
- class FileStorageService {
517
- // Get storage instance
518
- static async getStorage(storageType?: FileStorageEnum): Promise<Storage>
519
-
520
- // Get module options
521
- static getOptions(): FileStorageModuleOptions
522
-
523
- // Set module options
524
- static setOptions(options: FileStorageModuleOptions): void
559
+ ```ts
560
+ import { Injectable } from '@nestjs/common';
561
+ import { FileStorageService } from '@ackplus/nest-file-storage';
562
+
563
+ @Injectable()
564
+ export class FileUrlService {
565
+ async getUrlFromKey(key?: string | null): Promise<string | null> {
566
+ if (!key) {
567
+ return null;
568
+ }
569
+
570
+ const storage = await FileStorageService.getStorage();
571
+ return await Promise.resolve(storage.getUrl(key));
572
+ }
525
573
  }
526
574
  ```
527
575
 
528
- ### Storage Interface
529
-
530
- ```typescript
531
- interface Storage {
532
- // Get file content as Buffer
533
- getFile(key: string): Promise<Buffer> | Buffer
534
-
535
- // Delete file
536
- deleteFile(key: string): Promise<void> | void
537
-
538
- // Upload file
539
- putFile(fileContent: Buffer, key: string): Promise<UploadedFile> | UploadedFile
540
-
541
- // Copy file
542
- copyFile(oldKey: string, newKey: string): Promise<UploadedFile>
543
-
544
- // Get file URL
545
- getUrl(key: string): Promise<string> | string
546
-
547
- // Get signed URL (S3 only)
548
- getSignedUrl?(key: string, options: any): Promise<string> | string
549
-
550
- // Get file path (Local only)
551
- path?(filePath: string): Promise<string> | string
576
+ ### Add full URLs after loading entities
577
+
578
+ The usual pattern is to store only `fileKey` in the entity and attach the final URL after fetching data.
579
+
580
+ ```ts
581
+ import { Injectable } from '@nestjs/common';
582
+ import { FileStorageService } from '@ackplus/nest-file-storage';
583
+
584
+ type UserRecord = {
585
+ id: number;
586
+ avatarKey?: string | null;
587
+ };
588
+
589
+ @Injectable()
590
+ export class UsersResponseMapper {
591
+ async mapUser(user: UserRecord) {
592
+ const storage = await FileStorageService.getStorage();
593
+
594
+ return {
595
+ ...user,
596
+ avatarUrl: user.avatarKey
597
+ ? await Promise.resolve(storage.getUrl(user.avatarKey))
598
+ : null,
599
+ };
600
+ }
552
601
  }
553
602
  ```
554
603
 
555
- ### FileStorageInterceptor
556
-
557
- ```typescript
558
- // Single file upload
559
- FileStorageInterceptor(
560
- fieldName: string,
561
- options?: FileStorageInterceptorOptions
562
- )
563
-
564
- // Multiple files or fields
565
- FileStorageInterceptor(
566
- config: {
567
- type: 'single' | 'array' | 'fields';
568
- fieldName?: string;
569
- maxCount?: number;
570
- fields?: { name: string; maxCount?: number }[];
571
- },
572
- options?: FileStorageInterceptorOptions
573
- )
574
- ```
604
+ For entity-based applications:
605
+
606
+ - Store the key/path in the entity, for example `avatarKey` or `documentKey`.
607
+ - Build the full URL in a service, mapper, serializer, or response DTO step after loading the entity.
608
+ - This is usually cleaner than doing URL generation inside ORM entity hooks, because `FileStorageService.getStorage()` is async.
609
+
610
+ ### Provider capability summary
611
+
612
+ | Method | Local | S3 | Azure |
613
+ | --- | --- | --- | --- |
614
+ | `putFile()` | yes | yes | yes |
615
+ | `getFile()` | yes | yes | yes |
616
+ | `deleteFile()` | yes | yes | yes |
617
+ | `copyFile()` | yes | yes | yes |
618
+ | `getUrl()` | yes | yes | yes |
619
+ | `getSignedUrl()` | no | yes | yes |
620
+ | `path()` | yes | no | no |
621
+
622
+ ## Route-Level Storage Override
575
623
 
576
- ### UploadedFile Interface
577
-
578
- ```typescript
579
- interface UploadedFile {
580
- fieldName?: string; // Form field name
581
- fileName: string; // Generated file name
582
- originalName: string; // Original file name
583
- size: number; // File size in bytes
584
- mimetype?: string; // MIME type
585
- buffer?: Buffer; // File buffer (optional)
586
- key: string; // Storage key/path
587
- url: string; // Public URL
588
- fullPath: string; // Full storage path
589
- encoding?: string; // File encoding
624
+ You can override the storage provider per route.
625
+
626
+ ```ts
627
+ import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
628
+ import { FileStorageEnum, FileStorageInterceptor } from '@ackplus/nest-file-storage';
629
+
630
+ @Controller('avatars')
631
+ export class AvatarController {
632
+ @Post()
633
+ @UseInterceptors(
634
+ FileStorageInterceptor('avatar', {
635
+ storageType: FileStorageEnum.S3,
636
+ storageOptions: {
637
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
638
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
639
+ region: process.env.AWS_REGION!,
640
+ bucket: process.env.AWS_BUCKET!,
641
+ fileDist: (_file, req) => `users/${req.user.id}/avatars`,
642
+ },
643
+ mapToRequestBody: (file) => ({
644
+ key: file.key,
645
+ url: file.url,
646
+ }),
647
+ }),
648
+ )
649
+ upload(@Body() body: any) {
650
+ return body.avatar;
651
+ }
590
652
  }
591
653
  ```
592
654
 
593
- ## ๐Ÿงช Testing
594
-
595
- ```typescript
596
- import { Test, TestingModule } from '@nestjs/testing';
597
- import { NestFileStorageModule, FileStorageService, FileStorageEnum } from '@ackplus/nest-file-storage';
598
-
599
- describe('FileService', () => {
600
- let service: FileService;
601
- let storage: Storage;
602
-
603
- beforeEach(async () => {
604
- const module: TestingModule = await Test.createTestingModule({
605
- imports: [
606
- NestFileStorageModule.forRoot({
607
- storage: FileStorageEnum.LOCAL,
608
- localConfig: {
609
- rootPath: './test-uploads',
610
- baseUrl: 'http://localhost:3000/test-uploads',
611
- },
612
- }),
613
- ],
614
- providers: [FileService],
615
- }).compile();
616
-
617
- service = module.get<FileService>(FileService);
618
- storage = await FileStorageService.getStorage();
619
- });
620
-
621
- it('should upload file', async () => {
622
- const buffer = Buffer.from('test content');
623
- const result = await storage.putFile(buffer, 'test/file.txt');
624
-
625
- expect(result.key).toBe('test/file.txt');
626
- expect(result.size).toBeGreaterThan(0);
627
- });
628
-
629
- it('should delete file', async () => {
630
- const buffer = Buffer.from('test content');
631
- await storage.putFile(buffer, 'test/file.txt');
632
-
633
- await storage.deleteFile('test/file.txt');
634
-
635
- await expect(storage.getFile('test/file.txt')).rejects.toThrow();
636
- });
637
- });
638
- ```
655
+ Important behavior:
656
+
657
+ - If the route uses the same provider as the module default, `storageOptions` can override pieces of that provider config.
658
+ - If the route uses a different provider than the module default, pass the full config for that provider inside `storageOptions`.
659
+
660
+ ## Configuration Reference
661
+
662
+ ### Common file options
663
+
664
+ These are shared by Local, S3, and Azure configs:
665
+
666
+ - `fileName(file, req)`: return the filename segment.
667
+ - `fileDist(file, req)`: return the folder/path prefix.
668
+ - `transformUploadedFileObject(file)`: transform the raw uploaded file object returned by the storage engine before Multer stores it.
669
+
670
+ ### Local config
671
+
672
+ - `rootPath`: local directory where files are written.
673
+ - `baseUrl`: URL prefix used by `getUrl()`.
674
+ - Common file options listed above.
675
+
676
+ ### S3 config
677
+
678
+ - `accessKeyId`
679
+ - `secretAccessKey`
680
+ - `region`
681
+ - `bucket`
682
+ - `endpoint`: optional custom S3-compatible endpoint.
683
+ - `cloudFrontUrl`: optional CDN/public URL prefix used by `getUrl()`.
684
+ - Common file options listed above.
639
685
 
640
- ## ๐Ÿค Contributing
686
+ ### Azure config
641
687
 
642
- Contributions are welcome! Please feel free to submit a Pull Request.
688
+ - `account`
689
+ - `accountKey`
690
+ - `container`
691
+ - Common file options listed above.
643
692
 
644
- ## ๐Ÿ“„ License
693
+ Azure note:
645
694
 
646
- This project is licensed under the MIT License.
695
+ - `getSignedUrl()` generates a SAS URL by default.
696
+ - If `AZURE_CDN_DOMAIN_NAME` is set in the runtime environment, the Azure storage adapter returns `AZURE_CDN_DOMAIN_NAME/<key>` instead of a SAS URL.
647
697
 
648
- ## ๐Ÿ™ Acknowledgments
698
+ ## Current Behavior Notes
649
699
 
650
- - Built with [NestJS](https://nestjs.com/)
651
- - Uses [Multer](https://github.com/expressjs/multer) for file handling
652
- - AWS S3 support via [@aws-sdk/client-s3](https://www.npmjs.com/package/@aws-sdk/client-s3)
653
- - Azure support via [@azure/storage-blob](https://www.npmjs.com/package/@azure/storage-blob)
700
+ - This package is implemented for NestJS with Express and Multer. It is not a Fastify-targeted integration.
701
+ - In `fields` mode, each field is mapped to an array even when `maxCount` is `1`.
702
+ - `baseUrl` is only a URL prefix. Add static serving yourself for direct local-file access.
703
+ - The exported types include a `prefix` option, but `fileDist` and `fileName` are the reliable hooks for controlling saved paths in the current implementation.
704
+ - The module stores one provider configuration at a time. For per-route provider switching, use `storageType` with `storageOptions`.
705
+ - The custom `storageFactory` module option is best treated as an advanced service-level hook. The controller upload flow documented here is the built-in path for Local, S3, and Azure storage.
654
706
 
655
- ## ๐Ÿ“ฎ Support
707
+ ## Examples And Workspace Docs
656
708
 
657
- If you have any questions or need help:
658
- - Open an issue on [GitHub](https://github.com/ack-solutions/nest-file-storage/issues)
659
- - Check the [examples](./examples/) directory
709
+ - Package examples: [`examples/`](./examples/)
710
+ - Workspace overview: [`../../README.md`](../../README.md)
711
+ - Example app: [`../../apps/example-app/README.md`](../../apps/example-app/README.md)
660
712
 
661
- ---
713
+ ## License
662
714
 
663
- Made with โค๏ธ for the NestJS community
715
+ MIT