@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/MIGRATION.md +220 -0
  3. package/README.md +311 -557
  4. package/dist/index.d.ts +1 -4
  5. package/dist/index.js +1 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/constants.d.ts +3 -1
  8. package/dist/lib/constants.js +4 -2
  9. package/dist/lib/constants.js.map +1 -1
  10. package/dist/lib/driver-registry.d.ts +34 -0
  11. package/dist/lib/driver-registry.js +118 -0
  12. package/dist/lib/driver-registry.js.map +1 -0
  13. package/dist/lib/drivers/azure.driver.d.ts +21 -0
  14. package/dist/lib/drivers/azure.driver.js +91 -0
  15. package/dist/lib/drivers/azure.driver.js.map +1 -0
  16. package/dist/lib/drivers/driver.interface.d.ts +40 -0
  17. package/dist/lib/drivers/driver.interface.js +3 -0
  18. package/dist/lib/drivers/driver.interface.js.map +1 -0
  19. package/dist/lib/drivers/driver.util.d.ts +2 -0
  20. package/dist/lib/drivers/driver.util.js +15 -0
  21. package/dist/lib/drivers/driver.util.js.map +1 -0
  22. package/dist/lib/drivers/index.d.ts +7 -0
  23. package/dist/lib/drivers/index.js +39 -0
  24. package/dist/lib/drivers/index.js.map +1 -0
  25. package/dist/lib/drivers/local.driver.d.ts +15 -0
  26. package/dist/lib/drivers/local.driver.js +110 -0
  27. package/dist/lib/drivers/local.driver.js.map +1 -0
  28. package/dist/lib/drivers/s3.driver.d.ts +22 -0
  29. package/dist/lib/drivers/s3.driver.js +103 -0
  30. package/dist/lib/drivers/s3.driver.js.map +1 -0
  31. package/dist/lib/file-storage.service.d.ts +16 -5
  32. package/dist/lib/file-storage.service.js +60 -22
  33. package/dist/lib/file-storage.service.js.map +1 -1
  34. package/dist/lib/index.d.ts +9 -2
  35. package/dist/lib/index.js +15 -2
  36. package/dist/lib/index.js.map +1 -1
  37. package/dist/lib/interceptor/file-storage.interceptor.d.ts +7 -10
  38. package/dist/lib/interceptor/file-storage.interceptor.js +117 -112
  39. package/dist/lib/interceptor/file-storage.interceptor.js.map +1 -1
  40. package/dist/lib/multer/driver-multer-engine.d.ts +18 -0
  41. package/dist/lib/multer/driver-multer-engine.js +91 -0
  42. package/dist/lib/multer/driver-multer-engine.js.map +1 -0
  43. package/dist/lib/nest-file-storage.module.d.ts +3 -3
  44. package/dist/lib/nest-file-storage.module.js +81 -44
  45. package/dist/lib/nest-file-storage.module.js.map +1 -1
  46. package/dist/lib/registry-holder.d.ts +6 -0
  47. package/dist/lib/registry-holder.js +26 -0
  48. package/dist/lib/registry-holder.js.map +1 -0
  49. package/dist/lib/tenant/tenant-from.d.ts +14 -0
  50. package/dist/lib/tenant/tenant-from.js +71 -0
  51. package/dist/lib/tenant/tenant-from.js.map +1 -0
  52. package/dist/lib/tenant/tenant.types.d.ts +20 -0
  53. package/dist/lib/tenant/tenant.types.js +3 -0
  54. package/dist/lib/tenant/tenant.types.js.map +1 -0
  55. package/dist/lib/types.d.ts +45 -35
  56. package/dist/lib/types.js.map +1 -1
  57. package/dist/lib/validation.d.ts +22 -0
  58. package/dist/lib/validation.js +98 -0
  59. package/dist/lib/validation.js.map +1 -0
  60. package/dist/tsconfig.build.tsbuildinfo +1 -1
  61. package/examples/1-basic-local-storage.example.ts +11 -7
  62. package/examples/10-testing.example.ts +60 -196
  63. package/examples/11-custom-driver.example.ts +82 -0
  64. package/examples/12-multi-tenant.example.ts +93 -0
  65. package/examples/2-s3-storage.example.ts +18 -16
  66. package/examples/3-azure-storage.example.ts +14 -12
  67. package/examples/4-upload-controller.example.ts +20 -55
  68. package/examples/5-custom-configuration.example.ts +37 -57
  69. package/examples/6-file-service.example.ts +34 -91
  70. package/examples/7-user-avatar.example.ts +37 -92
  71. package/examples/8-document-management.example.ts +45 -196
  72. package/examples/9-dynamic-storage.example.ts +29 -147
  73. package/examples/README.md +25 -107
  74. package/package.json +17 -4
  75. package/dist/lib/storage/azure.storage.d.ts +0 -18
  76. package/dist/lib/storage/azure.storage.js +0 -210
  77. package/dist/lib/storage/azure.storage.js.map +0 -1
  78. package/dist/lib/storage/local.storage.d.ts +0 -20
  79. package/dist/lib/storage/local.storage.js +0 -212
  80. package/dist/lib/storage/local.storage.js.map +0 -1
  81. package/dist/lib/storage/s3.storage.d.ts +0 -19
  82. package/dist/lib/storage/s3.storage.js +0 -241
  83. package/dist/lib/storage/s3.storage.js.map +0 -1
  84. package/dist/lib/storage.factory.d.ts +0 -8
  85. package/dist/lib/storage.factory.js +0 -46
  86. 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
- `@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.
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
- This README is the canonical developer guide for using the package in an application.
5
+ [![npm](https://img.shields.io/npm/v/@ackplus/nest-file-storage.svg)](https://www.npmjs.com/package/@ackplus/nest-file-storage)
6
+ [![license](https://img.shields.io/npm/l/@ackplus/nest-file-storage.svg)](./LICENSE)
6
7
 
7
- ## What You Get
8
+ ---
8
9
 
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.
10
+ ## Why this library?
14
11
 
15
- ## Compatibility And Prerequisites
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
- This library is designed for NestJS on the Express platform.
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
- Required in the consuming app:
22
+ > **Upgrading from v1?** See **[MIGRATION.md](./MIGRATION.md)**. Your old config still boots (with a deprecation warning) while you migrate.
20
23
 
21
- - `@nestjs/common`
22
- - `@nestjs/core`
23
- - `@nestjs/platform-express`
24
- - `multer`
25
- - `reflect-metadata`
24
+ ## Contents
26
25
 
27
- Peer dependency ranges exported by the package:
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
- - `@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
+ ---
33
34
 
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
35
+ ## Install
40
36
 
41
37
  ```bash
42
- pnpm add @ackplus/nest-file-storage multer reflect-metadata
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
- If your Nest app does not already include the Express platform package:
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
- ## Register The Module
47
+ ## Quick start
64
48
 
65
- ### Local storage
49
+ **1. Register a driver:**
66
50
 
67
51
  ```ts
68
52
  import { Module } from '@nestjs/common';
69
- import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
53
+ import { NestFileStorageModule, localDriver } from '@ackplus/nest-file-storage';
70
54
 
71
55
  @Module({
72
56
  imports: [
73
57
  NestFileStorageModule.forRoot({
74
- storage: FileStorageEnum.LOCAL,
75
- localConfig: {
76
- rootPath: './uploads',
77
- baseUrl: 'http://localhost:3000/uploads',
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
- ### AWS S3
71
+ **2. Accept uploads in a controller:**
86
72
 
87
73
  ```ts
88
- import { Module } from '@nestjs/common';
89
- import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
74
+ import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
75
+ import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
90
76
 
91
- @Module({
92
- imports: [
93
- NestFileStorageModule.forRoot({
94
- storage: FileStorageEnum.S3,
95
- s3Config: {
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,
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
- ### Azure Blob Storage
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
- ```ts
111
- import { Module } from '@nestjs/common';
112
- import { FileStorageEnum, NestFileStorageModule } from '@ackplus/nest-file-storage';
89
+ ## Core concepts
113
90
 
114
- @Module({
115
- imports: [
116
- NestFileStorageModule.forRoot({
117
- storage: FileStorageEnum.AZURE,
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
- ### Async configuration
96
+ ## Configure providers
130
97
 
131
- ```ts
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
- @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 {}
100
+ ```ts
101
+ localDriver({ rootPath: './uploads', baseUrl: 'http://localhost:3000/uploads' })
155
102
  ```
156
103
 
157
- ### Example: build module options from one shared storage config
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
- 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.
106
+ ### AWS S3 (and S3-compatible: MinIO, R2, Spaces)
160
107
 
161
108
  ```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
- }
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
- 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
- ```
119
+ ### Azure Blob Storage
224
120
 
225
121
  ```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
- ],
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
- 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`.
130
+ ### Async configuration
252
131
 
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[]` |
132
+ Use `forRootAsync` when config comes from `ConfigService`, a database, etc.:
258
133
 
259
- Important behavior for `fields` mode:
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
- - Every configured field is mapped to an array.
262
- - That stays true even when `maxCount: 1`.
152
+ ### Provider comparison
263
153
 
264
- DTO note:
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
- - 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`.
163
+ ## Custom storage drivers
268
164
 
269
- ### Single file upload
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 { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
273
- import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
168
+ import { defineDriver, StorageDriver, UploadedFile } from '@ackplus/nest-file-storage';
169
+ import { Storage } from '@google-cloud/storage';
274
170
 
275
- @Controller('files')
276
- export class FilesController {
277
- @Post('single')
278
- @UseInterceptors(FileStorageInterceptor('file'))
279
- uploadSingle(@Body() body: { file: string }) {
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: body.file,
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
- ### Multiple files in one field
196
+ For async setup, register a plain factory instead: `drivers: { gcs: async () => new GcsDriver(await load()) }`.
288
197
 
289
- ```ts
290
- import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
291
- import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
198
+ ## Multi-tenant storage
292
199
 
293
- @Controller('files')
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 { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
316
- import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
317
-
318
- @Controller('files')
319
- export class FilesController {
320
- @Post('fields')
321
- @UseInterceptors(
322
- FileStorageInterceptor({
323
- type: 'fields',
324
- fields: [
325
- { name: 'avatar', maxCount: 1 },
326
- { name: 'attachments', maxCount: 5 },
327
- ],
328
- }),
329
- )
330
- uploadFields(@Body() body: { avatar: string[]; attachments: string[] }) {
331
- return body;
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
- ## Validation
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
- import {
346
- BadRequestException,
347
- Body,
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
- ### Cross-field or post-upload validation
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
- import { BadRequestException, Body, Controller, Post, UseInterceptors } from '@nestjs/common';
386
- import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
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
- Practical guidance:
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
- ## Control The Stored Path And Key
252
+ **`tenant.driver(tenantId)` returns one of:**
425
253
 
426
- Two callbacks define where the file is stored and what key is saved:
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
- - `fileDist(file, req)`: the directory or path prefix.
429
- - `fileName(file, req)`: the final filename segment.
260
+ ## Uploading in controllers
430
261
 
431
- The final key is effectively:
262
+ `FileStorageInterceptor(field, options?)` accepts `multipart/form-data`, stores the file(s), and writes the result into `request.body`.
432
263
 
433
- ```text
434
- <fileDist>/<fileName>
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
- ### 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`
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
- ### Custom path and filename
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
- This gives you keys such as:
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
- ### Return metadata instead of just the key
300
+ ### Override the driver for one route
474
301
 
475
302
  ```ts
476
- import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
477
- import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
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
- ### Preserve an existing body field
308
+ ## Validation & limits
501
309
 
502
- If the request already contains a JSON/text field with the same name and you only want to populate it when missing:
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
- FileStorageInterceptor('file', {
506
- overwriteBodyField: false,
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
- ## Use `FileStorageService` Programmatically
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
- `FileStorageService.getStorage()` gives you the active storage implementation so you can upload or manage files outside controller interceptors.
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
- import { Injectable } from '@nestjs/common';
516
- import { FileStorageService } from '@ackplus/nest-file-storage';
517
-
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
- }
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
- async copy(oldKey: string, newKey: string) {
536
- const storage = await FileStorageService.getStorage();
537
- return storage.copyFile(oldKey, newKey);
538
- }
345
+ ## Mapping results into the request body
539
346
 
540
- async url(key: string) {
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
- async signedUrl(key: string) {
546
- const storage = await FileStorageService.getStorage();
547
- if ('getSignedUrl' in storage && storage.getSignedUrl) {
548
- return storage.getSignedUrl(key, { expiresIn: 3600 });
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
- ### Get full URL from a stored key/path
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
- 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.
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 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
- }
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
- ### 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.
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
- 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
- }
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
- 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.
390
+ ## The `UploadedFile` model (file state)
609
391
 
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
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
- 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
- }
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
- Important behavior:
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
- - 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`.
418
+ ### `NestFileStorageModule`
659
419
 
660
- ## Configuration Reference
420
+ - `forRoot(options)` / `forRootAsync(asyncOptions)` → a global `DynamicModule`.
421
+ - Options: `{ default: string; drivers: Record<string, DriverFactory>; validation?: UploadValidation; tenant?: TenantOptions }`.
661
422
 
662
- ### Common file options
423
+ ### Driver factories
663
424
 
664
- These are shared by Local, S3, and Azure configs:
425
+ - `localDriver(options)` · `s3Driver(options)` · `azureDriver(options)` · `defineDriver(DriverClass, options?)` `DriverFactory`.
665
426
 
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.
427
+ ### `FileStorageService` (injectable)
669
428
 
670
- ### Local config
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
- - `rootPath`: local directory where files are written.
673
- - `baseUrl`: URL prefix used by `getUrl()`.
674
- - Common file options listed above.
433
+ ### `FileStorageInterceptor(field, options?)`
675
434
 
676
- ### S3 config
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
- - `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.
438
+ ### The `StorageDriver` interface
685
439
 
686
- ### Azure config
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
- - `account`
689
- - `accountKey`
690
- - `container`
691
- - Common file options listed above.
453
+ ### Tenant resolvers — `tenantFrom`
692
454
 
693
- Azure note:
455
+ `jwt(path?)` · `header(name)` · `subdomain({ rootDomain?, ignore? })` · `param(name)` · `query(name)` · `first(...resolvers)`.
694
456
 
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.
457
+ ### Exceptions
697
458
 
698
- ## Current Behavior Notes
459
+ `FileTooLargeException` · `InvalidFileTypeException` · `TooManyFilesException` (all extend `BadRequestException`).
699
460
 
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.
461
+ ---
706
462
 
707
- ## Examples And Workspace Docs
463
+ ## More
708
464
 
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)
465
+ - **[Migration from v1](./MIGRATION.md)** · **[Changelog](./CHANGELOG.md)** · **[Examples](./examples/)**
712
466
 
713
467
  ## License
714
468