@ackplus/nest-file-storage 1.1.23 → 2.0.0
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/CHANGELOG.md +72 -0
- package/MIGRATION.md +220 -0
- package/README.md +311 -557
- package/dist/index.d.ts +1 -4
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/constants.d.ts +3 -1
- package/dist/lib/constants.js +4 -2
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/driver-registry.d.ts +34 -0
- package/dist/lib/driver-registry.js +118 -0
- package/dist/lib/driver-registry.js.map +1 -0
- package/dist/lib/drivers/azure.driver.d.ts +21 -0
- package/dist/lib/drivers/azure.driver.js +91 -0
- package/dist/lib/drivers/azure.driver.js.map +1 -0
- package/dist/lib/drivers/driver.interface.d.ts +40 -0
- package/dist/lib/drivers/driver.interface.js +3 -0
- package/dist/lib/drivers/driver.interface.js.map +1 -0
- package/dist/lib/drivers/driver.util.d.ts +2 -0
- package/dist/lib/drivers/driver.util.js +15 -0
- package/dist/lib/drivers/driver.util.js.map +1 -0
- package/dist/lib/drivers/index.d.ts +7 -0
- package/dist/lib/drivers/index.js +39 -0
- package/dist/lib/drivers/index.js.map +1 -0
- package/dist/lib/drivers/local.driver.d.ts +15 -0
- package/dist/lib/drivers/local.driver.js +110 -0
- package/dist/lib/drivers/local.driver.js.map +1 -0
- package/dist/lib/drivers/s3.driver.d.ts +22 -0
- package/dist/lib/drivers/s3.driver.js +103 -0
- package/dist/lib/drivers/s3.driver.js.map +1 -0
- package/dist/lib/file-storage.service.d.ts +16 -5
- package/dist/lib/file-storage.service.js +60 -22
- package/dist/lib/file-storage.service.js.map +1 -1
- package/dist/lib/index.d.ts +9 -2
- package/dist/lib/index.js +15 -2
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/interceptor/file-storage.interceptor.d.ts +7 -10
- package/dist/lib/interceptor/file-storage.interceptor.js +117 -112
- package/dist/lib/interceptor/file-storage.interceptor.js.map +1 -1
- package/dist/lib/multer/driver-multer-engine.d.ts +18 -0
- package/dist/lib/multer/driver-multer-engine.js +91 -0
- package/dist/lib/multer/driver-multer-engine.js.map +1 -0
- package/dist/lib/nest-file-storage.module.d.ts +3 -3
- package/dist/lib/nest-file-storage.module.js +81 -44
- package/dist/lib/nest-file-storage.module.js.map +1 -1
- package/dist/lib/registry-holder.d.ts +6 -0
- package/dist/lib/registry-holder.js +26 -0
- package/dist/lib/registry-holder.js.map +1 -0
- package/dist/lib/tenant/tenant-from.d.ts +14 -0
- package/dist/lib/tenant/tenant-from.js +71 -0
- package/dist/lib/tenant/tenant-from.js.map +1 -0
- package/dist/lib/tenant/tenant.types.d.ts +20 -0
- package/dist/lib/tenant/tenant.types.js +3 -0
- package/dist/lib/tenant/tenant.types.js.map +1 -0
- package/dist/lib/types.d.ts +45 -35
- package/dist/lib/types.js.map +1 -1
- package/dist/lib/validation.d.ts +22 -0
- package/dist/lib/validation.js +98 -0
- package/dist/lib/validation.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/examples/1-basic-local-storage.example.ts +11 -7
- package/examples/10-testing.example.ts +60 -196
- package/examples/11-custom-driver.example.ts +82 -0
- package/examples/12-multi-tenant.example.ts +93 -0
- package/examples/2-s3-storage.example.ts +18 -16
- package/examples/3-azure-storage.example.ts +14 -12
- package/examples/4-upload-controller.example.ts +20 -55
- package/examples/5-custom-configuration.example.ts +37 -57
- package/examples/6-file-service.example.ts +34 -91
- package/examples/7-user-avatar.example.ts +37 -92
- package/examples/8-document-management.example.ts +45 -196
- package/examples/9-dynamic-storage.example.ts +29 -147
- package/examples/README.md +25 -107
- package/package.json +17 -4
- package/dist/lib/storage/azure.storage.d.ts +0 -18
- package/dist/lib/storage/azure.storage.js +0 -210
- package/dist/lib/storage/azure.storage.js.map +0 -1
- package/dist/lib/storage/local.storage.d.ts +0 -20
- package/dist/lib/storage/local.storage.js +0 -212
- package/dist/lib/storage/local.storage.js.map +0 -1
- package/dist/lib/storage/s3.storage.d.ts +0 -19
- package/dist/lib/storage/s3.storage.js +0 -241
- package/dist/lib/storage/s3.storage.js.map +0 -1
- package/dist/lib/storage.factory.d.ts +0 -8
- package/dist/lib/storage.factory.js +0 -46
- package/dist/lib/storage.factory.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,80 +1,66 @@
|
|
|
1
1
|
# @ackplus/nest-file-storage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> One file-storage API for NestJS — Local, S3, Azure, or your own driver. Upload through a controller interceptor or call it directly. Pick storage **per request** (great for multi-tenant apps), validate declaratively, and store one consistent file shape everywhere.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@ackplus/nest-file-storage)
|
|
6
|
+
[](./LICENSE)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
---
|
|
8
9
|
|
|
9
|
-
|
|
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.
|
|
10
|
+
## Why this library?
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
Wiring Multer to S3/Azure/local by hand means juggling storage engines, SDK clients, key generation, URL building, and validation in every project — and swapping providers later means rewriting it all. This library gives you **one stable API** over all of them.
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
- **Provider-agnostic.** Write your upload code once. Switch Local → S3 → Azure (or your own backend) by changing config, not controllers.
|
|
15
|
+
- **Custom storage is first-class.** Implement a small `StorageDriver` interface and it works **everywhere** — interceptor and service alike. No fork, no adapter glue.
|
|
16
|
+
- **Per-request / multi-tenant storage.** Route each upload to the right bucket/folder based on the request (tenant, user plan, file type). Built-in caching means one client per tenant, not per request.
|
|
17
|
+
- **Declarative validation.** Size, MIME type, extension, and file-count limits as data — not hand-rolled checks scattered through your handlers. Rejections are typed `400`s.
|
|
18
|
+
- **One result shape.** Every upload — local or cloud — returns the same `UploadedFile` (`key`, `url`, `size`, …), so the rest of your app never branches on provider.
|
|
19
|
+
- **Lean dependencies.** The AWS and Azure SDKs are optional peers, loaded lazily only if you use them. Install nothing extra for local storage.
|
|
20
|
+
- **NestJS-native.** A dynamic module, an injectable service, and a route interceptor — the patterns you already use.
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
> **Upgrading from v1?** See **[MIGRATION.md](./MIGRATION.md)**. Your old config still boots (with a deprecation warning) while you migrate.
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
- `@nestjs/core`
|
|
23
|
-
- `@nestjs/platform-express`
|
|
24
|
-
- `multer`
|
|
25
|
-
- `reflect-metadata`
|
|
24
|
+
## Contents
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
- [Install](#install) · [Quick start](#quick-start) · [Core concepts](#core-concepts)
|
|
27
|
+
- [Configure providers](#configure-providers) · [Provider comparison](#provider-comparison)
|
|
28
|
+
- [Custom storage drivers](#custom-storage-drivers) · [Multi-tenant storage](#multi-tenant-storage)
|
|
29
|
+
- [Uploading in controllers](#uploading-in-controllers) · [Validation & limits](#validation--limits)
|
|
30
|
+
- [Mapping results into the body](#mapping-results-into-the-request-body) · [Using the service](#using-the-service-programmatically)
|
|
31
|
+
- [The `UploadedFile` model (file state)](#the-uploadedfile-model-file-state) · [API reference](#api-reference)
|
|
28
32
|
|
|
29
|
-
|
|
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
|
+
---
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- AWS S3: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`
|
|
37
|
-
- Azure Blob Storage: `@azure/storage-blob`
|
|
38
|
-
|
|
39
|
-
## Installation
|
|
35
|
+
## Install
|
|
40
36
|
|
|
41
37
|
```bash
|
|
42
|
-
|
|
38
|
+
npm i @ackplus/nest-file-storage multer reflect-metadata
|
|
39
|
+
# AWS S3 (optional):
|
|
40
|
+
npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
41
|
+
# Azure Blob (optional):
|
|
42
|
+
npm i @azure/storage-blob
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
pnpm add @nestjs/platform-express
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
For AWS S3 support:
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
For Azure Blob Storage support:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
pnpm add @azure/storage-blob
|
|
61
|
-
```
|
|
45
|
+
Requires NestJS on **Express** (`@nestjs/platform-express`). Peer ranges: `@nestjs/common`/`@nestjs/core` `^10 || ^11`, `multer` `^1.4.5-lts.1 || ^2`, `reflect-metadata` `^0.2.2`.
|
|
62
46
|
|
|
63
|
-
##
|
|
47
|
+
## Quick start
|
|
64
48
|
|
|
65
|
-
|
|
49
|
+
**1. Register a driver:**
|
|
66
50
|
|
|
67
51
|
```ts
|
|
68
52
|
import { Module } from '@nestjs/common';
|
|
69
|
-
import {
|
|
53
|
+
import { NestFileStorageModule, localDriver } from '@ackplus/nest-file-storage';
|
|
70
54
|
|
|
71
55
|
@Module({
|
|
72
56
|
imports: [
|
|
73
57
|
NestFileStorageModule.forRoot({
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
58
|
+
default: 'local',
|
|
59
|
+
drivers: {
|
|
60
|
+
local: localDriver({
|
|
61
|
+
rootPath: './uploads',
|
|
62
|
+
baseUrl: 'http://localhost:3000/uploads',
|
|
63
|
+
}),
|
|
78
64
|
},
|
|
79
65
|
}),
|
|
80
66
|
],
|
|
@@ -82,633 +68,401 @@ import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-stora
|
|
|
82
68
|
export class AppModule {}
|
|
83
69
|
```
|
|
84
70
|
|
|
85
|
-
|
|
71
|
+
**2. Accept uploads in a controller:**
|
|
86
72
|
|
|
87
73
|
```ts
|
|
88
|
-
import {
|
|
89
|
-
import {
|
|
74
|
+
import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
|
|
75
|
+
import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
|
|
90
76
|
|
|
91
|
-
@
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
bucket: process.env.AWS_BUCKET!,
|
|
100
|
-
cloudFrontUrl: process.env.AWS_CLOUDFRONT_URL,
|
|
101
|
-
},
|
|
102
|
-
}),
|
|
103
|
-
],
|
|
104
|
-
})
|
|
105
|
-
export class AppModule {}
|
|
77
|
+
@Controller('files')
|
|
78
|
+
export class FilesController {
|
|
79
|
+
@Post('upload')
|
|
80
|
+
@UseInterceptors(FileStorageInterceptor('file'))
|
|
81
|
+
upload(@Body() body: { file: string }) {
|
|
82
|
+
return { key: body.file }; // body.file is the stored key
|
|
83
|
+
}
|
|
84
|
+
}
|
|
106
85
|
```
|
|
107
86
|
|
|
108
|
-
|
|
87
|
+
The interceptor parses `multipart/form-data`, stores the file, and writes the storage key into `request.body.file` before your handler runs.
|
|
109
88
|
|
|
110
|
-
|
|
111
|
-
import { Module } from '@nestjs/common';
|
|
112
|
-
import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
|
|
89
|
+
## Core concepts
|
|
113
90
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
azureConfig: {
|
|
119
|
-
account: process.env.AZURE_STORAGE_ACCOUNT!,
|
|
120
|
-
accountKey: process.env.AZURE_STORAGE_KEY!,
|
|
121
|
-
container: process.env.AZURE_CONTAINER!,
|
|
122
|
-
},
|
|
123
|
-
}),
|
|
124
|
-
],
|
|
125
|
-
})
|
|
126
|
-
export class AppModule {}
|
|
127
|
-
```
|
|
91
|
+
- **Driver** — a backend implementing the [`StorageDriver`](#the-storagedriver-interface) contract (put/get/delete/copy/url). Built-ins: `localDriver`, `s3Driver`, `azureDriver`. Bring your own with `defineDriver`.
|
|
92
|
+
- **Registry** — the module builds a registry from your `drivers` map. Drivers are instantiated **once** and cached. The interceptor and the service both resolve from it, so everything behaves the same.
|
|
93
|
+
- **Default driver** — `default` names the driver used when nothing else is specified.
|
|
94
|
+
- **`UploadedFile`** — the one result shape returned by every driver. See [file state](#the-uploadedfile-model-file-state).
|
|
128
95
|
|
|
129
|
-
|
|
96
|
+
## Configure providers
|
|
130
97
|
|
|
131
|
-
|
|
132
|
-
import { Module } from '@nestjs/common';
|
|
133
|
-
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
134
|
-
import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
|
|
98
|
+
### Local
|
|
135
99
|
|
|
136
|
-
|
|
137
|
-
|
|
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 {}
|
|
100
|
+
```ts
|
|
101
|
+
localDriver({ rootPath: './uploads', baseUrl: 'http://localhost:3000/uploads' })
|
|
155
102
|
```
|
|
156
103
|
|
|
157
|
-
|
|
104
|
+
> `baseUrl` only builds the URL string — it does not serve files. Add static serving (e.g. `@nestjs/serve-static`) for browser access, or stream files through your own controller.
|
|
158
105
|
|
|
159
|
-
|
|
106
|
+
### AWS S3 (and S3-compatible: MinIO, R2, Spaces)
|
|
160
107
|
|
|
161
108
|
```ts
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
endpoint
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
}
|
|
109
|
+
s3Driver({
|
|
110
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
111
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
112
|
+
region: process.env.AWS_REGION!,
|
|
113
|
+
bucket: process.env.AWS_BUCKET!,
|
|
114
|
+
endpoint: process.env.S3_ENDPOINT, // optional, for S3-compatible
|
|
115
|
+
cloudFrontUrl: process.env.CDN_URL, // optional, used by getUrl()
|
|
116
|
+
})
|
|
207
117
|
```
|
|
208
118
|
|
|
209
|
-
|
|
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
|
-
```
|
|
119
|
+
### Azure Blob Storage
|
|
224
120
|
|
|
225
121
|
```ts
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
imports: [
|
|
232
|
-
ServeStaticModule.forRoot({
|
|
233
|
-
rootPath: join(process.cwd(), 'uploads'),
|
|
234
|
-
serveRoot: '/uploads',
|
|
235
|
-
}),
|
|
236
|
-
],
|
|
122
|
+
azureDriver({
|
|
123
|
+
account: process.env.AZURE_ACCOUNT!,
|
|
124
|
+
accountKey: process.env.AZURE_KEY!,
|
|
125
|
+
container: process.env.AZURE_CONTAINER!,
|
|
126
|
+
cdnUrl: process.env.AZURE_CDN_URL, // optional, used by getSignedUrl()
|
|
237
127
|
})
|
|
238
|
-
export class AppModule {}
|
|
239
128
|
```
|
|
240
129
|
|
|
241
|
-
|
|
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`.
|
|
130
|
+
### Async configuration
|
|
252
131
|
|
|
253
|
-
|
|
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[]` |
|
|
132
|
+
Use `forRootAsync` when config comes from `ConfigService`, a database, etc.:
|
|
258
133
|
|
|
259
|
-
|
|
134
|
+
```ts
|
|
135
|
+
NestFileStorageModule.forRootAsync({
|
|
136
|
+
imports: [ConfigModule],
|
|
137
|
+
inject: [ConfigService],
|
|
138
|
+
useFactory: (config: ConfigService) => ({
|
|
139
|
+
default: 's3',
|
|
140
|
+
drivers: {
|
|
141
|
+
s3: s3Driver({
|
|
142
|
+
accessKeyId: config.getOrThrow('AWS_ACCESS_KEY_ID'),
|
|
143
|
+
secretAccessKey: config.getOrThrow('AWS_SECRET_ACCESS_KEY'),
|
|
144
|
+
region: config.getOrThrow('AWS_REGION'),
|
|
145
|
+
bucket: config.getOrThrow('AWS_BUCKET'),
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
```
|
|
260
151
|
|
|
261
|
-
|
|
262
|
-
- That stays true even when `maxCount: 1`.
|
|
152
|
+
### Provider comparison
|
|
263
153
|
|
|
264
|
-
|
|
154
|
+
| Capability | Local | S3 | Azure | Custom |
|
|
155
|
+
| --- | :---: | :---: | :---: | :---: |
|
|
156
|
+
| `putFile` / `getFile` / `deleteFile` / `copyFile` | ✅ | ✅ | ✅ | ✅ |
|
|
157
|
+
| `getUrl` | ✅ | ✅ | ✅ | ✅ |
|
|
158
|
+
| `getSignedUrl` | — | ✅ | ✅ (SAS) | optional |
|
|
159
|
+
| `path` (absolute FS path) | ✅ | — | — | optional |
|
|
160
|
+
| Public URLs need extra setup | static serving | bucket/CDN policy | container/CDN policy | your call |
|
|
161
|
+
| Extra dependency | none | `@aws-sdk/*` | `@azure/storage-blob` | your SDK |
|
|
265
162
|
|
|
266
|
-
|
|
267
|
-
- Typical DTO fields are `string` for single uploads, `string[]` for array uploads, and custom object shapes when you use `mapToRequestBody`.
|
|
163
|
+
## Custom storage drivers
|
|
268
164
|
|
|
269
|
-
|
|
165
|
+
Implement the small [`StorageDriver`](#the-storagedriver-interface) interface and register it with `defineDriver`. It then works in the interceptor, the service, and tenant resolution — identically to the built-ins.
|
|
270
166
|
|
|
271
167
|
```ts
|
|
272
|
-
import {
|
|
273
|
-
import {
|
|
168
|
+
import { defineDriver, StorageDriver, UploadedFile } from '@ackplus/nest-file-storage';
|
|
169
|
+
import { Storage } from '@google-cloud/storage';
|
|
274
170
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
171
|
+
class GcsDriver implements StorageDriver {
|
|
172
|
+
private storage = new Storage();
|
|
173
|
+
constructor(private opts: { bucket: string }) {}
|
|
174
|
+
|
|
175
|
+
async putFile(content: Buffer, key: string, meta?): Promise<UploadedFile> {
|
|
176
|
+
await this.storage.bucket(this.opts.bucket).file(key).save(content, {
|
|
177
|
+
contentType: meta?.contentType,
|
|
178
|
+
});
|
|
280
179
|
return {
|
|
281
|
-
key:
|
|
180
|
+
key, url: this.getUrl(key), originalName: key.split('/').pop()!,
|
|
181
|
+
fileName: key.split('/').pop()!, size: content.length, fullPath: key,
|
|
282
182
|
};
|
|
283
183
|
}
|
|
184
|
+
async getFile(key: string) { const [buf] = await this.storage.bucket(this.opts.bucket).file(key).download(); return buf; }
|
|
185
|
+
async deleteFile(key: string) { await this.storage.bucket(this.opts.bucket).file(key).delete(); }
|
|
186
|
+
async copyFile(src: string, dest: string) { /* copy + return UploadedFile */ }
|
|
187
|
+
getUrl(key: string) { return `https://storage.googleapis.com/${this.opts.bucket}/${key}`; }
|
|
284
188
|
}
|
|
189
|
+
|
|
190
|
+
NestFileStorageModule.forRoot({
|
|
191
|
+
default: 'gcs',
|
|
192
|
+
drivers: { gcs: defineDriver(GcsDriver, { bucket: 'my-bucket' }) },
|
|
193
|
+
});
|
|
285
194
|
```
|
|
286
195
|
|
|
287
|
-
|
|
196
|
+
For async setup, register a plain factory instead: `drivers: { gcs: async () => new GcsDriver(await load()) }`.
|
|
288
197
|
|
|
289
|
-
|
|
290
|
-
import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
|
|
291
|
-
import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
|
|
198
|
+
## Multi-tenant storage
|
|
292
199
|
|
|
293
|
-
|
|
294
|
-
export class FilesController {
|
|
295
|
-
@Post('multiple')
|
|
296
|
-
@UseInterceptors(
|
|
297
|
-
FileStorageInterceptor({
|
|
298
|
-
type: 'array',
|
|
299
|
-
fieldName: 'files',
|
|
300
|
-
maxCount: 10,
|
|
301
|
-
}),
|
|
302
|
-
)
|
|
303
|
-
uploadMultiple(@Body() body: { files: string[] }) {
|
|
304
|
-
return {
|
|
305
|
-
keys: body.files,
|
|
306
|
-
count: body.files.length,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
### Multiple named fields
|
|
200
|
+
Route each upload to the right storage based on the request — a different **folder per tenant** in one bucket, a **dedicated bucket per tenant**, or a mix. The library does no authentication: your guard/middleware identifies the tenant; these hooks read it.
|
|
313
201
|
|
|
314
202
|
```ts
|
|
315
|
-
import {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
203
|
+
import { NestFileStorageModule, localDriver, s3Driver, tenantFrom } from '@ackplus/nest-file-storage';
|
|
204
|
+
|
|
205
|
+
NestFileStorageModule.forRootAsync({
|
|
206
|
+
inject: [TenantStorageService],
|
|
207
|
+
useFactory: (tenants: TenantStorageService) => ({
|
|
208
|
+
default: 'local',
|
|
209
|
+
drivers: {
|
|
210
|
+
local: localDriver({ rootPath: './uploads', baseUrl: 'http://localhost:3000/uploads' }),
|
|
211
|
+
},
|
|
212
|
+
tenant: {
|
|
213
|
+
// Identify the tenant — try several strategies in order.
|
|
214
|
+
resolve: tenantFrom.first(
|
|
215
|
+
tenantFrom.jwt('tenantId'), // req.user.tenantId (after your auth guard)
|
|
216
|
+
tenantFrom.subdomain(), // acme.app.com -> 'acme'
|
|
217
|
+
tenantFrom.header('x-tenant-id'),
|
|
218
|
+
),
|
|
219
|
+
// Resolve a tenant -> storage. Cached by tenant id (this runs once per tenant).
|
|
220
|
+
driver: async (tenantId) => {
|
|
221
|
+
const cfg = await tenants.find(tenantId); // your DB lookup
|
|
222
|
+
if (cfg?.dedicated) {
|
|
223
|
+
return { factory: s3Driver({ bucket: cfg.bucket, region: cfg.region,
|
|
224
|
+
accessKeyId: cfg.key, secretAccessKey: cfg.secret }) }; // dedicated bucket
|
|
225
|
+
}
|
|
226
|
+
return { use: 'local', prefix: `tenants/${tenantId}` }; // shared + folder
|
|
227
|
+
},
|
|
228
|
+
cache: { ttlMs: 10 * 60_000, max: 500 },
|
|
229
|
+
fallback: 'default', // no tenant on the request -> use the default driver ('error' to 400)
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
334
233
|
```
|
|
335
234
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
Use the interceptor options for validation, not the module registration.
|
|
339
|
-
|
|
340
|
-
### File size and mime type validation
|
|
341
|
-
|
|
342
|
-
Use `multerOptions()` when you want Multer to reject invalid uploads before your route handler runs.
|
|
235
|
+
Controllers need **no tenant-specific code** — a plain `FileStorageInterceptor('file')` is routed automatically:
|
|
343
236
|
|
|
344
237
|
```ts
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
Controller,
|
|
349
|
-
Post,
|
|
350
|
-
UseInterceptors,
|
|
351
|
-
} from '@nestjs/common';
|
|
352
|
-
import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
|
|
353
|
-
|
|
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;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
238
|
+
@Post('upload')
|
|
239
|
+
@UseInterceptors(FileStorageInterceptor('file'))
|
|
240
|
+
upload(@Body() body: { file: string }) { return { key: body.file }; }
|
|
378
241
|
```
|
|
379
242
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
Use `afterUpload()` when validation depends on the final uploaded file list or multiple fields together.
|
|
243
|
+
Outside a request (jobs, URL generation), resolve a tenant's driver programmatically — it uses the same cache:
|
|
383
244
|
|
|
384
245
|
```ts
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
}
|
|
414
|
-
}
|
|
246
|
+
const { driver, prefix } = await this.fileStorage.getTenantDriver('acme');
|
|
247
|
+
const url = await driver.getUrl(key);
|
|
415
248
|
```
|
|
416
249
|
|
|
417
|
-
|
|
418
|
-
|
|
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.
|
|
250
|
+
When a tenant changes its storage settings, drop the cached driver: `this.fileStorage.getRegistry().invalidateTenant('acme')`.
|
|
423
251
|
|
|
424
|
-
|
|
252
|
+
**`tenant.driver(tenantId)` returns one of:**
|
|
425
253
|
|
|
426
|
-
|
|
254
|
+
| Return | Meaning |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `'local'` | a registered driver, no prefix |
|
|
257
|
+
| `{ use: 'local', prefix: 'tenants/acme' }` | a shared driver + per-tenant key prefix (folder isolation) |
|
|
258
|
+
| `{ factory: s3Driver({...}), prefix? }` | a dedicated driver built for this tenant (bucket isolation) |
|
|
427
259
|
|
|
428
|
-
|
|
429
|
-
- `fileName(file, req)`: the final filename segment.
|
|
260
|
+
## Uploading in controllers
|
|
430
261
|
|
|
431
|
-
|
|
262
|
+
`FileStorageInterceptor(field, options?)` accepts `multipart/form-data`, stores the file(s), and writes the result into `request.body`.
|
|
432
263
|
|
|
433
|
-
```
|
|
434
|
-
|
|
264
|
+
```ts
|
|
265
|
+
// Single file (field name 'file')
|
|
266
|
+
@UseInterceptors(FileStorageInterceptor('file'))
|
|
267
|
+
upload(@Body() body: { file: string }) {}
|
|
268
|
+
|
|
269
|
+
// Multiple files in one field
|
|
270
|
+
@UseInterceptors(FileStorageInterceptor({ type: 'array', fieldName: 'photos', maxCount: 5 }))
|
|
271
|
+
uploadMany(@Body() body: { photos: string[] }) {}
|
|
272
|
+
|
|
273
|
+
// Multiple named fields
|
|
274
|
+
@UseInterceptors(FileStorageInterceptor({
|
|
275
|
+
type: 'fields',
|
|
276
|
+
fields: [{ name: 'avatar', maxCount: 1 }, { name: 'docs', maxCount: 10 }],
|
|
277
|
+
}))
|
|
278
|
+
uploadFields(@Body() body: { avatar: string[]; docs: string[] }) {}
|
|
435
279
|
```
|
|
436
280
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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`
|
|
281
|
+
| Mode | Accepted field(s) | Default `body` value |
|
|
282
|
+
| --- | --- | --- |
|
|
283
|
+
| `'file'` (string) | `file` | `body.file: string` |
|
|
284
|
+
| `{ type: 'array' }` | repeated `photos` or `photos[0]`, `photos[1]`, … | `body.photos: string[]` |
|
|
285
|
+
| `{ type: 'fields' }` | each named field | `body[field]: string[]` |
|
|
449
286
|
|
|
450
|
-
###
|
|
287
|
+
### Control the stored key
|
|
451
288
|
|
|
452
289
|
```ts
|
|
453
|
-
import { extname } from 'path';
|
|
454
|
-
|
|
455
290
|
FileStorageInterceptor('avatar', {
|
|
456
|
-
fileDist: (_file, req) => `users/${req.user.id}/avatars`,
|
|
457
|
-
fileName: (file) => `${Date.now()}${extname(file.originalname)}`,
|
|
291
|
+
fileDist: (_file, req) => `users/${req.user.id}/avatars`, // folder (relative)
|
|
292
|
+
fileName: (file) => `${Date.now()}${extname(file.originalname)}`, // last segment
|
|
293
|
+
prefix: 'public', // optional static prefix
|
|
458
294
|
});
|
|
295
|
+
// -> public/users/42/avatars/1713876155123.png
|
|
459
296
|
```
|
|
460
297
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
```text
|
|
464
|
-
users/42/avatars/1713876155123.png
|
|
465
|
-
```
|
|
466
|
-
|
|
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.
|
|
468
|
-
|
|
469
|
-
## Map Upload Results Into `request.body`
|
|
470
|
-
|
|
471
|
-
The default mapping stores only keys. If you want richer metadata in the controller body, use `mapToRequestBody`.
|
|
298
|
+
The final key is `joinKey(prefix, fileDist, fileName)`. Defaults: `fileDist` = `YYYY/MM/DD`, `fileName` = `uuid-originalname`.
|
|
472
299
|
|
|
473
|
-
###
|
|
300
|
+
### Override the driver for one route
|
|
474
301
|
|
|
475
302
|
```ts
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
@Controller('documents')
|
|
480
|
-
export class DocumentsController {
|
|
481
|
-
@Post()
|
|
482
|
-
@UseInterceptors(
|
|
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
|
-
}),
|
|
493
|
-
)
|
|
494
|
-
upload(@Body() body: any) {
|
|
495
|
-
return body.document;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
303
|
+
FileStorageInterceptor('file', { driver: 's3' }); // by name
|
|
304
|
+
FileStorageInterceptor('file', { driver: (req) => req.user.plan }); // dynamically
|
|
305
|
+
FileStorageInterceptor('file', { tenant: false }); // opt out of tenant routing
|
|
498
306
|
```
|
|
499
307
|
|
|
500
|
-
|
|
308
|
+
## Validation & limits
|
|
501
309
|
|
|
502
|
-
|
|
310
|
+
Declare limits as data — at the module level (applies to every route) and/or per route (merged over the module default). Rejections throw typed `400`s.
|
|
503
311
|
|
|
504
312
|
```ts
|
|
505
|
-
|
|
506
|
-
|
|
313
|
+
// module-wide default
|
|
314
|
+
validation: { maxSize: 10 * 1024 * 1024 }
|
|
315
|
+
|
|
316
|
+
// per route
|
|
317
|
+
FileStorageInterceptor('image', {
|
|
318
|
+
validation: {
|
|
319
|
+
maxSize: 5 * 1024 * 1024, // bytes
|
|
320
|
+
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/*'], // wildcards supported
|
|
321
|
+
allowedExtensions: ['.jpg', '.png'], // case-insensitive
|
|
322
|
+
maxFiles: 10, // total files per request
|
|
323
|
+
fileFilter: (req, file, cb) => cb(null, true), // escape hatch (runs after the above)
|
|
324
|
+
},
|
|
507
325
|
});
|
|
508
326
|
```
|
|
509
327
|
|
|
510
|
-
|
|
328
|
+
| Rule | Becomes | Throws on violation |
|
|
329
|
+
| --- | --- | --- |
|
|
330
|
+
| `maxSize` | Multer `limits.fileSize` | `FileTooLargeException` |
|
|
331
|
+
| `maxFiles` | Multer `limits.files` | `TooManyFilesException` |
|
|
332
|
+
| `allowedMimeTypes` / `allowedExtensions` | generated `fileFilter` | `InvalidFileTypeException` |
|
|
511
333
|
|
|
512
|
-
`
|
|
334
|
+
All three extend `BadRequestException`, so they surface as standard `400` responses. For cross-field rules (e.g. "at least 2 images"), use `afterUpload`:
|
|
513
335
|
|
|
514
336
|
```ts
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
return storage.putFile(buffer, key);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
async get(key: string) {
|
|
526
|
-
const storage = await FileStorageService.getStorage();
|
|
527
|
-
return storage.getFile(key);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
async remove(key: string) {
|
|
531
|
-
const storage = await FileStorageService.getStorage();
|
|
532
|
-
await storage.deleteFile(key);
|
|
533
|
-
}
|
|
337
|
+
FileStorageInterceptor({ type: 'fields', fields: [{ name: 'images', maxCount: 10 }] }, {
|
|
338
|
+
afterUpload: (req) => {
|
|
339
|
+
const files = req.files as Record<string, Express.Multer.File[]>;
|
|
340
|
+
if ((files?.images?.length ?? 0) < 2) throw new BadRequestException('At least 2 images required.');
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
```
|
|
534
344
|
|
|
535
|
-
|
|
536
|
-
const storage = await FileStorageService.getStorage();
|
|
537
|
-
return storage.copyFile(oldKey, newKey);
|
|
538
|
-
}
|
|
345
|
+
## Mapping results into the request body
|
|
539
346
|
|
|
540
|
-
|
|
541
|
-
const storage = await FileStorageService.getStorage();
|
|
542
|
-
return storage.getUrl(key);
|
|
543
|
-
}
|
|
347
|
+
By default the interceptor writes the storage **key** into `request.body[field]`. Customize it with `mapToRequestBody`:
|
|
544
348
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
return storage.getUrl(key);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
349
|
+
```ts
|
|
350
|
+
FileStorageInterceptor('document', {
|
|
351
|
+
mapToRequestBody: (file) => ({ key: file.key, url: file.url, size: file.size }),
|
|
352
|
+
});
|
|
353
|
+
// body.document === { key, url, size }
|
|
553
354
|
```
|
|
554
355
|
|
|
555
|
-
|
|
356
|
+
Set `overwriteBodyField: false` to keep an existing body value (e.g. a JSON field with the same name on a PATCH).
|
|
556
357
|
|
|
557
|
-
|
|
358
|
+
## Using the service programmatically
|
|
359
|
+
|
|
360
|
+
`FileStorageService` is injectable (the module is global). Use it in services, jobs, or response mappers.
|
|
558
361
|
|
|
559
362
|
```ts
|
|
560
363
|
import { Injectable } from '@nestjs/common';
|
|
561
364
|
import { FileStorageService } from '@ackplus/nest-file-storage';
|
|
562
365
|
|
|
563
366
|
@Injectable()
|
|
564
|
-
export class
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
367
|
+
export class DocsService {
|
|
368
|
+
constructor(private readonly fileStorage: FileStorageService) {}
|
|
369
|
+
|
|
370
|
+
upload(buf: Buffer, key: string) { return this.fileStorage.putFile(buf, key); }
|
|
371
|
+
read(key: string) { return this.fileStorage.getFile(key); }
|
|
372
|
+
remove(key: string) { return this.fileStorage.deleteFile(key); }
|
|
373
|
+
url(key: string) { return this.fileStorage.getUrl(key); }
|
|
374
|
+
signedUrl(key: string) { return this.fileStorage.getSignedUrl(key, { expiresIn: 3600 }); }
|
|
375
|
+
|
|
376
|
+
// a specific driver, or a tenant's driver:
|
|
377
|
+
async s3() { return this.fileStorage.getDriver('s3'); }
|
|
378
|
+
async forTenant(id: string) { return this.fileStorage.getTenantDriver(id); }
|
|
573
379
|
}
|
|
574
380
|
```
|
|
575
381
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
The usual pattern is to store only `fileKey` in the entity and attach the final URL after fetching data.
|
|
382
|
+
A common pattern: store only the `key` in your database and build the URL when serializing a response.
|
|
579
383
|
|
|
580
384
|
```ts
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
}
|
|
385
|
+
async toResponse(user: { avatarKey?: string }) {
|
|
386
|
+
return { ...user, avatarUrl: user.avatarKey ? await this.fileStorage.getUrl(user.avatarKey) : null };
|
|
601
387
|
}
|
|
602
388
|
```
|
|
603
389
|
|
|
604
|
-
|
|
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.
|
|
390
|
+
## The `UploadedFile` model (file state)
|
|
609
391
|
|
|
610
|
-
|
|
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
|
|
623
|
-
|
|
624
|
-
You can override the storage provider per route.
|
|
392
|
+
Every driver returns the same shape from `putFile`/`copyFile`, and the interceptor exposes it to `mapToRequestBody`. Persist the **`key`** (stable); derive the **`url`** when you need it.
|
|
625
393
|
|
|
626
394
|
```ts
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
}
|
|
395
|
+
interface UploadedFile {
|
|
396
|
+
key: string; // storage key/path — persist THIS
|
|
397
|
+
url: string; // public URL (local needs static serving)
|
|
398
|
+
originalName: string; // original client filename
|
|
399
|
+
fileName: string; // final filename segment of the key
|
|
400
|
+
size: number; // bytes
|
|
401
|
+
mimetype?: string; // when known
|
|
402
|
+
fieldName?: string; // upload field it came from
|
|
403
|
+
fullPath: string; // provider-native path (local absolute; cloud key)
|
|
404
|
+
encoding?: string;
|
|
405
|
+
buffer?: Buffer; // when the provider returns bytes
|
|
652
406
|
}
|
|
653
407
|
```
|
|
654
408
|
|
|
655
|
-
|
|
409
|
+
| Field | Use it for |
|
|
410
|
+
| --- | --- |
|
|
411
|
+
| `key` | the database value; the input to `getFile`/`getUrl`/`deleteFile`/`copyFile` |
|
|
412
|
+
| `url` | rendering/links (regenerate from `key` via `getUrl` rather than storing) |
|
|
413
|
+
| `fullPath` | local file operations; provider-native identifier |
|
|
414
|
+
| `size` / `mimetype` / `originalName` | metadata you may want to persist alongside the key |
|
|
415
|
+
|
|
416
|
+
## API reference
|
|
656
417
|
|
|
657
|
-
|
|
658
|
-
- If the route uses a different provider than the module default, pass the full config for that provider inside `storageOptions`.
|
|
418
|
+
### `NestFileStorageModule`
|
|
659
419
|
|
|
660
|
-
|
|
420
|
+
- `forRoot(options)` / `forRootAsync(asyncOptions)` → a global `DynamicModule`.
|
|
421
|
+
- Options: `{ default: string; drivers: Record<string, DriverFactory>; validation?: UploadValidation; tenant?: TenantOptions }`.
|
|
661
422
|
|
|
662
|
-
###
|
|
423
|
+
### Driver factories
|
|
663
424
|
|
|
664
|
-
|
|
425
|
+
- `localDriver(options)` · `s3Driver(options)` · `azureDriver(options)` · `defineDriver(DriverClass, options?)` → `DriverFactory`.
|
|
665
426
|
|
|
666
|
-
|
|
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.
|
|
427
|
+
### `FileStorageService` (injectable)
|
|
669
428
|
|
|
670
|
-
|
|
429
|
+
- `getDriver(name?)` · `getTenantDriver(tenantId)` · `getRegistry()`
|
|
430
|
+
- `putFile` · `getFile` · `deleteFile` · `copyFile` · `getUrl` · `getSignedUrl` (delegate to the default driver)
|
|
431
|
+
- `static getStorage(name?)` — *deprecated* facade for non-DI call sites.
|
|
671
432
|
|
|
672
|
-
|
|
673
|
-
- `baseUrl`: URL prefix used by `getUrl()`.
|
|
674
|
-
- Common file options listed above.
|
|
433
|
+
### `FileStorageInterceptor(field, options?)`
|
|
675
434
|
|
|
676
|
-
|
|
435
|
+
`field`: a string (single file) or `{ type, fieldName?, maxCount?, fields? }`.
|
|
436
|
+
`options`: `{ driver?, fileName?, fileDist?, prefix?, validation?, mapToRequestBody?, overwriteBodyField?, afterUpload?, tenant? }`.
|
|
677
437
|
|
|
678
|
-
|
|
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.
|
|
438
|
+
### The `StorageDriver` interface
|
|
685
439
|
|
|
686
|
-
|
|
440
|
+
```ts
|
|
441
|
+
interface StorageDriver {
|
|
442
|
+
putFile(content: Buffer, key: string, meta?: PutFileMeta): Promise<UploadedFile>;
|
|
443
|
+
getFile(key: string): Promise<Buffer>;
|
|
444
|
+
deleteFile(key: string): Promise<void>;
|
|
445
|
+
copyFile(sourceKey: string, destKey: string): Promise<UploadedFile>;
|
|
446
|
+
getUrl(key: string): string | Promise<string>;
|
|
447
|
+
getSignedUrl?(key: string, options?: SignedUrlOptions): Promise<string>;
|
|
448
|
+
path?(key: string): string | Promise<string>;
|
|
449
|
+
readonly keyDefaults?: { fileName?; fileDist?; prefix? };
|
|
450
|
+
}
|
|
451
|
+
```
|
|
687
452
|
|
|
688
|
-
|
|
689
|
-
- `accountKey`
|
|
690
|
-
- `container`
|
|
691
|
-
- Common file options listed above.
|
|
453
|
+
### Tenant resolvers — `tenantFrom`
|
|
692
454
|
|
|
693
|
-
|
|
455
|
+
`jwt(path?)` · `header(name)` · `subdomain({ rootDomain?, ignore? })` · `param(name)` · `query(name)` · `first(...resolvers)`.
|
|
694
456
|
|
|
695
|
-
|
|
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.
|
|
457
|
+
### Exceptions
|
|
697
458
|
|
|
698
|
-
|
|
459
|
+
`FileTooLargeException` · `InvalidFileTypeException` · `TooManyFilesException` (all extend `BadRequestException`).
|
|
699
460
|
|
|
700
|
-
|
|
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.
|
|
461
|
+
---
|
|
706
462
|
|
|
707
|
-
##
|
|
463
|
+
## More
|
|
708
464
|
|
|
709
|
-
-
|
|
710
|
-
- Workspace overview: [`../../README.md`](../../README.md)
|
|
711
|
-
- Example app: [`../../apps/example-app/README.md`](../../apps/example-app/README.md)
|
|
465
|
+
- **[Migration from v1](./MIGRATION.md)** · **[Changelog](./CHANGELOG.md)** · **[Examples](./examples/)**
|
|
712
466
|
|
|
713
467
|
## License
|
|
714
468
|
|