@flusys/nestjs-storage 3.0.0-rc → 3.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/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Storage Package Guide
|
|
2
2
|
|
|
3
3
|
> **Package:** `@flusys/nestjs-storage`
|
|
4
|
+
> **Version:** 3.0.0
|
|
4
5
|
> **Type:** File storage system with pluggable providers and multi-tenant support
|
|
5
6
|
|
|
6
7
|
This comprehensive guide covers the storage package - flexible file storage with multiple provider support.
|
|
@@ -18,8 +19,13 @@ This comprehensive guide covers the storage package - flexible file storage with
|
|
|
18
19
|
- [File Manager](#file-manager)
|
|
19
20
|
- [Folder Management](#folder-management)
|
|
20
21
|
- [Upload Service](#upload-service)
|
|
22
|
+
- [File Validation & Security](#file-validation--security)
|
|
23
|
+
- [Image Compression](#image-compression)
|
|
21
24
|
- [REST API Endpoints](#rest-api-endpoints)
|
|
25
|
+
- [File Serve Middleware](#file-serve-middleware)
|
|
26
|
+
- [DataSource Provider Pattern](#datasource-provider-pattern)
|
|
22
27
|
- [Multi-Tenant Support](#multi-tenant-support)
|
|
28
|
+
- [Swagger Configuration](#swagger-configuration)
|
|
23
29
|
- [Best Practices](#best-practices)
|
|
24
30
|
- [API Reference](#api-reference)
|
|
25
31
|
|
|
@@ -30,10 +36,11 @@ This comprehensive guide covers the storage package - flexible file storage with
|
|
|
30
36
|
`@flusys/nestjs-storage` provides a comprehensive file storage system:
|
|
31
37
|
|
|
32
38
|
- **Multiple Providers** - Local, AWS S3, Azure Blob, SFTP
|
|
33
|
-
- **Provider Connection Reuse** - Efficient connection management
|
|
34
|
-
- **File Validation** - Size and
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
39
|
+
- **Provider Connection Reuse** - Efficient connection management with caching
|
|
40
|
+
- **File Validation** - Size, type, and magic bytes validation
|
|
41
|
+
- **Image Compression** - Automatic optimization with format conversion
|
|
42
|
+
- **Folder Organization** - Simple folder-based file organization
|
|
43
|
+
- **Presigned URLs** - Secure time-limited access for cloud providers
|
|
37
44
|
- **Multi-Tenant Support** - Company/branch file isolation
|
|
38
45
|
- **Storage Configuration** - Per-company storage settings
|
|
39
46
|
|
|
@@ -56,10 +63,13 @@ This comprehensive guide covers the storage package - flexible file storage with
|
|
|
56
63
|
```bash
|
|
57
64
|
npm install @flusys/nestjs-storage @flusys/nestjs-shared @flusys/nestjs-core
|
|
58
65
|
|
|
66
|
+
# Required dependencies
|
|
67
|
+
npm install sharp mime-types uuid
|
|
68
|
+
|
|
59
69
|
# Optional: Install provider-specific packages
|
|
60
|
-
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # For AWS S3
|
|
61
|
-
npm install @azure/storage-blob
|
|
62
|
-
npm install ssh2-sftp-client
|
|
70
|
+
npm install @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner # For AWS S3
|
|
71
|
+
npm install @azure/storage-blob # For Azure Blob
|
|
72
|
+
npm install ssh2-sftp-client # For SFTP
|
|
63
73
|
```
|
|
64
74
|
|
|
65
75
|
---
|
|
@@ -83,54 +93,51 @@ export const DEFAULT_ALLOWED_FILE_TYPES = ['*/*']; // All file types
|
|
|
83
93
|
nestjs-storage/
|
|
84
94
|
├── src/
|
|
85
95
|
│ ├── modules/
|
|
86
|
-
│ │ └── storage.module.ts
|
|
96
|
+
│ │ └── storage.module.ts # Main module with provider registration
|
|
87
97
|
│ │
|
|
88
98
|
│ ├── config/
|
|
89
|
-
│ │ ├── storage
|
|
90
|
-
│ │ ├── storage.constants.ts # Constants
|
|
99
|
+
│ │ ├── storage.constants.ts # Module constants
|
|
91
100
|
│ │ └── index.ts
|
|
92
101
|
│ │
|
|
93
102
|
│ ├── providers/
|
|
94
|
-
│ │ ├── local-provider.ts
|
|
95
|
-
│ │ ├── s3-provider.optional.ts
|
|
96
|
-
│ │ ├── azure-provider.optional.ts
|
|
97
|
-
│ │ ├── sftp-provider.optional.ts
|
|
98
|
-
│ │ ├── storage-factory.service.ts
|
|
99
|
-
│ │ ├── storage-provider.registry.ts
|
|
103
|
+
│ │ ├── local-provider.ts # Local filesystem (built-in)
|
|
104
|
+
│ │ ├── s3-provider.optional.ts # AWS S3 (requires @aws-sdk/client-s3)
|
|
105
|
+
│ │ ├── azure-provider.optional.ts # Azure Blob (requires @azure/storage-blob)
|
|
106
|
+
│ │ ├── sftp-provider.optional.ts # SFTP (requires ssh2-sftp-client)
|
|
107
|
+
│ │ ├── storage-factory.service.ts # Provider factory with caching
|
|
108
|
+
│ │ ├── storage-provider.registry.ts # Provider class registry
|
|
100
109
|
│ │ └── index.ts
|
|
101
110
|
│ │
|
|
102
111
|
│ ├── services/
|
|
103
|
-
│ │ ├── upload.service.ts
|
|
104
|
-
│ │ ├── file-manager.service.ts
|
|
105
|
-
│ │ ├── folder.service.ts
|
|
106
|
-
│ │ ├── storage-
|
|
107
|
-
│ │ ├── storage-
|
|
112
|
+
│ │ ├── upload.service.ts # Upload/delete operations
|
|
113
|
+
│ │ ├── file-manager.service.ts # File metadata CRUD
|
|
114
|
+
│ │ ├── folder.service.ts # Folder CRUD
|
|
115
|
+
│ │ ├── storage-config.service.ts # Module configuration
|
|
116
|
+
│ │ ├── storage-provider-config.service.ts # Storage config CRUD
|
|
117
|
+
│ │ ├── storage-datasource.provider.ts # Dynamic entity loading
|
|
108
118
|
│ │ └── index.ts
|
|
109
119
|
│ │
|
|
110
120
|
│ ├── controllers/
|
|
111
|
-
│ │ ├── upload.controller.ts
|
|
112
|
-
│ │ ├── file-manager.controller.ts
|
|
113
|
-
│ │ ├── folder.controller.ts
|
|
114
|
-
│ │ ├── storage-config.controller.ts
|
|
121
|
+
│ │ ├── upload.controller.ts # /storage/upload/*
|
|
122
|
+
│ │ ├── file-manager.controller.ts # /storage/file-manager/*
|
|
123
|
+
│ │ ├── folder.controller.ts # /storage/folder/*
|
|
124
|
+
│ │ ├── storage-config.controller.ts # /storage/storage-config/*
|
|
115
125
|
│ │ └── index.ts
|
|
116
126
|
│ │
|
|
117
127
|
│ ├── entities/
|
|
118
|
-
│ │ ├── file-manager
|
|
119
|
-
│ │ ├── file-manager.entity.ts
|
|
128
|
+
│ │ ├── file-manager.entity.ts # FileManager base
|
|
120
129
|
│ │ ├── file-manager-with-company.entity.ts
|
|
121
|
-
│ │ ├── folder
|
|
122
|
-
│ │ ├── folder.entity.ts
|
|
130
|
+
│ │ ├── folder.entity.ts # Folder base
|
|
123
131
|
│ │ ├── folder-with-company.entity.ts
|
|
124
|
-
│ │ ├── storage-config
|
|
125
|
-
│ │ ├── storage-config.entity.ts
|
|
132
|
+
│ │ ├── storage-config.entity.ts # StorageConfig base
|
|
126
133
|
│ │ ├── storage-config-with-company.entity.ts
|
|
127
134
|
│ │ └── index.ts
|
|
128
135
|
│ │
|
|
129
136
|
│ ├── dtos/
|
|
130
|
-
│ │ ├── upload.dto.ts
|
|
131
|
-
│ │ ├── file-manager.dto.ts
|
|
132
|
-
│ │ ├── folder.dto.ts
|
|
133
|
-
│ │ ├── storage-config.dto.ts
|
|
137
|
+
│ │ ├── upload.dto.ts # Upload options, delete DTOs
|
|
138
|
+
│ │ ├── file-manager.dto.ts # File manager DTOs
|
|
139
|
+
│ │ ├── folder.dto.ts # Folder DTOs
|
|
140
|
+
│ │ ├── storage-config.dto.ts # Storage config DTOs
|
|
134
141
|
│ │ └── index.ts
|
|
135
142
|
│ │
|
|
136
143
|
│ ├── interfaces/
|
|
@@ -142,11 +149,20 @@ nestjs-storage/
|
|
|
142
149
|
│ │ └── index.ts
|
|
143
150
|
│ │
|
|
144
151
|
│ ├── enums/
|
|
145
|
-
│ │ ├── file-location.enum.ts
|
|
152
|
+
│ │ ├── file-location.enum.ts # Provider type enum
|
|
153
|
+
│ │ └── index.ts
|
|
154
|
+
│ │
|
|
155
|
+
│ ├── middlewares/
|
|
156
|
+
│ │ ├── file-serve.middleware.ts # File serving with fallback strategies
|
|
157
|
+
│ │ └── index.ts
|
|
158
|
+
│ │
|
|
159
|
+
│ ├── docs/
|
|
160
|
+
│ │ ├── storage-swagger.config.ts # Swagger configuration
|
|
146
161
|
│ │ └── index.ts
|
|
147
162
|
│ │
|
|
148
163
|
│ └── utils/
|
|
149
|
-
│ ├──
|
|
164
|
+
│ ├── file-validator.util.ts # Magic bytes validation
|
|
165
|
+
│ ├── image-compressor.util.ts # Sharp-based compression
|
|
150
166
|
│ └── index.ts
|
|
151
167
|
```
|
|
152
168
|
|
|
@@ -168,22 +184,21 @@ import { StorageModule } from '@flusys/nestjs-storage';
|
|
|
168
184
|
bootstrapAppConfig: {
|
|
169
185
|
databaseMode: 'single',
|
|
170
186
|
enableCompanyFeature: false,
|
|
171
|
-
permissionMode: 'RBAC',
|
|
187
|
+
permissionMode: 'RBAC',
|
|
172
188
|
},
|
|
173
189
|
config: {
|
|
174
190
|
defaultDatabaseConfig: {
|
|
175
|
-
type: '
|
|
191
|
+
type: 'postgres',
|
|
176
192
|
host: 'localhost',
|
|
177
|
-
port:
|
|
178
|
-
username: '
|
|
193
|
+
port: 5432,
|
|
194
|
+
username: 'postgres',
|
|
179
195
|
password: 'password',
|
|
180
196
|
database: 'myapp',
|
|
181
197
|
},
|
|
182
|
-
// File validation
|
|
183
198
|
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
184
199
|
allowedFileTypes: ['image/*', 'application/pdf', 'text/*'],
|
|
185
|
-
|
|
186
|
-
appUrl: process.env.APP_URL ||
|
|
200
|
+
localStoragePath: './uploads',
|
|
201
|
+
appUrl: process.env.APP_URL || 'http://localhost:3000',
|
|
187
202
|
},
|
|
188
203
|
}),
|
|
189
204
|
],
|
|
@@ -199,20 +214,15 @@ StorageModule.forRoot({
|
|
|
199
214
|
includeController: true,
|
|
200
215
|
bootstrapAppConfig: {
|
|
201
216
|
databaseMode: 'single',
|
|
202
|
-
enableCompanyFeature: true,
|
|
203
|
-
permissionMode: 'FULL',
|
|
217
|
+
enableCompanyFeature: true,
|
|
218
|
+
permissionMode: 'FULL',
|
|
204
219
|
},
|
|
205
220
|
config: {
|
|
206
|
-
defaultDatabaseConfig: {
|
|
207
|
-
type: 'mysql',
|
|
208
|
-
host: 'localhost',
|
|
209
|
-
port: 3306,
|
|
210
|
-
username: 'root',
|
|
211
|
-
password: 'password',
|
|
212
|
-
database: 'myapp',
|
|
213
|
-
},
|
|
221
|
+
defaultDatabaseConfig: { /* ... */ },
|
|
214
222
|
maxFileSize: 10 * 1024 * 1024,
|
|
215
223
|
allowedFileTypes: ['image/*', 'application/pdf'],
|
|
224
|
+
localStoragePath: './uploads',
|
|
225
|
+
appUrl: process.env.APP_URL,
|
|
216
226
|
},
|
|
217
227
|
});
|
|
218
228
|
```
|
|
@@ -233,6 +243,8 @@ StorageModule.forRootAsync({
|
|
|
233
243
|
defaultDatabaseConfig: configService.getDatabaseConfig(),
|
|
234
244
|
maxFileSize: configService.get('MAX_FILE_SIZE'),
|
|
235
245
|
allowedFileTypes: configService.get('ALLOWED_FILE_TYPES'),
|
|
246
|
+
localStoragePath: configService.get('UPLOAD_PATH'),
|
|
247
|
+
appUrl: configService.get('APP_URL'),
|
|
236
248
|
}),
|
|
237
249
|
inject: [ConfigService],
|
|
238
250
|
});
|
|
@@ -241,28 +253,28 @@ StorageModule.forRootAsync({
|
|
|
241
253
|
### Configuration Options
|
|
242
254
|
|
|
243
255
|
```typescript
|
|
244
|
-
interface
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
allowedFileTypes?: string[]; // MIME type patterns
|
|
256
|
-
uploadPath?: string; // Local upload path
|
|
257
|
-
};
|
|
256
|
+
interface IStorageModuleConfig extends IDataSourceServiceOptions {
|
|
257
|
+
/** Maximum file size in bytes (default: 100MB) */
|
|
258
|
+
maxFileSize?: number;
|
|
259
|
+
/** Allowed MIME types or patterns (default: ['*/*']) */
|
|
260
|
+
allowedFileTypes?: string[];
|
|
261
|
+
/** Default storage provider (optional) */
|
|
262
|
+
defaultStorageProvider?: string;
|
|
263
|
+
/** Local storage path (default: './uploads') */
|
|
264
|
+
localStoragePath?: string;
|
|
265
|
+
/** Application base URL for file URLs */
|
|
266
|
+
appUrl?: string;
|
|
258
267
|
}
|
|
259
|
-
```
|
|
260
268
|
|
|
261
|
-
|
|
269
|
+
interface StorageModuleOptions extends IDynamicModuleConfig {
|
|
270
|
+
bootstrapAppConfig?: IBootstrapAppConfig;
|
|
271
|
+
config?: IStorageModuleConfig;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
262
274
|
|
|
263
275
|
### Swagger Schema Behavior
|
|
264
276
|
|
|
265
|
-
When `enableCompanyFeature: false`, the following properties are automatically hidden from Swagger
|
|
277
|
+
When `enableCompanyFeature: false`, the following properties are automatically hidden from Swagger:
|
|
266
278
|
|
|
267
279
|
| DTO | Hidden Fields |
|
|
268
280
|
| -------------------------- | ------------- |
|
|
@@ -270,12 +282,21 @@ When `enableCompanyFeature: false`, the following properties are automatically h
|
|
|
270
282
|
| `FolderResponseDto` | `companyId` |
|
|
271
283
|
| `StorageConfigResponseDto` | `companyId` |
|
|
272
284
|
|
|
273
|
-
This ensures the API documentation accurately reflects the available fields based on your configuration.
|
|
274
|
-
|
|
275
285
|
---
|
|
276
286
|
|
|
277
287
|
## Entities
|
|
278
288
|
|
|
289
|
+
### FileLocationEnum
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
export enum FileLocationEnum {
|
|
293
|
+
AWS = 'aws',
|
|
294
|
+
AZURE = 'azure',
|
|
295
|
+
SFTP = 'sftp',
|
|
296
|
+
LOCAL = 'local',
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
279
300
|
### Entity Groups
|
|
280
301
|
|
|
281
302
|
```typescript
|
|
@@ -294,58 +315,201 @@ export function getStorageEntitiesByConfig(enableCompanyFeature: boolean): any[]
|
|
|
294
315
|
return enableCompanyFeature ? StorageCompanyEntities : StorageCoreEntities;
|
|
295
316
|
}
|
|
296
317
|
|
|
297
|
-
// Base type aliases
|
|
318
|
+
// Base type aliases
|
|
298
319
|
export { FileManager as FileManagerBase } from './file-manager.entity';
|
|
299
320
|
export { Folder as FolderBase } from './folder.entity';
|
|
300
321
|
export { StorageConfig as StorageConfigBase } from './storage-config.entity';
|
|
301
322
|
```
|
|
302
323
|
|
|
324
|
+
### FileManager Entity
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
@Entity({ name: 'file_manager' })
|
|
328
|
+
export class FileManager extends Identity {
|
|
329
|
+
@Column({ type: 'varchar', length: 255 })
|
|
330
|
+
name!: string; // Original filename
|
|
331
|
+
|
|
332
|
+
@Column({ type: 'varchar', length: 255 })
|
|
333
|
+
contentType!: string; // MIME type
|
|
334
|
+
|
|
335
|
+
@Column({ type: 'varchar', length: 255 })
|
|
336
|
+
size!: string; // File size as string
|
|
337
|
+
|
|
338
|
+
@Column({ type: 'text' })
|
|
339
|
+
key!: string; // Storage key/path
|
|
340
|
+
|
|
341
|
+
@Column({ type: 'text', nullable: true })
|
|
342
|
+
url!: string | null; // Public/presigned URL
|
|
343
|
+
|
|
344
|
+
@Column({ type: 'varchar', length: 50 })
|
|
345
|
+
location!: string; // Provider type (local, aws, azure, sftp)
|
|
346
|
+
|
|
347
|
+
@Column({ type: 'uuid', nullable: true })
|
|
348
|
+
storageConfigId!: string | null;
|
|
349
|
+
|
|
350
|
+
@Column({ type: 'bigint', nullable: true })
|
|
351
|
+
expiresAt: number | null = null; // URL expiration timestamp
|
|
352
|
+
|
|
353
|
+
@Column({ type: 'boolean', default: false })
|
|
354
|
+
isPrivate!: boolean;
|
|
355
|
+
|
|
356
|
+
@ManyToOne('Folder', (folder: any) => folder.fileManager, { nullable: true, onDelete: 'SET NULL' })
|
|
357
|
+
@JoinColumn({ name: 'folder_id' })
|
|
358
|
+
folder!: Folder | null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// With company feature
|
|
362
|
+
@Entity({ name: 'file_manager' })
|
|
363
|
+
export class FileManagerWithCompany extends FileManager {
|
|
364
|
+
@Column({ type: 'uuid', nullable: true })
|
|
365
|
+
companyId!: string | null;
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Folder Entity
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
@Entity({ name: 'folder' })
|
|
373
|
+
export class Folder extends Identity {
|
|
374
|
+
@Column({ type: 'varchar', length: 255 })
|
|
375
|
+
name!: string;
|
|
376
|
+
|
|
377
|
+
@Column({ type: 'varchar', length: 255 })
|
|
378
|
+
slug!: string;
|
|
379
|
+
|
|
380
|
+
@OneToMany('FileManager', (fileManager: any) => fileManager.folder)
|
|
381
|
+
fileManager!: any[];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// With company feature
|
|
385
|
+
@Entity({ name: 'folder' })
|
|
386
|
+
export class FolderWithCompany extends Folder {
|
|
387
|
+
@Column({ type: 'uuid', nullable: true })
|
|
388
|
+
companyId!: string | null;
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### StorageConfig Entity
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
@Entity({ name: 'storage_config' })
|
|
396
|
+
@Index(['name'])
|
|
397
|
+
@Index(['storage'])
|
|
398
|
+
@Index(['isActive'])
|
|
399
|
+
@Index(['isDefault'])
|
|
400
|
+
export class StorageConfig extends Identity {
|
|
401
|
+
@Column({ type: 'varchar', length: 255 })
|
|
402
|
+
name!: string; // e.g., 'default', 'backups', 'media'
|
|
403
|
+
|
|
404
|
+
@Column({ type: 'varchar', length: 50 })
|
|
405
|
+
storage!: string; // Provider type (local, aws, azure, sftp)
|
|
406
|
+
|
|
407
|
+
@Column({ type: 'json' })
|
|
408
|
+
config!: Record<string, any>; // Provider-specific config
|
|
409
|
+
|
|
410
|
+
@Column({ type: 'boolean', default: true, name: 'is_active' })
|
|
411
|
+
isActive!: boolean;
|
|
412
|
+
|
|
413
|
+
@Column({ type: 'boolean', default: false, name: 'is_default' })
|
|
414
|
+
isDefault!: boolean;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// With company feature
|
|
418
|
+
@Entity({ name: 'storage_config' })
|
|
419
|
+
export class StorageConfigWithCompany extends StorageConfig {
|
|
420
|
+
@Column({ type: 'uuid', nullable: true })
|
|
421
|
+
companyId!: string | null;
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
303
425
|
---
|
|
304
426
|
|
|
305
427
|
## Storage Providers
|
|
306
428
|
|
|
307
|
-
### Provider
|
|
429
|
+
### Provider Interface
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
interface IStorageProvider {
|
|
433
|
+
uploadFile(file: Express.Multer.File, options: UploadOptionsDto): Promise<IUploadedFileInfo>;
|
|
434
|
+
uploadMultipleFiles(files: Express.Multer.File[], options: UploadOptionsDto): Promise<IUploadedFileInfo[]>;
|
|
435
|
+
deleteFile(key: string): Promise<void>;
|
|
436
|
+
deleteMultipleFiles(keys: string[]): Promise<void>;
|
|
437
|
+
generatePresignedUrl(key: string, expiresInSeconds?: number): Promise<string>;
|
|
438
|
+
healthCheck(): Promise<boolean>;
|
|
439
|
+
initialize?(config: any): Promise<void>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
interface IUploadedFileInfo {
|
|
443
|
+
key: string;
|
|
444
|
+
contentType: string;
|
|
445
|
+
size: number;
|
|
446
|
+
name: string;
|
|
447
|
+
location?: string;
|
|
448
|
+
storageConfigId?: string;
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Provider Registration
|
|
453
|
+
|
|
454
|
+
Providers are automatically registered when the module loads:
|
|
308
455
|
|
|
309
456
|
```typescript
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
457
|
+
// storage.module.ts
|
|
458
|
+
StorageProviderRegistry.register(FileLocationEnum.LOCAL, LocalProvider);
|
|
459
|
+
|
|
460
|
+
// Optional providers loaded dynamically
|
|
461
|
+
const OPTIONAL_PROVIDERS = [
|
|
462
|
+
{ location: FileLocationEnum.AWS, path: '../providers/s3-provider.optional', name: 'S3Provider', dep: '@aws-sdk/client-s3' },
|
|
463
|
+
{ location: FileLocationEnum.AZURE, path: '../providers/azure-provider.optional', name: 'AzureProvider', dep: '@azure/storage-blob' },
|
|
464
|
+
{ location: FileLocationEnum.SFTP, path: '../providers/sftp-provider.optional', name: 'SftpProvider', dep: 'ssh2-sftp-client' },
|
|
465
|
+
];
|
|
466
|
+
|
|
467
|
+
for (const { location, path, name, dep } of OPTIONAL_PROVIDERS) {
|
|
468
|
+
try {
|
|
469
|
+
StorageProviderRegistry.register(location, require(path)[name]);
|
|
470
|
+
logger.log(`Registered ${name}`);
|
|
471
|
+
} catch {
|
|
472
|
+
logger.debug(`${name} not available (install ${dep} to enable)`);
|
|
473
|
+
}
|
|
315
474
|
}
|
|
316
475
|
```
|
|
317
476
|
|
|
318
477
|
### Local Provider
|
|
319
478
|
|
|
320
479
|
```typescript
|
|
321
|
-
//
|
|
322
|
-
//
|
|
480
|
+
// Built-in, no external dependencies required
|
|
481
|
+
// Uses Node.js fs module
|
|
323
482
|
|
|
324
483
|
// Configuration:
|
|
325
484
|
{
|
|
326
|
-
storage:
|
|
485
|
+
storage: 'local',
|
|
327
486
|
config: {
|
|
328
|
-
|
|
487
|
+
basePath: './uploads', // Base directory
|
|
488
|
+
baseUrl: 'http://localhost:3000' // Optional, for URL generation
|
|
329
489
|
}
|
|
330
490
|
}
|
|
331
491
|
```
|
|
332
492
|
|
|
493
|
+
**Features:**
|
|
494
|
+
- Path traversal attack prevention
|
|
495
|
+
- Automatic directory creation
|
|
496
|
+
- UUID-prefixed filenames
|
|
497
|
+
- Image compression support
|
|
498
|
+
|
|
333
499
|
### AWS S3 Provider
|
|
334
500
|
|
|
335
501
|
```typescript
|
|
336
|
-
// Requires: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
502
|
+
// Requires: npm install @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
|
|
337
503
|
|
|
338
504
|
// Configuration:
|
|
339
505
|
{
|
|
340
|
-
storage:
|
|
506
|
+
storage: 'aws',
|
|
341
507
|
config: {
|
|
342
508
|
region: 'us-east-1',
|
|
343
509
|
bucket: 'my-bucket',
|
|
344
510
|
accessKeyId: 'AKIA...',
|
|
345
511
|
secretAccessKey: 'secret...',
|
|
346
|
-
// Optional
|
|
347
|
-
endpoint: 'https://s3.us-east-1.amazonaws.com',
|
|
348
|
-
forcePathStyle: false,
|
|
512
|
+
endpoint: 'https://s3.us-east-1.amazonaws.com' // Optional
|
|
349
513
|
}
|
|
350
514
|
}
|
|
351
515
|
```
|
|
@@ -357,13 +521,13 @@ enum FileLocationEnum {
|
|
|
357
521
|
|
|
358
522
|
// Configuration:
|
|
359
523
|
{
|
|
360
|
-
storage:
|
|
524
|
+
storage: 'azure',
|
|
361
525
|
config: {
|
|
362
|
-
connectionString: 'DefaultEndpointsProtocol=https;...',
|
|
363
|
-
containerName: 'my-container',
|
|
364
|
-
// Or use SAS token
|
|
365
526
|
accountName: 'myaccount',
|
|
366
|
-
|
|
527
|
+
accountKey: 'key...',
|
|
528
|
+
containerName: 'my-container',
|
|
529
|
+
// Or use connection string
|
|
530
|
+
connectionString: 'DefaultEndpointsProtocol=https;...'
|
|
367
531
|
}
|
|
368
532
|
}
|
|
369
533
|
```
|
|
@@ -375,7 +539,7 @@ enum FileLocationEnum {
|
|
|
375
539
|
|
|
376
540
|
// Configuration:
|
|
377
541
|
{
|
|
378
|
-
storage:
|
|
542
|
+
storage: 'sftp',
|
|
379
543
|
config: {
|
|
380
544
|
host: 'sftp.example.com',
|
|
381
545
|
port: 22,
|
|
@@ -383,60 +547,51 @@ enum FileLocationEnum {
|
|
|
383
547
|
password: 'password',
|
|
384
548
|
// Or use private key
|
|
385
549
|
privateKey: '-----BEGIN RSA PRIVATE KEY-----...',
|
|
386
|
-
basePath: '/uploads'
|
|
550
|
+
basePath: '/uploads'
|
|
387
551
|
}
|
|
388
552
|
}
|
|
389
553
|
```
|
|
390
554
|
|
|
391
|
-
### Provider
|
|
392
|
-
|
|
393
|
-
Providers are automatically registered via `UploadService.onModuleInit()`:
|
|
394
|
-
- **Local** - Always available (no external dependencies)
|
|
395
|
-
- **S3/Azure/SFTP** - Registered only if their SDK dependencies are installed
|
|
396
|
-
|
|
397
|
-
### Provider Connection Reuse
|
|
398
|
-
|
|
399
|
-
The `StorageFactoryService` caches provider instances:
|
|
555
|
+
### Provider Factory & Caching
|
|
400
556
|
|
|
401
557
|
```typescript
|
|
402
|
-
// storage-factory.service.ts
|
|
403
558
|
@Injectable()
|
|
404
559
|
export class StorageFactoryService implements OnModuleDestroy {
|
|
405
|
-
private
|
|
406
|
-
|
|
407
|
-
// Generate unique cache key using SHA256 hash
|
|
408
|
-
private generateCacheKey(config: IStorageProviderConfig): string {
|
|
409
|
-
const configString = JSON.stringify(config.config, Object.keys(config.config || {}).sort());
|
|
410
|
-
const configHash = crypto
|
|
411
|
-
.createHash('sha256')
|
|
412
|
-
.update(configString)
|
|
413
|
-
.digest('hex')
|
|
414
|
-
.substring(0, 16);
|
|
415
|
-
return `${config.provider}-${configHash}`;
|
|
416
|
-
}
|
|
560
|
+
private readonly cache = new Map<string, IStorageProvider>();
|
|
417
561
|
|
|
418
562
|
async createProvider(config: IStorageProviderConfig): Promise<IStorageProvider> {
|
|
419
563
|
const cacheKey = this.generateCacheKey(config);
|
|
420
564
|
|
|
421
|
-
|
|
422
|
-
if (
|
|
423
|
-
|
|
565
|
+
const cached = this.cache.get(cacheKey);
|
|
566
|
+
if (cached) return cached;
|
|
567
|
+
|
|
568
|
+
const ProviderClass = StorageProviderRegistry.get(config.provider);
|
|
569
|
+
if (!ProviderClass) {
|
|
570
|
+
throw new NotFoundException(`Storage provider '${config.provider}' not registered`);
|
|
424
571
|
}
|
|
425
572
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
this.
|
|
429
|
-
return
|
|
573
|
+
const instance = new ProviderClass();
|
|
574
|
+
await this.initializeProvider(instance, config);
|
|
575
|
+
this.cache.set(cacheKey, instance);
|
|
576
|
+
return instance;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Generate cache key using SHA256 hash of config
|
|
580
|
+
private generateCacheKey(config: IStorageProviderConfig): string {
|
|
581
|
+
const hash = crypto.createHash('sha256')
|
|
582
|
+
.update(JSON.stringify(config.config, Object.keys(config.config || {}).sort()))
|
|
583
|
+
.digest('hex').substring(0, 16);
|
|
584
|
+
return `${config.provider}-${hash}`;
|
|
430
585
|
}
|
|
431
586
|
|
|
432
587
|
// Cleanup on module destroy
|
|
433
588
|
async onModuleDestroy(): Promise<void> {
|
|
434
|
-
for (const [key, provider] of this.
|
|
589
|
+
for (const [key, provider] of this.cache.entries()) {
|
|
435
590
|
if ('close' in provider && typeof (provider as any).close === 'function') {
|
|
436
591
|
await (provider as any).close();
|
|
437
592
|
}
|
|
438
593
|
}
|
|
439
|
-
this.
|
|
594
|
+
this.cache.clear();
|
|
440
595
|
}
|
|
441
596
|
}
|
|
442
597
|
```
|
|
@@ -445,30 +600,53 @@ export class StorageFactoryService implements OnModuleDestroy {
|
|
|
445
600
|
|
|
446
601
|
## Storage Configuration
|
|
447
602
|
|
|
448
|
-
###
|
|
449
|
-
|
|
450
|
-
Each company can have multiple storage configurations:
|
|
603
|
+
### StorageProviderConfigService
|
|
451
604
|
|
|
452
605
|
```typescript
|
|
453
|
-
@
|
|
454
|
-
export class
|
|
455
|
-
@Column({ length: 100 })
|
|
456
|
-
name: string; // e.g., 'default', 'backups', 'media'
|
|
606
|
+
@Injectable({ scope: Scope.REQUEST })
|
|
607
|
+
export class StorageProviderConfigService extends RequestScopedApiService<...> {
|
|
457
608
|
|
|
458
|
-
|
|
459
|
-
|
|
609
|
+
/** Direct lookup by ID (bypasses company filtering) */
|
|
610
|
+
async findByIdDirect(id: string): Promise<StorageConfigBase | null>;
|
|
460
611
|
|
|
461
|
-
|
|
462
|
-
|
|
612
|
+
/** Get default config for user's company */
|
|
613
|
+
async getDefaultConfig(user?: ILoggedUserInfo): Promise<StorageConfigBase | null>;
|
|
614
|
+
|
|
615
|
+
/** Get configs by storage type */
|
|
616
|
+
async getConfigByType(storage: string, user?: ILoggedUserInfo): Promise<StorageConfigBase[]>;
|
|
617
|
+
}
|
|
618
|
+
```
|
|
463
619
|
|
|
464
|
-
|
|
465
|
-
isActive: boolean; // Whether config is active and usable
|
|
620
|
+
### Default Configuration Resolution
|
|
466
621
|
|
|
467
|
-
|
|
468
|
-
isDefault: boolean; // Set as default configuration
|
|
622
|
+
When `storageConfigId` is not provided for uploads:
|
|
469
623
|
|
|
470
|
-
|
|
471
|
-
|
|
624
|
+
1. **Priority 1:** Find config with `isDefault: true` and `isActive: true`
|
|
625
|
+
2. **Priority 2:** Fall back to oldest active config
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
async getDefaultConfig(user?: ILoggedUserInfo): Promise<StorageConfigBase | null> {
|
|
629
|
+
await this.ensureRepositoryInitialized();
|
|
630
|
+
|
|
631
|
+
const baseWhere = buildCompanyWhereCondition(
|
|
632
|
+
{ isActive: true },
|
|
633
|
+
this.storageConfig.isCompanyFeatureEnabled(),
|
|
634
|
+
user,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
// First try to find config marked as default
|
|
638
|
+
const defaultConfig = await this.repository.findOne({
|
|
639
|
+
where: { ...baseWhere, isDefault: true },
|
|
640
|
+
order: { createdAt: 'ASC' },
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (defaultConfig) return defaultConfig;
|
|
644
|
+
|
|
645
|
+
// Fall back to oldest active config
|
|
646
|
+
return await this.repository.findOne({
|
|
647
|
+
where: baseWhere,
|
|
648
|
+
order: { createdAt: 'ASC' },
|
|
649
|
+
});
|
|
472
650
|
}
|
|
473
651
|
```
|
|
474
652
|
|
|
@@ -483,737 +661,646 @@ export class SetupService {
|
|
|
483
661
|
|
|
484
662
|
async setupStorageConfigs(user: ILoggedUserInfo) {
|
|
485
663
|
// Create default S3 config
|
|
486
|
-
await this.storageConfigService.insert(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
secretAccessKey: process.env.AWS_SECRET_KEY,
|
|
495
|
-
},
|
|
496
|
-
isActive: true,
|
|
497
|
-
isDefault: true, // Set as default configuration
|
|
664
|
+
await this.storageConfigService.insert({
|
|
665
|
+
name: 'default',
|
|
666
|
+
storage: 'aws',
|
|
667
|
+
config: {
|
|
668
|
+
region: 'us-east-1',
|
|
669
|
+
bucket: 'company-files',
|
|
670
|
+
accessKeyId: process.env.AWS_ACCESS_KEY,
|
|
671
|
+
secretAccessKey: process.env.AWS_SECRET_KEY,
|
|
498
672
|
},
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
username: 'backup-user',
|
|
510
|
-
privateKey: process.env.SFTP_PRIVATE_KEY,
|
|
511
|
-
basePath: '/backups',
|
|
512
|
-
},
|
|
513
|
-
isActive: true,
|
|
514
|
-
isDefault: false, // Not the default
|
|
673
|
+
isActive: true,
|
|
674
|
+
isDefault: true,
|
|
675
|
+
}, user);
|
|
676
|
+
|
|
677
|
+
// Create local backup config
|
|
678
|
+
await this.storageConfigService.insert({
|
|
679
|
+
name: 'local-backup',
|
|
680
|
+
storage: 'local',
|
|
681
|
+
config: {
|
|
682
|
+
basePath: './backups',
|
|
515
683
|
},
|
|
516
|
-
|
|
517
|
-
|
|
684
|
+
isActive: true,
|
|
685
|
+
isDefault: false,
|
|
686
|
+
}, user);
|
|
518
687
|
}
|
|
519
688
|
}
|
|
520
689
|
```
|
|
521
690
|
|
|
522
|
-
|
|
691
|
+
---
|
|
523
692
|
|
|
524
|
-
|
|
693
|
+
## File Manager
|
|
525
694
|
|
|
526
|
-
|
|
695
|
+
### FileManagerService
|
|
527
696
|
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
▼ ▼
|
|
546
|
-
┌─────────────────────────────────────────────────────────┐
|
|
547
|
-
│ 4. Validate company ownership (if company feature) │
|
|
548
|
-
│ - Throws BadRequestException if wrong company │
|
|
549
|
-
└─────────────────────────────────────────────────────────┘
|
|
550
|
-
│
|
|
551
|
-
▼
|
|
552
|
-
┌─────────────────────────────────────────────────────────┐
|
|
553
|
-
│ 5. Create/reuse storage provider instance │
|
|
554
|
-
│ - Check provider cache by config hash │
|
|
555
|
-
│ - Create new if not cached │
|
|
556
|
-
└─────────────────────────────────────────────────────────┘
|
|
557
|
-
│
|
|
558
|
-
▼
|
|
559
|
-
[Perform Upload]
|
|
697
|
+
```typescript
|
|
698
|
+
@Injectable({ scope: Scope.REQUEST })
|
|
699
|
+
export class FileManagerService extends RequestScopedApiService<...> {
|
|
700
|
+
|
|
701
|
+
/** Enrich files with provider names */
|
|
702
|
+
async enrichWithProviderNames<T extends { storageConfigId?: string | null }>(
|
|
703
|
+
items: T[]
|
|
704
|
+
): Promise<(T & { providerName?: string })[]>;
|
|
705
|
+
|
|
706
|
+
/** Get multiple files with refreshed URLs */
|
|
707
|
+
async getFiles(
|
|
708
|
+
dtos: GetFilesRequestDto[],
|
|
709
|
+
protocol: string,
|
|
710
|
+
host: string,
|
|
711
|
+
user?: ILoggedUserInfo
|
|
712
|
+
): Promise<FilesResponseDto[]>;
|
|
713
|
+
}
|
|
560
714
|
```
|
|
561
715
|
|
|
562
|
-
|
|
716
|
+
### URL Generation & Refresh
|
|
563
717
|
|
|
564
|
-
|
|
565
|
-
// UploadService
|
|
566
|
-
private async getStorageProvider(
|
|
567
|
-
storageConfigId?: string,
|
|
568
|
-
user?: ILoggedUserInfo,
|
|
569
|
-
): Promise<{ provider: IStorageProvider; config: StorageConfigBase }> {
|
|
570
|
-
let config: StorageConfigBase | null = null;
|
|
571
|
-
|
|
572
|
-
// Step 1: Try to get by ID or fallback to default
|
|
573
|
-
if (storageConfigId) {
|
|
574
|
-
config = await this.storageProviderConfigService.findByIdDirect(storageConfigId);
|
|
575
|
-
}
|
|
718
|
+
Files automatically get presigned URLs when needed:
|
|
576
719
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
720
|
+
```typescript
|
|
721
|
+
async getFiles(dtos: GetFilesRequestDto[], protocol: string, host: string, user?: ILoggedUserInfo): Promise<FilesResponseDto[]> {
|
|
722
|
+
const files = await this.repository.findBy({ id: In(ids) });
|
|
723
|
+
const now = Date.now();
|
|
724
|
+
const updatedFiles: FileManagerBase[] = [];
|
|
580
725
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
726
|
+
const responses = await Promise.all(
|
|
727
|
+
files.map(async (file) => {
|
|
728
|
+
const updated = await this.refreshFileUrl(file, protocol, host, now, user);
|
|
729
|
+
if (updated) updatedFiles.push(file);
|
|
730
|
+
return this.toFileResponse(file);
|
|
731
|
+
}),
|
|
732
|
+
);
|
|
584
733
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const configWithCompany = config as StorageConfigWithCompany;
|
|
588
|
-
if (configWithCompany.companyId && configWithCompany.companyId !== user.companyId) {
|
|
589
|
-
throw new BadRequestException('Storage configuration belongs to another company');
|
|
590
|
-
}
|
|
734
|
+
if (updatedFiles.length) {
|
|
735
|
+
await this.repository.save(updatedFiles);
|
|
591
736
|
}
|
|
592
737
|
|
|
593
|
-
|
|
594
|
-
const provider = await this.storageFactory.createProvider({
|
|
595
|
-
provider: config.storage,
|
|
596
|
-
config: config.config,
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
return { provider, config };
|
|
738
|
+
return responses;
|
|
600
739
|
}
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
**Key Points:**
|
|
604
|
-
- Always specify `storageConfigId` for deterministic behavior
|
|
605
|
-
- Without `storageConfigId`, system uses company's default config
|
|
606
|
-
- Company ownership is always validated when company feature is enabled
|
|
607
|
-
- Provider instances are cached for connection reuse
|
|
608
|
-
|
|
609
|
-
### Default Configuration Resolution
|
|
610
|
-
|
|
611
|
-
When `storageConfigId` is not provided for uploads, the system automatically resolves the default config:
|
|
612
|
-
|
|
613
|
-
1. **Priority 1:** Find config with `isDefault: true` and `isActive: true`
|
|
614
|
-
2. **Priority 2:** Fall back to oldest active config
|
|
615
|
-
|
|
616
|
-
```typescript
|
|
617
|
-
// StorageProviderConfigService
|
|
618
|
-
async getDefaultConfig(user?: ILoggedUserInfo): Promise<StorageConfigBase | null> {
|
|
619
|
-
await this.ensureRepositoryInitialized();
|
|
620
740
|
|
|
621
|
-
|
|
741
|
+
private async refreshFileUrl(file, protocol, host, now, user): Promise<boolean> {
|
|
742
|
+
const isCloudProvider = file.location === 'aws' || file.location === 'azure';
|
|
743
|
+
const needsNewUrl = !file.url || (isCloudProvider && now >= file.expiresAt);
|
|
622
744
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
745
|
+
if (needsNewUrl && file.storageConfigId) {
|
|
746
|
+
file.url = await this.uploadService.makeFileUrl(file.key, file.storageConfigId, 3600, user);
|
|
747
|
+
file.expiresAt = now + 3600 * 1000;
|
|
748
|
+
return true;
|
|
626
749
|
}
|
|
627
750
|
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
if (defaultConfig) return defaultConfig;
|
|
751
|
+
// For local/SFTP, generate serving URL
|
|
752
|
+
if (file.location === 'sftp' || file.location === 'local') {
|
|
753
|
+
const baseUrl = this.getFileBaseUrl(protocol, host);
|
|
754
|
+
file.url = `${baseUrl}/storage/upload/file/${file.key}`;
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
635
757
|
|
|
636
|
-
|
|
637
|
-
return await this.repository.findOne({
|
|
638
|
-
where: baseWhere,
|
|
639
|
-
order: { createdAt: 'ASC' },
|
|
640
|
-
});
|
|
758
|
+
return false;
|
|
641
759
|
}
|
|
642
760
|
```
|
|
643
761
|
|
|
644
762
|
---
|
|
645
763
|
|
|
646
|
-
##
|
|
764
|
+
## Folder Management
|
|
647
765
|
|
|
648
|
-
###
|
|
766
|
+
### FolderService
|
|
649
767
|
|
|
650
768
|
```typescript
|
|
651
|
-
@
|
|
652
|
-
export class
|
|
653
|
-
|
|
654
|
-
|
|
769
|
+
@Injectable({ scope: Scope.REQUEST })
|
|
770
|
+
export class FolderService extends RequestScopedApiService<
|
|
771
|
+
CreateFolderDto,
|
|
772
|
+
UpdateFolderDto,
|
|
773
|
+
IFolder,
|
|
774
|
+
FolderBase,
|
|
775
|
+
Repository<FolderBase>
|
|
776
|
+
> {
|
|
777
|
+
// Standard CRUD operations inherited from RequestScopedApiService
|
|
778
|
+
// Company filtering applied automatically when enabled
|
|
779
|
+
}
|
|
780
|
+
```
|
|
655
781
|
|
|
656
|
-
|
|
657
|
-
contentType: string; // MIME type
|
|
782
|
+
### Folder DTOs
|
|
658
783
|
|
|
659
|
-
|
|
660
|
-
|
|
784
|
+
```typescript
|
|
785
|
+
export class CreateFolderDto {
|
|
786
|
+
@IsNotEmpty()
|
|
787
|
+
@IsString()
|
|
788
|
+
name!: string;
|
|
789
|
+
}
|
|
661
790
|
|
|
662
|
-
|
|
663
|
-
|
|
791
|
+
export class UpdateFolderDto extends PartialType(CreateFolderDto) {
|
|
792
|
+
@IsUUID()
|
|
793
|
+
@IsNotEmpty()
|
|
794
|
+
id!: string;
|
|
795
|
+
}
|
|
664
796
|
|
|
665
|
-
|
|
666
|
-
|
|
797
|
+
export class FolderResponseDto {
|
|
798
|
+
id!: string;
|
|
799
|
+
name!: string;
|
|
800
|
+
slug!: string;
|
|
801
|
+
}
|
|
802
|
+
```
|
|
667
803
|
|
|
668
|
-
|
|
669
|
-
location: FileLocationEnum;
|
|
804
|
+
---
|
|
670
805
|
|
|
671
|
-
|
|
672
|
-
isPrivate: boolean;
|
|
806
|
+
## Upload Service
|
|
673
807
|
|
|
674
|
-
|
|
675
|
-
folderId: string;
|
|
808
|
+
### UploadService
|
|
676
809
|
|
|
677
|
-
|
|
678
|
-
|
|
810
|
+
```typescript
|
|
811
|
+
@Injectable({ scope: Scope.REQUEST })
|
|
812
|
+
export class UploadService {
|
|
813
|
+
|
|
814
|
+
/** Upload single file with validation */
|
|
815
|
+
async uploadSingleFile(
|
|
816
|
+
file: Express.Multer.File,
|
|
817
|
+
options: UploadOptionsDto,
|
|
818
|
+
user?: ILoggedUserInfo
|
|
819
|
+
): Promise<IUploadedFileInfo>;
|
|
820
|
+
|
|
821
|
+
/** Upload multiple files */
|
|
822
|
+
async uploadMultipleFiles(
|
|
823
|
+
files: Express.Multer.File[],
|
|
824
|
+
options: UploadOptionsDto,
|
|
825
|
+
user?: ILoggedUserInfo
|
|
826
|
+
): Promise<IUploadedFileInfo[]>;
|
|
827
|
+
|
|
828
|
+
/** Delete single file */
|
|
829
|
+
async deleteSingleFile(
|
|
830
|
+
key: string,
|
|
831
|
+
storageConfigId?: string,
|
|
832
|
+
user?: ILoggedUserInfo,
|
|
833
|
+
locationHint?: string
|
|
834
|
+
): Promise<boolean>;
|
|
835
|
+
|
|
836
|
+
/** Delete multiple files */
|
|
837
|
+
async deleteMultipleFile(
|
|
838
|
+
keys: string[],
|
|
839
|
+
storageConfigId?: string,
|
|
840
|
+
user?: ILoggedUserInfo,
|
|
841
|
+
locationHint?: string
|
|
842
|
+
): Promise<boolean>;
|
|
843
|
+
|
|
844
|
+
/** Generate presigned URL */
|
|
845
|
+
async makeFileUrl(
|
|
846
|
+
key: string,
|
|
847
|
+
storageConfigId: string,
|
|
848
|
+
expiresIn?: number,
|
|
849
|
+
user?: ILoggedUserInfo
|
|
850
|
+
): Promise<string>;
|
|
851
|
+
|
|
852
|
+
/** Get local storage base path from DB config */
|
|
853
|
+
async getLocalStorageBasePath(): Promise<string | null>;
|
|
854
|
+
}
|
|
855
|
+
```
|
|
679
856
|
|
|
680
|
-
|
|
681
|
-
storageConfigId: string;
|
|
857
|
+
### Upload Options DTO
|
|
682
858
|
|
|
683
|
-
|
|
684
|
-
|
|
859
|
+
```typescript
|
|
860
|
+
export enum ImageFormat {
|
|
861
|
+
ORIGINAL = 'original',
|
|
862
|
+
JPEG = 'jpeg',
|
|
863
|
+
PNG = 'png',
|
|
864
|
+
WEBP = 'webp',
|
|
865
|
+
}
|
|
685
866
|
|
|
686
|
-
|
|
687
|
-
|
|
867
|
+
export class UploadOptionsDto {
|
|
868
|
+
@IsOptional()
|
|
869
|
+
@IsUUID()
|
|
870
|
+
storageConfigId?: string;
|
|
871
|
+
|
|
872
|
+
@IsOptional()
|
|
873
|
+
@Matches(/^[a-zA-Z0-9-_/]*$/)
|
|
874
|
+
folderPath?: string;
|
|
875
|
+
|
|
876
|
+
@IsOptional()
|
|
877
|
+
@IsInt()
|
|
878
|
+
@Min(100)
|
|
879
|
+
@Max(10000)
|
|
880
|
+
maxWidth?: number = 1280;
|
|
881
|
+
|
|
882
|
+
@IsOptional()
|
|
883
|
+
@IsInt()
|
|
884
|
+
@Min(100)
|
|
885
|
+
@Max(10000)
|
|
886
|
+
maxHeight?: number = 1280;
|
|
887
|
+
|
|
888
|
+
@IsOptional()
|
|
889
|
+
@IsInt()
|
|
890
|
+
@Min(1)
|
|
891
|
+
@Max(100)
|
|
892
|
+
quality?: number = 85;
|
|
893
|
+
|
|
894
|
+
@IsOptional()
|
|
895
|
+
@IsEnum(ImageFormat)
|
|
896
|
+
format?: ImageFormat = ImageFormat.ORIGINAL;
|
|
897
|
+
|
|
898
|
+
@IsOptional()
|
|
899
|
+
@IsBoolean()
|
|
900
|
+
compress?: boolean = true;
|
|
688
901
|
}
|
|
689
902
|
```
|
|
690
903
|
|
|
691
|
-
|
|
904
|
+
---
|
|
692
905
|
|
|
693
|
-
|
|
694
|
-
import { FileManagerService } from '@flusys/nestjs-storage';
|
|
906
|
+
## File Validation & Security
|
|
695
907
|
|
|
696
|
-
|
|
697
|
-
export class MyService {
|
|
698
|
-
constructor(private readonly fileManagerService: FileManagerService) {}
|
|
699
|
-
|
|
700
|
-
async manageFiles(user: ILoggedUserInfo) {
|
|
701
|
-
// Create file record
|
|
702
|
-
const file = await this.fileManagerService.insert(
|
|
703
|
-
{
|
|
704
|
-
name: 'document.pdf',
|
|
705
|
-
contentType: 'application/pdf',
|
|
706
|
-
size: 1024000,
|
|
707
|
-
key: 'documents/doc-123.pdf',
|
|
708
|
-
location: FileLocationEnum.AWS,
|
|
709
|
-
storageConfigId: 'config-id',
|
|
710
|
-
folderId: 'folder-id',
|
|
711
|
-
},
|
|
712
|
-
user,
|
|
713
|
-
);
|
|
908
|
+
### FileValidator Utility
|
|
714
909
|
|
|
715
|
-
|
|
716
|
-
const fileData = await this.fileManagerService.getById(file.id, user);
|
|
910
|
+
The `FileValidator` class provides security features to prevent file upload attacks:
|
|
717
911
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
pagination: { page: 1, limit: 10 },
|
|
723
|
-
},
|
|
724
|
-
user,
|
|
725
|
-
);
|
|
912
|
+
```typescript
|
|
913
|
+
export class FileValidator {
|
|
914
|
+
/** Detect file type from buffer using magic bytes */
|
|
915
|
+
static detectFileType(buffer: Buffer): string | null;
|
|
726
916
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
[{ id: 'file-1' }, { id: 'file-2' }],
|
|
730
|
-
'https',
|
|
731
|
-
'example.com',
|
|
732
|
-
user,
|
|
733
|
-
);
|
|
917
|
+
/** Check if MIME type is text-based (no magic bytes) */
|
|
918
|
+
static isTextBasedType(mimeType: string): boolean;
|
|
734
919
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
{
|
|
738
|
-
id: file.id,
|
|
739
|
-
type: 'permanent', // Also deletes from storage
|
|
740
|
-
},
|
|
741
|
-
user,
|
|
742
|
-
);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
```
|
|
920
|
+
/** Check if MIME type is dangerous (HTML, JS, SVG) */
|
|
921
|
+
static isDangerousTextType(mimeType: string): boolean;
|
|
746
922
|
|
|
747
|
-
|
|
923
|
+
/** Check if two MIME types are compatible */
|
|
924
|
+
static mimeTypesMatch(detected: string, declared: string): boolean;
|
|
748
925
|
|
|
749
|
-
|
|
926
|
+
/** Check if MIME type is in allowed list */
|
|
927
|
+
static isTypeAllowed(mimeType: string, allowedTypes: string[]): boolean;
|
|
750
928
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
user?: ILoggedUserInfo,
|
|
758
|
-
): Promise<FilesResponseDto[]> {
|
|
759
|
-
const files = await this.repository.findBy({ id: In(ids) });
|
|
760
|
-
const now = Date.now();
|
|
929
|
+
/** Validate file content matches declared MIME type */
|
|
930
|
+
static validateFileContent(
|
|
931
|
+
buffer: Buffer,
|
|
932
|
+
declaredMimeType: string,
|
|
933
|
+
allowedTypes?: string[]
|
|
934
|
+
): FileValidationResult;
|
|
761
935
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
!file.url ||
|
|
766
|
-
(file.location === FileLocationEnum.AWS && now >= file.expiresAt) ||
|
|
767
|
-
(file.location === FileLocationEnum.AZURE && now >= file.expiresAt);
|
|
768
|
-
|
|
769
|
-
if (needsNewUrl && file.storageConfigId) {
|
|
770
|
-
// Generate presigned URL (1 hour expiry)
|
|
771
|
-
file.url = await this.uploadService.makeFileUrl(
|
|
772
|
-
file.key,
|
|
773
|
-
file.storageConfigId,
|
|
774
|
-
3600, // seconds
|
|
775
|
-
user,
|
|
776
|
-
);
|
|
777
|
-
file.expiresAt = now + 3600 * 1000;
|
|
778
|
-
await this.repository.save(file);
|
|
779
|
-
}
|
|
936
|
+
/** Sanitize filename to prevent path traversal */
|
|
937
|
+
static sanitizeFilename(filename: string): string;
|
|
938
|
+
}
|
|
780
939
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
};
|
|
787
|
-
}));
|
|
940
|
+
interface FileValidationResult {
|
|
941
|
+
valid: boolean;
|
|
942
|
+
detectedType?: string;
|
|
943
|
+
declaredType?: string;
|
|
944
|
+
message?: string;
|
|
788
945
|
}
|
|
789
946
|
```
|
|
790
947
|
|
|
791
|
-
|
|
948
|
+
### Supported Magic Bytes
|
|
792
949
|
|
|
793
|
-
|
|
950
|
+
| Category | Formats |
|
|
951
|
+
|----------|---------|
|
|
952
|
+
| Images | JPEG, PNG, GIF, BMP, WebP, ICO |
|
|
953
|
+
| Documents | PDF, ZIP (includes DOCX, XLSX, PPTX) |
|
|
954
|
+
| Audio | MP3, OGG, FLAC |
|
|
955
|
+
| Video | MP4, WebM, AVI |
|
|
956
|
+
| Archives | GZIP, 7Z, RAR |
|
|
794
957
|
|
|
795
|
-
###
|
|
958
|
+
### Dangerous File Types
|
|
796
959
|
|
|
797
|
-
|
|
798
|
-
@Entity('folders')
|
|
799
|
-
export class Folder extends Identity {
|
|
800
|
-
@Column({ length: 100 })
|
|
801
|
-
name: string;
|
|
960
|
+
These types bypass magic-bytes validation but require **explicit allowlisting**:
|
|
802
961
|
|
|
803
|
-
|
|
804
|
-
|
|
962
|
+
- `text/html`
|
|
963
|
+
- `application/javascript`, `text/javascript`
|
|
964
|
+
- `image/svg+xml`
|
|
965
|
+
- `application/xhtml+xml`
|
|
805
966
|
|
|
806
|
-
|
|
807
|
-
parent: Folder;
|
|
967
|
+
### Safe Text-Based Types
|
|
808
968
|
|
|
809
|
-
|
|
810
|
-
children: Folder[];
|
|
969
|
+
These are allowed without magic bytes:
|
|
811
970
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
```
|
|
971
|
+
- `text/plain`, `text/csv`, `text/markdown`
|
|
972
|
+
- `application/json`, `application/xml`
|
|
973
|
+
- `application/typescript`, `text/css`
|
|
816
974
|
|
|
817
|
-
###
|
|
975
|
+
### Filename Sanitization
|
|
818
976
|
|
|
819
977
|
```typescript
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
// Create root folder
|
|
828
|
-
const documents = await this.folderService.insert(
|
|
829
|
-
{
|
|
830
|
-
name: 'Documents',
|
|
831
|
-
},
|
|
832
|
-
user,
|
|
833
|
-
);
|
|
834
|
-
|
|
835
|
-
// Create subfolder
|
|
836
|
-
const reports = await this.folderService.insert(
|
|
837
|
-
{
|
|
838
|
-
name: 'Reports',
|
|
839
|
-
parentId: documents.id,
|
|
840
|
-
},
|
|
841
|
-
user,
|
|
842
|
-
);
|
|
843
|
-
|
|
844
|
-
// Get folder tree
|
|
845
|
-
const tree = await this.folderService.getTree(user);
|
|
846
|
-
|
|
847
|
-
// Get folder with children
|
|
848
|
-
const folder = await this.folderService.getById(documents.id, user);
|
|
849
|
-
|
|
850
|
-
// Delete folder
|
|
851
|
-
await this.folderService.delete(
|
|
852
|
-
{
|
|
853
|
-
id: reports.id,
|
|
854
|
-
type: 'soft',
|
|
855
|
-
},
|
|
856
|
-
user,
|
|
857
|
-
);
|
|
858
|
-
}
|
|
978
|
+
static sanitizeFilename(filename: string): string {
|
|
979
|
+
return filename
|
|
980
|
+
.replace(/^.*[\\\/]/, '') // Remove path components
|
|
981
|
+
.replace(/\0/g, '') // Remove null bytes
|
|
982
|
+
.replace(/\.{2,}/g, '.') // Replace multiple dots
|
|
983
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_') // Remove special characters
|
|
984
|
+
.substring(0, 255); // Limit length
|
|
859
985
|
}
|
|
860
986
|
```
|
|
861
987
|
|
|
862
988
|
---
|
|
863
989
|
|
|
864
|
-
##
|
|
865
|
-
|
|
866
|
-
### UploadService
|
|
867
|
-
|
|
868
|
-
```typescript
|
|
869
|
-
import { UploadService } from '@flusys/nestjs-storage';
|
|
990
|
+
## Image Compression
|
|
870
991
|
|
|
871
|
-
|
|
872
|
-
export class MyService {
|
|
873
|
-
constructor(private readonly uploadService: UploadService) {}
|
|
874
|
-
|
|
875
|
-
async uploadFiles(files: Express.Multer.File[], user: ILoggedUserInfo) {
|
|
876
|
-
// Upload single file
|
|
877
|
-
const result = await this.uploadService.uploadSingleFile(
|
|
878
|
-
files[0],
|
|
879
|
-
{
|
|
880
|
-
folderPath: 'documents/reports',
|
|
881
|
-
storageConfigId: 'my-s3-config', // Optional - uses default if not provided
|
|
882
|
-
},
|
|
883
|
-
user,
|
|
884
|
-
);
|
|
885
|
-
// Returns: { name, key, size, contentType, url }
|
|
886
|
-
|
|
887
|
-
// Upload multiple files
|
|
888
|
-
const results = await this.uploadService.uploadMultipleFiles(
|
|
889
|
-
files,
|
|
890
|
-
{
|
|
891
|
-
folderPath: 'images',
|
|
892
|
-
storageConfigId: 'my-azure-config',
|
|
893
|
-
},
|
|
894
|
-
user,
|
|
895
|
-
);
|
|
992
|
+
### ImageCompressor Utility
|
|
896
993
|
|
|
897
|
-
|
|
898
|
-
await this.uploadService.deleteSingleFile('documents/reports/report.pdf', 'my-s3-config', user);
|
|
994
|
+
Uses Sharp for image processing:
|
|
899
995
|
|
|
900
|
-
|
|
901
|
-
|
|
996
|
+
```typescript
|
|
997
|
+
export class ImageCompressor {
|
|
998
|
+
static async compress(
|
|
999
|
+
buffer: Buffer,
|
|
1000
|
+
mimetype: string,
|
|
1001
|
+
options?: CompressionOptions
|
|
1002
|
+
): Promise<{ buffer: Buffer; format: string }>;
|
|
1003
|
+
}
|
|
902
1004
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
user,
|
|
909
|
-
);
|
|
910
|
-
}
|
|
1005
|
+
interface CompressionOptions {
|
|
1006
|
+
maxWidth?: number; // Default: 1280
|
|
1007
|
+
maxHeight?: number; // Default: 1280
|
|
1008
|
+
quality?: number; // Default: 85
|
|
1009
|
+
format?: ImageFormat; // Default: 'original'
|
|
911
1010
|
}
|
|
912
1011
|
```
|
|
913
1012
|
|
|
914
|
-
###
|
|
915
|
-
|
|
916
|
-
```typescript
|
|
917
|
-
// StorageConfigService
|
|
918
|
-
validateFileSize(size: number): { valid: boolean; message?: string } {
|
|
919
|
-
const maxSize = this.config.maxFileSize || 10 * 1024 * 1024; // 10MB default
|
|
920
|
-
if (size > maxSize) {
|
|
921
|
-
return {
|
|
922
|
-
valid: false,
|
|
923
|
-
message: `File size exceeds maximum allowed size of ${maxSize / 1024 / 1024}MB`,
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
return { valid: true };
|
|
927
|
-
}
|
|
1013
|
+
### Supported Output Formats
|
|
928
1014
|
|
|
929
|
-
|
|
930
|
-
|
|
1015
|
+
| Format | Quality Options |
|
|
1016
|
+
|--------|-----------------|
|
|
1017
|
+
| JPEG | MozJPEG optimization, 4:4:4 chroma |
|
|
1018
|
+
| PNG | Compression level 9, adaptive filtering, palette |
|
|
1019
|
+
| WebP | Smart subsample, effort 6, lossless at 100% |
|
|
1020
|
+
| AVIF | Effort 6, 4:4:4 chroma |
|
|
1021
|
+
| TIFF | LZW compression, pyramid |
|
|
1022
|
+
| GIF | 256 colors, effort 10, dithering |
|
|
1023
|
+
| JP2 | JPEG 2000, lossless at 100% |
|
|
931
1024
|
|
|
932
|
-
|
|
933
|
-
if (pattern === '*/*') return true;
|
|
934
|
-
if (pattern.endsWith('/*')) {
|
|
935
|
-
const type = pattern.replace('/*', '');
|
|
936
|
-
return mimeType.startsWith(type);
|
|
937
|
-
}
|
|
938
|
-
return mimeType === pattern;
|
|
939
|
-
});
|
|
1025
|
+
### Usage
|
|
940
1026
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1027
|
+
```typescript
|
|
1028
|
+
// Automatic compression during upload
|
|
1029
|
+
await uploadService.uploadSingleFile(file, {
|
|
1030
|
+
compress: true,
|
|
1031
|
+
maxWidth: 1920,
|
|
1032
|
+
maxHeight: 1080,
|
|
1033
|
+
quality: 80,
|
|
1034
|
+
format: 'webp',
|
|
1035
|
+
}, user);
|
|
949
1036
|
```
|
|
950
1037
|
|
|
951
1038
|
---
|
|
952
1039
|
|
|
953
1040
|
## REST API Endpoints
|
|
954
1041
|
|
|
1042
|
+
All storage endpoints are prefixed with `/storage`.
|
|
1043
|
+
|
|
955
1044
|
### Upload Endpoints
|
|
956
1045
|
|
|
957
|
-
| Endpoint
|
|
958
|
-
|
|
959
|
-
| `/upload/single-file`
|
|
960
|
-
| `/upload/multiple-file`
|
|
961
|
-
| `/upload/delete-single-file`
|
|
962
|
-
| `/upload/delete-multiple-file` | POST
|
|
963
|
-
| `/upload/file/*filePath`
|
|
1046
|
+
| Endpoint | Method | Description | Permission |
|
|
1047
|
+
|----------|--------|-------------|------------|
|
|
1048
|
+
| `/storage/upload/single-file` | POST | Upload single file | `file.upload` |
|
|
1049
|
+
| `/storage/upload/multiple-file` | POST | Upload multiple files (max 50) | `file.upload` |
|
|
1050
|
+
| `/storage/upload/delete-single-file` | POST | Delete single file | `file.delete` |
|
|
1051
|
+
| `/storage/upload/delete-multiple-file` | POST | Delete multiple files | `file.delete` |
|
|
1052
|
+
| `/storage/upload/file/*filePath` | GET | Serve file (local storage) | Public |
|
|
964
1053
|
|
|
965
1054
|
### File Manager Endpoints
|
|
966
1055
|
|
|
967
|
-
| Endpoint
|
|
968
|
-
|
|
969
|
-
| `/file-manager/insert`
|
|
970
|
-
| `/file-manager/get/:id`
|
|
971
|
-
| `/file-manager/get-all`
|
|
972
|
-
| `/file-manager/update`
|
|
973
|
-
| `/file-manager/delete`
|
|
974
|
-
| `/file-manager/get-files` | POST
|
|
1056
|
+
| Endpoint | Method | Permission |
|
|
1057
|
+
|----------|--------|------------|
|
|
1058
|
+
| `/storage/file-manager/insert` | POST | `file.create` |
|
|
1059
|
+
| `/storage/file-manager/get/:id` | POST | `file.read` |
|
|
1060
|
+
| `/storage/file-manager/get-all` | POST | `file.read` |
|
|
1061
|
+
| `/storage/file-manager/update` | POST | `file.update` |
|
|
1062
|
+
| `/storage/file-manager/delete` | POST | `file.delete` |
|
|
1063
|
+
| `/storage/file-manager/get-files` | POST | JWT Auth |
|
|
975
1064
|
|
|
976
1065
|
### Folder Endpoints
|
|
977
1066
|
|
|
978
|
-
| Endpoint
|
|
979
|
-
|
|
980
|
-
| `/folder/insert`
|
|
981
|
-
| `/folder/get/:id` | POST
|
|
982
|
-
| `/folder/get-all` | POST
|
|
983
|
-
| `/folder/
|
|
984
|
-
| `/folder/
|
|
985
|
-
| `/folder/delete` | POST | Delete folder |
|
|
1067
|
+
| Endpoint | Method | Permission |
|
|
1068
|
+
|----------|--------|------------|
|
|
1069
|
+
| `/storage/folder/insert` | POST | `folder.create` |
|
|
1070
|
+
| `/storage/folder/get/:id` | POST | `folder.read` |
|
|
1071
|
+
| `/storage/folder/get-all` | POST | `folder.read` |
|
|
1072
|
+
| `/storage/folder/update` | POST | `folder.update` |
|
|
1073
|
+
| `/storage/folder/delete` | POST | `folder.delete` |
|
|
986
1074
|
|
|
987
1075
|
### Storage Config Endpoints
|
|
988
1076
|
|
|
989
|
-
| Endpoint
|
|
990
|
-
|
|
991
|
-
| `/storage-config/insert`
|
|
992
|
-
| `/storage-config/get/:id` | POST
|
|
993
|
-
| `/storage-config/get-all` | POST
|
|
994
|
-
| `/storage-config/update`
|
|
995
|
-
| `/storage-config/delete`
|
|
1077
|
+
| Endpoint | Method | Permission |
|
|
1078
|
+
|----------|--------|------------|
|
|
1079
|
+
| `/storage/storage-config/insert` | POST | `storageConfig.create` |
|
|
1080
|
+
| `/storage/storage-config/get/:id` | POST | `storageConfig.read` |
|
|
1081
|
+
| `/storage/storage-config/get-all` | POST | `storageConfig.read` |
|
|
1082
|
+
| `/storage/storage-config/update` | POST | `storageConfig.update` |
|
|
1083
|
+
| `/storage/storage-config/delete` | POST | `storageConfig.delete` |
|
|
996
1084
|
|
|
997
1085
|
### Upload Request Example
|
|
998
1086
|
|
|
999
1087
|
```bash
|
|
1000
1088
|
# Upload single file
|
|
1001
|
-
curl -X POST http://localhost:3000/upload/single-file \
|
|
1089
|
+
curl -X POST http://localhost:3000/storage/upload/single-file \
|
|
1002
1090
|
-H "Authorization: Bearer <token>" \
|
|
1003
1091
|
-H "Content-Type: multipart/form-data" \
|
|
1004
1092
|
-F "file=@document.pdf" \
|
|
1005
|
-
-F "folderPath=documents"
|
|
1093
|
+
-F "folderPath=documents" \
|
|
1094
|
+
-F "compress=true"
|
|
1006
1095
|
|
|
1007
1096
|
# Response:
|
|
1008
1097
|
{
|
|
1009
|
-
"
|
|
1010
|
-
"
|
|
1011
|
-
"
|
|
1012
|
-
|
|
1098
|
+
"success": true,
|
|
1099
|
+
"message": "File uploaded successfully",
|
|
1100
|
+
"data": {
|
|
1101
|
+
"name": "abc123-document.pdf",
|
|
1102
|
+
"key": "uploads/documents/abc123-document.pdf",
|
|
1103
|
+
"size": 102.4,
|
|
1104
|
+
"contentType": "application/pdf",
|
|
1105
|
+
"location": "local",
|
|
1106
|
+
"storageConfigId": "123e4567-e89b-12d3-a456-426614174000"
|
|
1107
|
+
}
|
|
1013
1108
|
}
|
|
1014
1109
|
```
|
|
1015
1110
|
|
|
1016
1111
|
---
|
|
1017
1112
|
|
|
1018
|
-
##
|
|
1019
|
-
|
|
1020
|
-
### Overview
|
|
1113
|
+
## File Serve Middleware
|
|
1021
1114
|
|
|
1022
|
-
|
|
1115
|
+
The `FileServeMiddleware` handles file serving for local storage with multiple fallback strategies:
|
|
1023
1116
|
|
|
1024
|
-
### Architecture
|
|
1025
|
-
|
|
1026
|
-
```
|
|
1027
|
-
┌─────────────────────────────┐
|
|
1028
|
-
│ Configuration │
|
|
1029
|
-
│ enableCompanyFeature │
|
|
1030
|
-
└──────────┬──────────────────┘
|
|
1031
|
-
│
|
|
1032
|
-
▼
|
|
1033
|
-
┌──────────────────────────────┐
|
|
1034
|
-
│ StorageDataSourceProvider │
|
|
1035
|
-
│ (REQUEST-scoped) │
|
|
1036
|
-
│ • Extends MultiTenant... │
|
|
1037
|
-
│ • Dynamic entity loading │
|
|
1038
|
-
└──────────┬───────────────────┘
|
|
1039
|
-
│
|
|
1040
|
-
▼
|
|
1041
|
-
┌─────────────────────────────────┐
|
|
1042
|
-
│ Services (REQUEST-scoped) │
|
|
1043
|
-
│ • FileManagerService │
|
|
1044
|
-
│ • FolderService │
|
|
1045
|
-
│ • StorageProviderConfigService │
|
|
1046
|
-
└─────────────────────────────────┘
|
|
1047
|
-
```
|
|
1048
|
-
|
|
1049
|
-
### Key Changes
|
|
1050
|
-
|
|
1051
|
-
#### Before (Static Entity Injection)
|
|
1052
1117
|
```typescript
|
|
1053
1118
|
@Injectable()
|
|
1054
|
-
export class
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
//
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
```
|
|
1062
|
-
|
|
1063
|
-
#### After (Dynamic Entity Loading)
|
|
1064
|
-
```typescript
|
|
1065
|
-
@Injectable({ scope: Scope.REQUEST })
|
|
1066
|
-
export class FileManagerService {
|
|
1067
|
-
constructor(
|
|
1068
|
-
private readonly storageConfig: StorageConfigService,
|
|
1069
|
-
private readonly dataSourceProvider: StorageDataSourceProvider,
|
|
1070
|
-
) {
|
|
1071
|
-
super('file_manager', null as any, ...);
|
|
1119
|
+
export class FileServeMiddleware implements NestMiddleware {
|
|
1120
|
+
async use(req: Request, res: Response, next: NextFunction) {
|
|
1121
|
+
const normalizedPath = this.extractFilePath(req);
|
|
1122
|
+
const fullPath = await this.resolveFilePath(normalizedPath);
|
|
1123
|
+
// Stream file with proper headers
|
|
1072
1124
|
}
|
|
1073
1125
|
|
|
1074
|
-
async
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
this.repository = await this.dataSourceProvider.getRepository(entity);
|
|
1126
|
+
private async resolveFilePath(normalizedPath: string): Promise<string> {
|
|
1127
|
+
// Strategy 1: Path relative to CWD (new format)
|
|
1128
|
+
// Strategy 2: basePath + remaining path
|
|
1129
|
+
// Strategy 3: Old format - prepend basePath
|
|
1079
1130
|
}
|
|
1080
1131
|
}
|
|
1081
1132
|
```
|
|
1082
1133
|
|
|
1083
|
-
###
|
|
1134
|
+
### MIME Types for Inline Display
|
|
1084
1135
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1136
|
+
```typescript
|
|
1137
|
+
const VIEWABLE_TYPE_PREFIXES = [
|
|
1138
|
+
'image/',
|
|
1139
|
+
'video/',
|
|
1140
|
+
'audio/',
|
|
1141
|
+
'text/',
|
|
1142
|
+
'application/pdf',
|
|
1143
|
+
'application/json',
|
|
1144
|
+
'application/xml',
|
|
1145
|
+
];
|
|
1146
|
+
```
|
|
1090
1147
|
|
|
1091
|
-
###
|
|
1148
|
+
### Response Headers
|
|
1092
1149
|
|
|
1093
|
-
|
|
1150
|
+
```typescript
|
|
1151
|
+
res.set({
|
|
1152
|
+
'Content-Type': mimeType,
|
|
1153
|
+
'Content-Length': size,
|
|
1154
|
+
'Content-Disposition': isViewable ? 'inline' : 'attachment',
|
|
1155
|
+
'Cache-Control': 'public, max-age=3600',
|
|
1156
|
+
'Accept-Ranges': 'bytes',
|
|
1157
|
+
'Cross-Origin-Resource-Policy': 'cross-origin',
|
|
1158
|
+
'Access-Control-Allow-Origin': '*',
|
|
1159
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1160
|
+
});
|
|
1161
|
+
```
|
|
1094
1162
|
|
|
1095
|
-
|
|
1096
|
-
- **FolderService** - Uses `Folder` or `FolderWithCompany`
|
|
1097
|
-
- **StorageProviderConfigService** - Uses `StorageConfig` or `StorageConfigWithCompany`
|
|
1163
|
+
---
|
|
1098
1164
|
|
|
1099
|
-
|
|
1165
|
+
## DataSource Provider Pattern
|
|
1100
1166
|
|
|
1101
|
-
|
|
1167
|
+
### StorageDataSourceProvider
|
|
1102
1168
|
|
|
1103
1169
|
```typescript
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
}));
|
|
1110
|
-
}
|
|
1170
|
+
@Injectable({ scope: Scope.REQUEST })
|
|
1171
|
+
export class StorageDataSourceProvider extends MultiTenantDataSourceService {
|
|
1172
|
+
// Storage-specific static cache (isolated from Auth/IAM)
|
|
1173
|
+
protected static override readonly tenantConnections = new Map<string, DataSource>();
|
|
1174
|
+
protected static override singleDataSource: DataSource | null = null;
|
|
1111
1175
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
StorageProviderConfigService,
|
|
1119
|
-
...
|
|
1120
|
-
]
|
|
1176
|
+
/** Get storage entities based on company feature flag */
|
|
1177
|
+
async getStorageEntities(enableCompanyFeature?: boolean): Promise<any[]>;
|
|
1178
|
+
|
|
1179
|
+
/** Get company feature for current tenant */
|
|
1180
|
+
getEnableCompanyFeatureForCurrentTenant(): boolean;
|
|
1181
|
+
}
|
|
1121
1182
|
```
|
|
1122
1183
|
|
|
1123
|
-
###
|
|
1184
|
+
### Dynamic Entity Selection
|
|
1124
1185
|
|
|
1125
|
-
|
|
1186
|
+
Services select entities at runtime:
|
|
1126
1187
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1188
|
+
```typescript
|
|
1189
|
+
@Injectable({ scope: Scope.REQUEST })
|
|
1190
|
+
export class FileManagerService extends RequestScopedApiService<...> {
|
|
1191
|
+
protected resolveEntity(): EntityTarget<FileManagerBase> {
|
|
1192
|
+
return this.storageConfig.isCompanyFeatureEnabled()
|
|
1193
|
+
? FileManagerWithCompany
|
|
1194
|
+
: FileManager;
|
|
1195
|
+
}
|
|
1132
1196
|
|
|
1133
|
-
|
|
1197
|
+
protected getDataSourceProvider() {
|
|
1198
|
+
return this.dataSourceProvider;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
```
|
|
1134
1202
|
|
|
1135
1203
|
---
|
|
1136
1204
|
|
|
1137
1205
|
## Multi-Tenant Support
|
|
1138
1206
|
|
|
1139
|
-
### Company
|
|
1207
|
+
### Company Filtering
|
|
1140
1208
|
|
|
1141
1209
|
When company feature is enabled:
|
|
1142
1210
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1211
|
+
```typescript
|
|
1212
|
+
protected override async getExtraManipulateQuery(query, filterDto, user) {
|
|
1213
|
+
const result = await super.getExtraManipulateQuery(query, filterDto, user);
|
|
1214
|
+
|
|
1215
|
+
applyCompanyFilter(query, {
|
|
1216
|
+
isCompanyFeatureEnabled: this.storageConfig.isCompanyFeatureEnabled(),
|
|
1217
|
+
entityAlias: 'file_manager',
|
|
1218
|
+
}, user);
|
|
1147
1219
|
|
|
1148
|
-
|
|
1220
|
+
await this.applyPrivateFileFilter(query, user);
|
|
1221
|
+
|
|
1222
|
+
return result;
|
|
1223
|
+
}
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
### Private File Access
|
|
1149
1227
|
|
|
1150
1228
|
```typescript
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
user: ILoggedUserInfo,
|
|
1156
|
-
) {
|
|
1157
|
-
// Filter by company
|
|
1158
|
-
if (this.storageConfig.isCompanyFeatureEnabled() && user.companyId) {
|
|
1159
|
-
query.andWhere(`file_manager.companyId = :companyId`, {
|
|
1160
|
-
companyId: user.companyId,
|
|
1161
|
-
});
|
|
1229
|
+
private async applyPrivateFileFilter(query, user): Promise<void> {
|
|
1230
|
+
if (!user) {
|
|
1231
|
+
query.andWhere('file_manager.isPrivate = :isPrivate', { isPrivate: false });
|
|
1232
|
+
return;
|
|
1162
1233
|
}
|
|
1163
1234
|
|
|
1164
|
-
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
);
|
|
1235
|
+
const cacheKey = `user_action_permission_${user.id}_${user.companyId}`;
|
|
1236
|
+
const actions = await this.cacheManager.get(cacheKey);
|
|
1237
|
+
const canViewPrivate = actions?.some((a) => a.url === 'storage.file.viewPrivate');
|
|
1168
1238
|
|
|
1169
|
-
if (!
|
|
1239
|
+
if (!canViewPrivate) {
|
|
1170
1240
|
query.andWhere('file_manager.isPrivate = :isPrivate', { isPrivate: false });
|
|
1171
1241
|
}
|
|
1172
|
-
|
|
1173
|
-
return { query, isRaw: false };
|
|
1174
1242
|
}
|
|
1175
1243
|
```
|
|
1176
1244
|
|
|
1177
|
-
###
|
|
1245
|
+
### Storage Config Validation
|
|
1178
1246
|
|
|
1179
1247
|
```typescript
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1248
|
+
private async getStorageProviderWithConfig(storageConfigId?: string, user?: ILoggedUserInfo) {
|
|
1249
|
+
let storageConfig: StorageConfigBase;
|
|
1250
|
+
|
|
1251
|
+
if (storageConfigId) {
|
|
1252
|
+
const config = await this.storageProviderConfigService.findByIdDirect(storageConfigId);
|
|
1253
|
+
if (!config) throw new NotFoundException('Storage configuration not found');
|
|
1254
|
+
|
|
1255
|
+
// Validate company ownership
|
|
1256
|
+
validateCompanyOwnership(
|
|
1257
|
+
config,
|
|
1258
|
+
user,
|
|
1259
|
+
this.storageConfigService.isCompanyFeatureEnabled(),
|
|
1260
|
+
'Storage configuration',
|
|
1261
|
+
);
|
|
1183
1262
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1263
|
+
storageConfig = config;
|
|
1264
|
+
} else {
|
|
1265
|
+
const defaultConfig = await this.storageProviderConfigService.getDefaultConfig(user);
|
|
1266
|
+
if (!defaultConfig) {
|
|
1267
|
+
throw new NotFoundException('No default storage configuration found');
|
|
1268
|
+
}
|
|
1269
|
+
storageConfig = defaultConfig;
|
|
1187
1270
|
}
|
|
1188
1271
|
|
|
1189
|
-
return
|
|
1190
|
-
|
|
1191
|
-
|
|
1272
|
+
return {
|
|
1273
|
+
provider: await this.createProviderFromConfig(storageConfig),
|
|
1274
|
+
location: storageConfig.storage,
|
|
1275
|
+
configId: storageConfig.id,
|
|
1276
|
+
};
|
|
1192
1277
|
}
|
|
1193
1278
|
```
|
|
1194
1279
|
|
|
1195
|
-
|
|
1280
|
+
---
|
|
1281
|
+
|
|
1282
|
+
## Swagger Configuration
|
|
1283
|
+
|
|
1284
|
+
### Using storageSwaggerConfig
|
|
1196
1285
|
|
|
1197
1286
|
```typescript
|
|
1198
|
-
|
|
1199
|
-
private async getStorageProvider(
|
|
1200
|
-
storageConfigId?: string,
|
|
1201
|
-
user?: ILoggedUserInfo,
|
|
1202
|
-
): Promise<IStorageProvider> {
|
|
1203
|
-
const config = await this.storageProviderConfigService.findById(storageConfigId, user);
|
|
1204
|
-
|
|
1205
|
-
// Validate company ownership
|
|
1206
|
-
if (this.storageConfigService.isCompanyFeatureEnabled() && user?.companyId) {
|
|
1207
|
-
if (config.companyId && config.companyId !== user.companyId) {
|
|
1208
|
-
throw new BadRequestException('Storage configuration belongs to another company');
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1287
|
+
import { storageSwaggerConfig } from '@flusys/nestjs-storage/docs';
|
|
1211
1288
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
}
|
|
1289
|
+
// In your main.ts or app module
|
|
1290
|
+
const swaggerOptions = storageSwaggerConfig({
|
|
1291
|
+
enableCompanyFeature: true,
|
|
1292
|
+
databaseMode: 'single',
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// Returns:
|
|
1296
|
+
// {
|
|
1297
|
+
// title: 'Storage API',
|
|
1298
|
+
// description: '... dynamic description based on config ...',
|
|
1299
|
+
// version: '1.0',
|
|
1300
|
+
// path: 'api/docs/storage',
|
|
1301
|
+
// bearerAuth: true,
|
|
1302
|
+
// excludeSchemaProperties: enableCompanyFeature ? undefined : COMPANY_SCHEMA_EXCLUSIONS,
|
|
1303
|
+
// }
|
|
1217
1304
|
```
|
|
1218
1305
|
|
|
1219
1306
|
---
|
|
@@ -1226,20 +1313,15 @@ private async getStorageProvider(
|
|
|
1226
1313
|
// ✅ Create meaningful config names
|
|
1227
1314
|
await storageConfigService.insert({
|
|
1228
1315
|
name: 'default', // Primary storage
|
|
1229
|
-
storage:
|
|
1230
|
-
config: { bucket: 'main-files', ... }
|
|
1316
|
+
storage: 'aws',
|
|
1317
|
+
config: { bucket: 'main-files', ... },
|
|
1318
|
+
isDefault: true,
|
|
1231
1319
|
}, user);
|
|
1232
1320
|
|
|
1233
1321
|
await storageConfigService.insert({
|
|
1234
1322
|
name: 'media', // Images/videos
|
|
1235
|
-
storage:
|
|
1236
|
-
config: { bucket: 'media-files', ... }
|
|
1237
|
-
}, user);
|
|
1238
|
-
|
|
1239
|
-
await storageConfigService.insert({
|
|
1240
|
-
name: 'backups', // Backup storage
|
|
1241
|
-
storage: FileLocationEnum.SFTP,
|
|
1242
|
-
config: { host: 'backup.server.com', ... }
|
|
1323
|
+
storage: 'aws',
|
|
1324
|
+
config: { bucket: 'media-files', ... },
|
|
1243
1325
|
}, user);
|
|
1244
1326
|
|
|
1245
1327
|
// ❌ Don't use generic names
|
|
@@ -1249,76 +1331,34 @@ await storageConfigService.insert({
|
|
|
1249
1331
|
});
|
|
1250
1332
|
```
|
|
1251
1333
|
|
|
1252
|
-
### 2.
|
|
1253
|
-
|
|
1254
|
-
```typescript
|
|
1255
|
-
// ✅ Create logical folder structure
|
|
1256
|
-
const docs = await folderService.insert({ name: 'Documents' }, user);
|
|
1257
|
-
const reports = await folderService.insert({ name: 'Reports', parentId: docs.id }, user);
|
|
1258
|
-
const invoices = await folderService.insert({ name: 'Invoices', parentId: docs.id }, user);
|
|
1259
|
-
|
|
1260
|
-
// Upload with folder association
|
|
1261
|
-
await uploadService.uploadSingleFile(
|
|
1262
|
-
file,
|
|
1263
|
-
{
|
|
1264
|
-
folderPath: 'Documents/Reports',
|
|
1265
|
-
// Files will be stored in organized structure
|
|
1266
|
-
},
|
|
1267
|
-
user,
|
|
1268
|
-
);
|
|
1269
|
-
|
|
1270
|
-
// ❌ Don't dump all files in root
|
|
1271
|
-
```
|
|
1272
|
-
|
|
1273
|
-
### 3. Set Appropriate File Validation
|
|
1334
|
+
### 2. Configure File Validation
|
|
1274
1335
|
|
|
1275
1336
|
```typescript
|
|
1276
|
-
// ✅
|
|
1337
|
+
// ✅ Restrict file types in production
|
|
1277
1338
|
StorageModule.forRoot({
|
|
1278
1339
|
config: {
|
|
1279
1340
|
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
1280
1341
|
allowedFileTypes: [
|
|
1281
1342
|
'image/jpeg',
|
|
1282
1343
|
'image/png',
|
|
1283
|
-
'image/
|
|
1344
|
+
'image/webp',
|
|
1284
1345
|
'application/pdf',
|
|
1285
|
-
'text/plain',
|
|
1286
|
-
'text/csv',
|
|
1287
1346
|
],
|
|
1288
1347
|
},
|
|
1289
1348
|
});
|
|
1290
1349
|
|
|
1291
|
-
// ❌ Don't allow all file types
|
|
1350
|
+
// ❌ Don't allow all file types
|
|
1292
1351
|
allowedFileTypes: ['*/*'];
|
|
1293
1352
|
```
|
|
1294
1353
|
|
|
1295
|
-
###
|
|
1296
|
-
|
|
1297
|
-
```typescript
|
|
1298
|
-
// ✅ Use decorator for user context
|
|
1299
|
-
@Post('upload')
|
|
1300
|
-
async upload(
|
|
1301
|
-
@UploadedFile() file: Express.Multer.File,
|
|
1302
|
-
@CurrentUser() user: ILoggedUserInfo,
|
|
1303
|
-
) {
|
|
1304
|
-
return this.uploadService.uploadSingleFile(file, {}, user);
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// ❌ Don't extract user manually
|
|
1308
|
-
@Post('upload')
|
|
1309
|
-
async upload(@Req() req: Request) {
|
|
1310
|
-
const user = req.user; // Not type-safe
|
|
1311
|
-
}
|
|
1312
|
-
```
|
|
1313
|
-
|
|
1314
|
-
### 5. Handle Presigned URL Expiration
|
|
1354
|
+
### 3. Use Presigned URLs Properly
|
|
1315
1355
|
|
|
1316
1356
|
```typescript
|
|
1317
1357
|
// ✅ Always use getFiles() for URL access
|
|
1318
1358
|
const files = await fileManagerService.getFiles(
|
|
1319
1359
|
[{ id: 'file-1' }, { id: 'file-2' }],
|
|
1320
|
-
protocol,
|
|
1321
|
-
host,
|
|
1360
|
+
req.protocol,
|
|
1361
|
+
req.get('host'),
|
|
1322
1362
|
user,
|
|
1323
1363
|
);
|
|
1324
1364
|
// URLs are automatically refreshed if expired
|
|
@@ -1328,26 +1368,33 @@ const file = await fileManagerService.getById(id, user);
|
|
|
1328
1368
|
return file.url; // May be expired!
|
|
1329
1369
|
```
|
|
1330
1370
|
|
|
1331
|
-
###
|
|
1371
|
+
### 4. Handle File Deletion Properly
|
|
1332
1372
|
|
|
1333
1373
|
```typescript
|
|
1334
1374
|
// ✅ Use permanent delete to remove from storage
|
|
1335
|
-
await fileManagerService.delete(
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
},
|
|
1340
|
-
user,
|
|
1341
|
-
);
|
|
1375
|
+
await fileManagerService.delete({
|
|
1376
|
+
id: fileId,
|
|
1377
|
+
type: 'permanent', // Deletes from storage provider
|
|
1378
|
+
}, user);
|
|
1342
1379
|
|
|
1343
|
-
// Soft delete keeps file in storage (
|
|
1344
|
-
await fileManagerService.delete(
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1380
|
+
// Soft delete keeps file in storage (for recovery)
|
|
1381
|
+
await fileManagerService.delete({
|
|
1382
|
+
id: fileId,
|
|
1383
|
+
type: 'soft', // Only marks as deleted in DB
|
|
1384
|
+
}, user);
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
### 5. Optimize Image Uploads
|
|
1388
|
+
|
|
1389
|
+
```typescript
|
|
1390
|
+
// ✅ Enable compression for images
|
|
1391
|
+
await uploadService.uploadSingleFile(file, {
|
|
1392
|
+
compress: true,
|
|
1393
|
+
maxWidth: 1920,
|
|
1394
|
+
maxHeight: 1080,
|
|
1395
|
+
quality: 80,
|
|
1396
|
+
format: 'webp', // Best compression/quality ratio
|
|
1397
|
+
}, user);
|
|
1351
1398
|
```
|
|
1352
1399
|
|
|
1353
1400
|
---
|
|
@@ -1366,6 +1413,7 @@ import {
|
|
|
1366
1413
|
FileManagerService,
|
|
1367
1414
|
FolderService,
|
|
1368
1415
|
StorageProviderConfigService,
|
|
1416
|
+
StorageConfigService,
|
|
1369
1417
|
StorageDataSourceProvider,
|
|
1370
1418
|
} from '@flusys/nestjs-storage/services';
|
|
1371
1419
|
|
|
@@ -1380,19 +1428,29 @@ import {
|
|
|
1380
1428
|
StorageConfig,
|
|
1381
1429
|
StorageConfigBase,
|
|
1382
1430
|
StorageConfigWithCompany,
|
|
1431
|
+
StorageCoreEntities,
|
|
1432
|
+
StorageCompanyEntities,
|
|
1433
|
+
getStorageEntitiesByConfig,
|
|
1383
1434
|
} from '@flusys/nestjs-storage/entities';
|
|
1384
1435
|
|
|
1385
1436
|
// DTOs
|
|
1386
1437
|
import {
|
|
1387
1438
|
UploadOptionsDto,
|
|
1439
|
+
DeleteSingleFileDto,
|
|
1440
|
+
DeleteMultipleFileDto,
|
|
1441
|
+
FileUploadResponsePayloadDto,
|
|
1388
1442
|
CreateFileManagerDto,
|
|
1389
1443
|
UpdateFileManagerDto,
|
|
1444
|
+
FileManagerResponseDto,
|
|
1390
1445
|
GetFilesRequestDto,
|
|
1391
1446
|
FilesResponseDto,
|
|
1392
1447
|
CreateFolderDto,
|
|
1393
1448
|
UpdateFolderDto,
|
|
1449
|
+
FolderResponseDto,
|
|
1394
1450
|
CreateStorageConfigDto,
|
|
1395
1451
|
UpdateStorageConfigDto,
|
|
1452
|
+
StorageConfigResponseDto,
|
|
1453
|
+
ImageFormat,
|
|
1396
1454
|
} from '@flusys/nestjs-storage/dtos';
|
|
1397
1455
|
|
|
1398
1456
|
// Interfaces
|
|
@@ -1406,6 +1464,7 @@ import {
|
|
|
1406
1464
|
IUploadedFileInfo,
|
|
1407
1465
|
StorageModuleOptions,
|
|
1408
1466
|
StorageModuleAsyncOptions,
|
|
1467
|
+
StorageOptionsFactory,
|
|
1409
1468
|
} from '@flusys/nestjs-storage/interfaces';
|
|
1410
1469
|
|
|
1411
1470
|
// Enums
|
|
@@ -1418,8 +1477,21 @@ import {
|
|
|
1418
1477
|
LocalProvider,
|
|
1419
1478
|
} from '@flusys/nestjs-storage/providers';
|
|
1420
1479
|
|
|
1421
|
-
//
|
|
1422
|
-
import {
|
|
1480
|
+
// Utils
|
|
1481
|
+
import { FileValidator, ImageCompressor } from '@flusys/nestjs-storage/utils';
|
|
1482
|
+
|
|
1483
|
+
// Docs
|
|
1484
|
+
import { storageSwaggerConfig } from '@flusys/nestjs-storage/docs';
|
|
1485
|
+
|
|
1486
|
+
// Middleware
|
|
1487
|
+
import { FileServeMiddleware } from '@flusys/nestjs-storage/middlewares';
|
|
1488
|
+
|
|
1489
|
+
// Constants
|
|
1490
|
+
import {
|
|
1491
|
+
STORAGE_MODULE_OPTIONS,
|
|
1492
|
+
DEFAULT_MAX_FILE_SIZE,
|
|
1493
|
+
DEFAULT_ALLOWED_FILE_TYPES,
|
|
1494
|
+
} from '@flusys/nestjs-storage/config';
|
|
1423
1495
|
```
|
|
1424
1496
|
|
|
1425
1497
|
---
|
|
@@ -1429,15 +1501,17 @@ import { StorageConfigService } from '@flusys/nestjs-storage/config';
|
|
|
1429
1501
|
The `@flusys/nestjs-storage` package provides:
|
|
1430
1502
|
|
|
1431
1503
|
- **Multiple Providers** - Local, AWS S3, Azure Blob, SFTP
|
|
1432
|
-
- **Connection Reuse** -
|
|
1433
|
-
- **
|
|
1434
|
-
- **
|
|
1435
|
-
- **
|
|
1504
|
+
- **Connection Reuse** - SHA256-based provider caching
|
|
1505
|
+
- **Security** - Magic bytes validation, path traversal prevention, filename sanitization
|
|
1506
|
+
- **Image Compression** - Sharp-based optimization with multiple formats
|
|
1507
|
+
- **File Validation** - Size, type, and content validation
|
|
1508
|
+
- **Folder Organization** - Simple folder-based file organization
|
|
1509
|
+
- **Presigned URLs** - Automatic URL refresh for cloud providers
|
|
1436
1510
|
- **Multi-Tenant** - Company/branch file isolation
|
|
1437
1511
|
- **Per-Company Storage** - Different providers per company
|
|
1438
|
-
- **REST API** - Complete CRUD endpoints
|
|
1439
|
-
- **
|
|
1512
|
+
- **REST API** - Complete CRUD endpoints with POST-only RPC
|
|
1513
|
+
- **Middleware** - File serving with multiple fallback strategies
|
|
1440
1514
|
|
|
1441
1515
|
---
|
|
1442
1516
|
|
|
1443
|
-
**Last Updated:** 2026-02-
|
|
1517
|
+
**Last Updated:** 2026-02-25
|