express-storage 2.0.2 → 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.
Files changed (177) hide show
  1. package/README.md +366 -34
  2. package/dist/cjs/config/index.d.ts +10 -0
  3. package/dist/cjs/config/index.d.ts.map +1 -0
  4. package/dist/cjs/config/index.js +19 -0
  5. package/dist/cjs/config/index.js.map +1 -0
  6. package/dist/cjs/drivers/azure.driver.d.ts +73 -0
  7. package/dist/cjs/drivers/azure.driver.d.ts.map +1 -0
  8. package/dist/cjs/drivers/azure.driver.js +390 -0
  9. package/dist/cjs/drivers/azure.driver.js.map +1 -0
  10. package/dist/cjs/drivers/base.driver.d.ts +136 -0
  11. package/dist/cjs/drivers/base.driver.d.ts.map +1 -0
  12. package/dist/cjs/drivers/base.driver.js +357 -0
  13. package/dist/cjs/drivers/base.driver.js.map +1 -0
  14. package/dist/{drivers → cjs/drivers}/gcs.driver.d.ts +20 -38
  15. package/dist/cjs/drivers/gcs.driver.d.ts.map +1 -0
  16. package/dist/cjs/drivers/gcs.driver.js +343 -0
  17. package/dist/cjs/drivers/gcs.driver.js.map +1 -0
  18. package/dist/cjs/drivers/index.d.ts +15 -0
  19. package/dist/cjs/drivers/index.d.ts.map +1 -0
  20. package/dist/cjs/drivers/index.js +26 -0
  21. package/dist/cjs/drivers/index.js.map +1 -0
  22. package/dist/cjs/drivers/local.driver.d.ts +86 -0
  23. package/dist/cjs/drivers/local.driver.d.ts.map +1 -0
  24. package/dist/cjs/drivers/local.driver.js +556 -0
  25. package/dist/cjs/drivers/local.driver.js.map +1 -0
  26. package/dist/{drivers → cjs/drivers}/s3.driver.d.ts +19 -39
  27. package/dist/cjs/drivers/s3.driver.d.ts.map +1 -0
  28. package/dist/cjs/drivers/s3.driver.js +400 -0
  29. package/dist/cjs/drivers/s3.driver.js.map +1 -0
  30. package/dist/cjs/factory/driver.factory.d.ts +43 -0
  31. package/dist/cjs/factory/driver.factory.d.ts.map +1 -0
  32. package/dist/cjs/factory/driver.factory.js +101 -0
  33. package/dist/cjs/factory/driver.factory.js.map +1 -0
  34. package/dist/cjs/index.d.ts +26 -0
  35. package/dist/cjs/index.d.ts.map +1 -0
  36. package/dist/cjs/index.js +31 -0
  37. package/dist/cjs/index.js.map +1 -0
  38. package/dist/cjs/package.json +1 -0
  39. package/dist/cjs/storage-manager.d.ts +210 -0
  40. package/dist/cjs/storage-manager.d.ts.map +1 -0
  41. package/dist/cjs/storage-manager.js +649 -0
  42. package/dist/cjs/storage-manager.js.map +1 -0
  43. package/dist/cjs/types/storage.types.d.ts +438 -0
  44. package/dist/cjs/types/storage.types.d.ts.map +1 -0
  45. package/dist/cjs/types/storage.types.js +3 -0
  46. package/dist/cjs/types/storage.types.js.map +1 -0
  47. package/dist/cjs/utils/config.utils.d.ts.map +1 -0
  48. package/dist/cjs/utils/config.utils.js +213 -0
  49. package/dist/cjs/utils/config.utils.js.map +1 -0
  50. package/dist/{utils → cjs/utils}/file.utils.d.ts +62 -8
  51. package/dist/cjs/utils/file.utils.d.ts.map +1 -0
  52. package/dist/cjs/utils/file.utils.js +464 -0
  53. package/dist/cjs/utils/file.utils.js.map +1 -0
  54. package/dist/cjs/utils/index.d.ts +12 -0
  55. package/dist/cjs/utils/index.d.ts.map +1 -0
  56. package/dist/cjs/utils/index.js +36 -0
  57. package/dist/cjs/utils/index.js.map +1 -0
  58. package/dist/cjs/utils/rate-limiter.d.ts +40 -0
  59. package/dist/cjs/utils/rate-limiter.d.ts.map +1 -0
  60. package/dist/cjs/utils/rate-limiter.js +87 -0
  61. package/dist/cjs/utils/rate-limiter.js.map +1 -0
  62. package/dist/esm/config/index.d.ts +10 -0
  63. package/dist/esm/config/index.d.ts.map +1 -0
  64. package/dist/esm/config/index.js +10 -0
  65. package/dist/esm/config/index.js.map +1 -0
  66. package/dist/esm/drivers/azure.driver.d.ts +73 -0
  67. package/dist/esm/drivers/azure.driver.d.ts.map +1 -0
  68. package/dist/esm/drivers/azure.driver.js +353 -0
  69. package/dist/esm/drivers/azure.driver.js.map +1 -0
  70. package/dist/esm/drivers/base.driver.d.ts +136 -0
  71. package/dist/esm/drivers/base.driver.d.ts.map +1 -0
  72. package/dist/esm/drivers/base.driver.js +350 -0
  73. package/dist/esm/drivers/base.driver.js.map +1 -0
  74. package/dist/esm/drivers/gcs.driver.d.ts +68 -0
  75. package/dist/esm/drivers/gcs.driver.d.ts.map +1 -0
  76. package/dist/esm/drivers/gcs.driver.js +306 -0
  77. package/dist/esm/drivers/gcs.driver.js.map +1 -0
  78. package/dist/esm/drivers/index.d.ts +15 -0
  79. package/dist/esm/drivers/index.d.ts.map +1 -0
  80. package/dist/esm/drivers/index.js +15 -0
  81. package/dist/esm/drivers/index.js.map +1 -0
  82. package/dist/esm/drivers/local.driver.d.ts +86 -0
  83. package/dist/esm/drivers/local.driver.d.ts.map +1 -0
  84. package/dist/esm/drivers/local.driver.js +549 -0
  85. package/dist/esm/drivers/local.driver.js.map +1 -0
  86. package/dist/esm/drivers/s3.driver.d.ts +69 -0
  87. package/dist/esm/drivers/s3.driver.d.ts.map +1 -0
  88. package/dist/esm/drivers/s3.driver.js +363 -0
  89. package/dist/esm/drivers/s3.driver.js.map +1 -0
  90. package/dist/esm/factory/driver.factory.d.ts +43 -0
  91. package/dist/esm/factory/driver.factory.d.ts.map +1 -0
  92. package/dist/esm/factory/driver.factory.js +92 -0
  93. package/dist/esm/factory/driver.factory.js.map +1 -0
  94. package/dist/esm/index.d.ts +26 -0
  95. package/dist/esm/index.d.ts.map +1 -0
  96. package/dist/esm/index.js +26 -0
  97. package/dist/esm/index.js.map +1 -0
  98. package/dist/esm/package.json +1 -0
  99. package/dist/esm/storage-manager.d.ts +210 -0
  100. package/dist/esm/storage-manager.d.ts.map +1 -0
  101. package/dist/esm/storage-manager.js +645 -0
  102. package/dist/esm/storage-manager.js.map +1 -0
  103. package/dist/esm/types/storage.types.d.ts +438 -0
  104. package/dist/esm/types/storage.types.d.ts.map +1 -0
  105. package/dist/esm/types/storage.types.js.map +1 -0
  106. package/dist/esm/utils/config.utils.d.ts +45 -0
  107. package/dist/esm/utils/config.utils.d.ts.map +1 -0
  108. package/dist/esm/utils/config.utils.js.map +1 -0
  109. package/dist/esm/utils/file.utils.d.ts +196 -0
  110. package/dist/esm/utils/file.utils.d.ts.map +1 -0
  111. package/dist/esm/utils/file.utils.js +439 -0
  112. package/dist/esm/utils/file.utils.js.map +1 -0
  113. package/dist/esm/utils/index.d.ts +12 -0
  114. package/dist/esm/utils/index.d.ts.map +1 -0
  115. package/dist/esm/utils/index.js +11 -0
  116. package/dist/esm/utils/index.js.map +1 -0
  117. package/dist/esm/utils/rate-limiter.d.ts +40 -0
  118. package/dist/esm/utils/rate-limiter.d.ts.map +1 -0
  119. package/dist/esm/utils/rate-limiter.js +82 -0
  120. package/dist/esm/utils/rate-limiter.js.map +1 -0
  121. package/package.json +90 -52
  122. package/src/config/index.ts +17 -0
  123. package/src/drivers/azure.driver.ts +434 -0
  124. package/src/drivers/base.driver.ts +436 -0
  125. package/src/drivers/gcs.driver.ts +366 -0
  126. package/src/drivers/index.ts +15 -0
  127. package/src/drivers/local.driver.ts +626 -0
  128. package/src/drivers/s3.driver.ts +459 -0
  129. package/src/factory/driver.factory.ts +101 -0
  130. package/src/index.ts +72 -0
  131. package/src/storage-manager.ts +801 -0
  132. package/src/types/storage.types.ts +561 -0
  133. package/src/utils/config.utils.ts +229 -0
  134. package/src/utils/file.utils.ts +536 -0
  135. package/src/utils/index.ts +35 -0
  136. package/src/utils/rate-limiter.ts +94 -0
  137. package/dist/drivers/azure.driver.d.ts +0 -88
  138. package/dist/drivers/azure.driver.d.ts.map +0 -1
  139. package/dist/drivers/azure.driver.js +0 -391
  140. package/dist/drivers/azure.driver.js.map +0 -1
  141. package/dist/drivers/base.driver.d.ts +0 -170
  142. package/dist/drivers/base.driver.d.ts.map +0 -1
  143. package/dist/drivers/base.driver.js +0 -347
  144. package/dist/drivers/base.driver.js.map +0 -1
  145. package/dist/drivers/gcs.driver.d.ts.map +0 -1
  146. package/dist/drivers/gcs.driver.js +0 -354
  147. package/dist/drivers/gcs.driver.js.map +0 -1
  148. package/dist/drivers/local.driver.d.ts +0 -107
  149. package/dist/drivers/local.driver.d.ts.map +0 -1
  150. package/dist/drivers/local.driver.js +0 -621
  151. package/dist/drivers/local.driver.js.map +0 -1
  152. package/dist/drivers/s3.driver.d.ts.map +0 -1
  153. package/dist/drivers/s3.driver.js +0 -387
  154. package/dist/drivers/s3.driver.js.map +0 -1
  155. package/dist/factory/driver.factory.d.ts +0 -62
  156. package/dist/factory/driver.factory.d.ts.map +0 -1
  157. package/dist/factory/driver.factory.js +0 -177
  158. package/dist/factory/driver.factory.js.map +0 -1
  159. package/dist/index.d.ts +0 -30
  160. package/dist/index.d.ts.map +0 -1
  161. package/dist/index.js +0 -33
  162. package/dist/index.js.map +0 -1
  163. package/dist/storage-manager.d.ts +0 -228
  164. package/dist/storage-manager.d.ts.map +0 -1
  165. package/dist/storage-manager.js +0 -715
  166. package/dist/storage-manager.js.map +0 -1
  167. package/dist/types/storage.types.d.ts +0 -295
  168. package/dist/types/storage.types.d.ts.map +0 -1
  169. package/dist/types/storage.types.js.map +0 -1
  170. package/dist/utils/config.utils.d.ts.map +0 -1
  171. package/dist/utils/config.utils.js.map +0 -1
  172. package/dist/utils/file.utils.d.ts.map +0 -1
  173. package/dist/utils/file.utils.js +0 -278
  174. package/dist/utils/file.utils.js.map +0 -1
  175. /package/dist/{utils → cjs/utils}/config.utils.d.ts +0 -0
  176. /package/dist/{types → esm/types}/storage.types.js +0 -0
  177. /package/dist/{utils → esm/utils}/config.utils.js +0 -0
@@ -0,0 +1,801 @@
1
+ import {
2
+ IStorageDriver,
3
+ FileUploadResult,
4
+ DeleteResult,
5
+ PresignedUrlError,
6
+ PresignedUploadUrlResult,
7
+ PresignedUploadUrlSuccess,
8
+ PresignedViewUrlResult,
9
+ PresignedViewUrlSuccess,
10
+ StorageConfig,
11
+ PublicStorageConfig,
12
+ StorageOptions,
13
+ FileValidationOptions,
14
+ StorageDriver,
15
+ BlobValidationOptions,
16
+ BlobValidationResult,
17
+ ListFilesResult,
18
+ UploadOptions,
19
+ FileMetadata,
20
+ FileInfo,
21
+ BatchOptions,
22
+ Logger,
23
+ RateLimiterAdapter,
24
+ StorageHooks,
25
+ HookErrorContext
26
+ } from './types/storage.types.js';
27
+ import { createDriver, getAvailableDrivers } from './factory/driver.factory.js';
28
+ import { validateStorageConfig, loadEnvironmentConfig, environmentToStorageConfig } from './utils/config.utils.js';
29
+ import { generateUniqueFileName, validateFileName, hasPathTraversal, isValidMimeType, validateFolderPath, validateFileForUpload, withConcurrencyLimit, formatFileSize } from './utils/file.utils.js';
30
+ import { InMemoryRateLimiter, isRateLimiterAdapter } from './utils/rate-limiter.js';
31
+
32
+ /** 5 GB — default maximum file size */
33
+ const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
34
+
35
+ const noopLogger: Logger = {
36
+ debug: () => {},
37
+ info: () => {},
38
+ warn: () => {},
39
+ error: () => {},
40
+ };
41
+
42
+ /**
43
+ * StorageManager - Your single point of contact for all file operations.
44
+ *
45
+ * Think of it as a universal remote that works with any storage provider.
46
+ * You don't need to know the specifics of S3, GCS, Azure, or local storage —
47
+ * just tell StorageManager what you want to do and it handles the rest.
48
+ *
49
+ * @example
50
+ * // The simplest setup - just reads from your .env file
51
+ * const storage = new StorageManager();
52
+ *
53
+ * // Full-featured setup
54
+ * const storage = new StorageManager({
55
+ * driver: 's3',
56
+ * credentials: { bucketName: 'my-bucket', awsRegion: 'us-east-1' },
57
+ * hooks: {
58
+ * beforeUpload: async (file) => { await virusScan(file.buffer); },
59
+ * afterUpload: (result) => { auditLog('file_uploaded', result); },
60
+ * },
61
+ * rateLimiter: { maxRequests: 100, windowMs: 60000 },
62
+ * concurrency: 5,
63
+ * });
64
+ */
65
+ export class StorageManager {
66
+ private driver: IStorageDriver;
67
+ private readonly config: StorageConfig;
68
+ private readonly logger: Logger;
69
+ private rateLimiter: RateLimiterAdapter | null = null;
70
+ private hooks: StorageHooks;
71
+ private readonly concurrency: number;
72
+ private destroyed = false;
73
+
74
+ constructor(options?: StorageOptions) {
75
+ this.logger = options?.logger || noopLogger;
76
+ this.config = this.buildConfig(options);
77
+ this.hooks = options?.hooks || {};
78
+ this.concurrency = options?.concurrency ?? 10;
79
+
80
+ // Initialize rate limiter — accepts either plain options or a custom adapter
81
+ if (options?.rateLimiter) {
82
+ if (isRateLimiterAdapter(options.rateLimiter)) {
83
+ this.rateLimiter = options.rateLimiter;
84
+ this.logger.debug('Custom rate limiter adapter configured');
85
+ } else {
86
+ this.rateLimiter = new InMemoryRateLimiter(options.rateLimiter);
87
+ this.logger.debug('In-memory rate limiting enabled', {
88
+ maxRequests: options.rateLimiter.maxRequests,
89
+ windowMs: options.rateLimiter.windowMs || 60000
90
+ });
91
+ }
92
+ }
93
+
94
+ this.logger.debug('StorageManager initializing', { driver: this.config.driver });
95
+
96
+ const validation = validateStorageConfig(this.config);
97
+ if (!validation.isValid) {
98
+ this.logger.error('Configuration validation failed', { errors: validation.errors });
99
+ throw new Error(`Configuration validation failed: ${validation.errors.join(', ')}`);
100
+ }
101
+
102
+ this.driver = createDriver(this.config);
103
+ this.logger.info('StorageManager initialized', { driver: this.config.driver });
104
+ }
105
+
106
+ /**
107
+ * Builds the final configuration by merging environment variables with any
108
+ * options you passed in. Your explicit options always win over env vars.
109
+ */
110
+ private buildConfig(options?: StorageOptions): StorageConfig {
111
+ const envConfig = loadEnvironmentConfig();
112
+ const baseConfig = environmentToStorageConfig(envConfig);
113
+
114
+ if (!options) {
115
+ return {
116
+ ...baseConfig,
117
+ driver: baseConfig.driver ?? 'local',
118
+ maxFileSize: baseConfig.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
119
+ };
120
+ }
121
+
122
+ const creds = options.credentials || {};
123
+
124
+ return {
125
+ driver: options.driver ?? baseConfig.driver ?? 'local',
126
+ bucketName: creds.bucketName ?? baseConfig.bucketName,
127
+ bucketPath: creds.bucketPath ?? baseConfig.bucketPath ?? '',
128
+ localPath: creds.localPath ?? baseConfig.localPath ?? 'public/express-storage',
129
+ presignedUrlExpiry: creds.presignedUrlExpiry ?? baseConfig.presignedUrlExpiry ?? 600,
130
+ maxFileSize: creds.maxFileSize ?? baseConfig.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
131
+
132
+ awsRegion: creds.awsRegion ?? baseConfig.awsRegion,
133
+ awsAccessKey: creds.awsAccessKey ?? baseConfig.awsAccessKey,
134
+ awsSecretKey: creds.awsSecretKey ?? baseConfig.awsSecretKey,
135
+
136
+ gcsProjectId: creds.gcsProjectId ?? baseConfig.gcsProjectId,
137
+ gcsCredentials: creds.gcsCredentials ?? baseConfig.gcsCredentials,
138
+
139
+ azureConnectionString: creds.azureConnectionString ?? baseConfig.azureConnectionString,
140
+ azureAccountName: creds.azureAccountName ?? baseConfig.azureAccountName,
141
+ azureAccountKey: creds.azureAccountKey ?? baseConfig.azureAccountKey,
142
+ azureContainerName: creds.azureContainerName ?? baseConfig.azureContainerName,
143
+ };
144
+ }
145
+
146
+ private assertNotDestroyed(): void {
147
+ if (this.destroyed) {
148
+ throw new Error('StorageManager has been destroyed and cannot be reused. Create a new instance.');
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Upload methods
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Uploads a single file to your configured storage.
158
+ *
159
+ * @param file - The file from Multer (req.file)
160
+ * @param validation - Optional rules like max size and allowed types
161
+ * @param uploadOptions - Optional metadata, cache headers, etc.
162
+ *
163
+ * @example
164
+ * const result = await storage.uploadFile(req.file, {
165
+ * maxSize: 5 * 1024 * 1024,
166
+ * allowedMimeTypes: ['image/jpeg', 'image/png'],
167
+ * });
168
+ * if (result.success) {
169
+ * console.log(result.reference, result.fileUrl);
170
+ * }
171
+ */
172
+ async uploadFile(
173
+ file: Express.Multer.File,
174
+ validation?: FileValidationOptions,
175
+ uploadOptions?: UploadOptions
176
+ ): Promise<FileUploadResult> {
177
+ this.assertNotDestroyed();
178
+ if (!file) {
179
+ this.logger.warn('uploadFile called with null/undefined file');
180
+ return { success: false, error: 'No file provided', code: 'NO_FILE' };
181
+ }
182
+
183
+ this.logger.debug('uploadFile called', {
184
+ originalName: file.originalname,
185
+ size: file.size,
186
+ mimeType: file.mimetype
187
+ });
188
+
189
+ return this.executeSingleUpload(file, validation, uploadOptions, 'upload');
190
+ }
191
+
192
+ /**
193
+ * Uploads multiple files at once.
194
+ * Files are processed in parallel (up to concurrency limit) for speed,
195
+ * but each file gets its own result — one failure doesn't stop the others.
196
+ *
197
+ * @example
198
+ * const results = await storage.uploadFiles(req.files, {
199
+ * maxSize: 10 * 1024 * 1024,
200
+ * });
201
+ * const uploaded = results.filter(r => r.success);
202
+ * const failed = results.filter(r => !r.success);
203
+ */
204
+ async uploadFiles(
205
+ files: Express.Multer.File[],
206
+ validation?: FileValidationOptions,
207
+ uploadOptions?: UploadOptions,
208
+ options?: BatchOptions
209
+ ): Promise<FileUploadResult[]> {
210
+ this.assertNotDestroyed();
211
+ if (!files || files.length === 0) {
212
+ return [];
213
+ }
214
+
215
+ return withConcurrencyLimit(
216
+ files,
217
+ (file) => this.executeSingleUpload(file, validation, uploadOptions, 'uploadMultiple'),
218
+ { maxConcurrent: this.concurrency, signal: options?.signal }
219
+ );
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Presigned URL methods
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Creates a presigned URL that lets clients upload directly to cloud storage.
228
+ *
229
+ * The URL is time-limited and (for S3/GCS) locked to specific file constraints.
230
+ *
231
+ * @param fileName - What the user wants to call their file
232
+ * @param contentType - The MIME type (e.g., 'image/jpeg')
233
+ * @param fileSize - Exact size in bytes (enforced by S3/GCS, advisory for Azure)
234
+ * @param folder - Where to put the file (overrides your default BUCKET_PATH)
235
+ *
236
+ * @example
237
+ * const result = await storage.generateUploadUrl('photo.jpg', 'image/jpeg', 204800);
238
+ * if (result.success) {
239
+ * // result.uploadUrl — PUT request goes here
240
+ * // result.reference — save this to confirm/view/delete later
241
+ * // result.expiresIn — seconds until URL expires
242
+ * }
243
+ */
244
+ async generateUploadUrl(
245
+ fileName: string,
246
+ contentType?: string,
247
+ fileSize?: number,
248
+ folder?: string
249
+ ): Promise<PresignedUploadUrlResult> {
250
+ this.assertNotDestroyed();
251
+ const rateLimitError = await this.checkRateLimit();
252
+ if (rateLimitError) return rateLimitError;
253
+
254
+ const fileNameError = validateFileName(fileName);
255
+ if (fileNameError) {
256
+ return { success: false, error: fileNameError, code: 'INVALID_FILENAME' };
257
+ }
258
+
259
+ if (fileSize !== undefined) {
260
+ if (typeof fileSize !== 'number' || Number.isNaN(fileSize) || fileSize < 0) {
261
+ return { success: false, error: 'fileSize must be a non-negative number', code: 'INVALID_INPUT' };
262
+ }
263
+
264
+ const maxAllowedSize = this.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
265
+ const effectiveMaxSize = maxAllowedSize > 0 ? maxAllowedSize : DEFAULT_MAX_FILE_SIZE;
266
+
267
+ if (fileSize > effectiveMaxSize) {
268
+ return {
269
+ success: false,
270
+ error: `fileSize cannot exceed ${effectiveMaxSize} bytes (${formatFileSize(effectiveMaxSize)})`,
271
+ code: 'FILE_TOO_LARGE',
272
+ };
273
+ }
274
+ }
275
+
276
+ if (contentType && !isValidMimeType(contentType)) {
277
+ return {
278
+ success: false,
279
+ error: `Invalid contentType format: '${contentType}'. Expected format: type/subtype (e.g., 'image/jpeg')`,
280
+ code: 'INVALID_INPUT',
281
+ };
282
+ }
283
+
284
+ const uniqueFileName = generateUniqueFileName(fileName);
285
+ const effectiveFolder = folder !== undefined ? folder : (this.config.bucketPath || '');
286
+
287
+ if (effectiveFolder) {
288
+ const folderValidationError = validateFolderPath(effectiveFolder);
289
+ if (folderValidationError) {
290
+ return { success: false, error: folderValidationError, code: 'PATH_TRAVERSAL' };
291
+ }
292
+ }
293
+
294
+ const reference = this.buildFilePath(uniqueFileName, effectiveFolder);
295
+ const result = await this.driver.generateUploadUrl(reference, contentType, fileSize);
296
+
297
+ if (result.success) {
298
+ const response: PresignedUploadUrlSuccess = {
299
+ success: true,
300
+ fileName: uniqueFileName,
301
+ reference,
302
+ uploadUrl: result.uploadUrl ?? '',
303
+ expiresIn: this.config.presignedUrlExpiry || 600,
304
+ };
305
+
306
+ if (effectiveFolder) {
307
+ response.filePath = effectiveFolder;
308
+ }
309
+ if (contentType) {
310
+ response.contentType = contentType;
311
+ }
312
+ if (fileSize !== undefined) {
313
+ response.fileSize = fileSize;
314
+ }
315
+ if (this.config.driver === 'azure-presigned') {
316
+ response.requiresValidation = true;
317
+ }
318
+ return response;
319
+ }
320
+
321
+ return result;
322
+ }
323
+
324
+ /**
325
+ * Creates a presigned URL for viewing/downloading an existing file.
326
+ *
327
+ * @param reference - The full path you got from generateUploadUrl
328
+ */
329
+ async generateViewUrl(reference: string): Promise<PresignedViewUrlResult> {
330
+ this.assertNotDestroyed();
331
+ const rateLimitError = await this.checkRateLimit();
332
+ if (rateLimitError) return rateLimitError;
333
+
334
+ if (hasPathTraversal(reference)) {
335
+ return {
336
+ success: false,
337
+ error: 'Invalid reference: path traversal sequences are not allowed',
338
+ code: 'PATH_TRAVERSAL',
339
+ };
340
+ }
341
+
342
+ const result = await this.driver.generateViewUrl(reference);
343
+
344
+ if (result.success) {
345
+ const response: PresignedViewUrlSuccess = {
346
+ success: true,
347
+ reference,
348
+ viewUrl: result.viewUrl ?? '',
349
+ expiresIn: this.config.presignedUrlExpiry || 600,
350
+ };
351
+ return response;
352
+ }
353
+
354
+ return result;
355
+ }
356
+
357
+ /**
358
+ * Verifies that a presigned upload actually happened and the file is valid.
359
+ *
360
+ * For Azure, this is essential — Azure doesn't enforce file constraints at
361
+ * the URL level, so we check the actual blob properties here.
362
+ * For S3/GCS, this confirms the file exists and optionally validates it.
363
+ */
364
+ async validateAndConfirmUpload(
365
+ reference: string,
366
+ options?: BlobValidationOptions
367
+ ): Promise<BlobValidationResult> {
368
+ this.assertNotDestroyed();
369
+ if (hasPathTraversal(reference)) {
370
+ return {
371
+ success: false,
372
+ error: 'Invalid reference: path traversal sequences are not allowed',
373
+ code: 'PATH_TRAVERSAL',
374
+ };
375
+ }
376
+
377
+ return this.driver.validateAndConfirmUpload(reference, options);
378
+ }
379
+
380
+ /**
381
+ * Returns true if you're using Azure presigned mode.
382
+ * Your hint that you MUST call validateAndConfirmUpload() after presigned uploads.
383
+ */
384
+ requiresPostUploadValidation(): boolean {
385
+ return this.config.driver === 'azure-presigned';
386
+ }
387
+
388
+ /**
389
+ * Creates presigned upload URLs for multiple files at once.
390
+ */
391
+ async generateUploadUrls(
392
+ files: (string | FileMetadata)[],
393
+ folder?: string,
394
+ options?: BatchOptions
395
+ ): Promise<PresignedUploadUrlResult[]> {
396
+ this.assertNotDestroyed();
397
+ if (!files || files.length === 0) {
398
+ return [];
399
+ }
400
+
401
+ const effectiveFolder = folder !== undefined ? folder : (this.config.bucketPath || '');
402
+
403
+ return withConcurrencyLimit(
404
+ files,
405
+ async (file): Promise<PresignedUploadUrlResult> => {
406
+ if (file === null || file === undefined) {
407
+ return {
408
+ success: false,
409
+ error: 'Invalid input: file entry cannot be null or undefined',
410
+ code: 'INVALID_INPUT',
411
+ };
412
+ }
413
+
414
+ if (typeof file === 'string') {
415
+ return this.generateUploadUrl(file, undefined, undefined, effectiveFolder);
416
+ }
417
+
418
+ if (typeof file !== 'object') {
419
+ return {
420
+ success: false,
421
+ error: `Invalid input type: expected string or FileMetadata object, got ${typeof file}`,
422
+ code: 'INVALID_INPUT',
423
+ };
424
+ }
425
+
426
+ if (!file.fileName || typeof file.fileName !== 'string') {
427
+ return {
428
+ success: false,
429
+ error: 'FileMetadata must have a valid fileName property',
430
+ code: 'INVALID_INPUT',
431
+ };
432
+ }
433
+
434
+ return this.generateUploadUrl(
435
+ file.fileName,
436
+ file.contentType,
437
+ file.fileSize,
438
+ effectiveFolder
439
+ );
440
+ },
441
+ { maxConcurrent: this.concurrency, signal: options?.signal }
442
+ );
443
+ }
444
+
445
+ /**
446
+ * Creates presigned view URLs for multiple files at once.
447
+ */
448
+ async generateViewUrls(
449
+ references: string[],
450
+ options?: BatchOptions
451
+ ): Promise<PresignedViewUrlResult[]> {
452
+ this.assertNotDestroyed();
453
+ if (!references || references.length === 0) {
454
+ return [];
455
+ }
456
+
457
+ return withConcurrencyLimit(
458
+ references,
459
+ async (reference): Promise<PresignedViewUrlResult> => {
460
+ if (reference === null || reference === undefined || typeof reference !== 'string') {
461
+ return {
462
+ success: false,
463
+ error: 'Invalid reference: must be a non-null string',
464
+ code: 'INVALID_INPUT',
465
+ };
466
+ }
467
+ return this.generateViewUrl(reference);
468
+ },
469
+ { maxConcurrent: this.concurrency, signal: options?.signal }
470
+ );
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Delete methods
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /**
478
+ * Deletes a single file from storage.
479
+ *
480
+ * @param reference - The full path from uploadFile result or generateUploadUrl
481
+ * @returns DeleteResult with success status and error details on failure
482
+ *
483
+ * @example
484
+ * const result = await storage.deleteFile(uploadResult.reference);
485
+ * if (!result.success) {
486
+ * console.log(result.error, result.code); // e.g., 'FILE_NOT_FOUND'
487
+ * }
488
+ */
489
+ async deleteFile(reference: string): Promise<DeleteResult> {
490
+ this.assertNotDestroyed();
491
+ this.logger.debug('deleteFile called', { reference });
492
+ return this.executeSingleDelete(reference, 'delete');
493
+ }
494
+
495
+ /**
496
+ * Deletes multiple files at once.
497
+ */
498
+ async deleteFiles(references: string[], options?: BatchOptions): Promise<DeleteResult[]> {
499
+ this.assertNotDestroyed();
500
+ if (!references || references.length === 0) {
501
+ return [];
502
+ }
503
+
504
+ return withConcurrencyLimit(
505
+ references,
506
+ (reference) => this.executeSingleDelete(reference, 'deleteMultiple'),
507
+ { maxConcurrent: this.concurrency, signal: options?.signal }
508
+ );
509
+ }
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // List files
513
+ // ---------------------------------------------------------------------------
514
+
515
+ /**
516
+ * Lists files in your storage with optional filtering and pagination.
517
+ *
518
+ * @param prefix - Only show files starting with this path
519
+ * @param maxResults - How many files to return per page (default: 1000)
520
+ * @param continuationToken - Pass nextToken from previous response for next page
521
+ */
522
+ async listFiles(
523
+ prefix?: string,
524
+ maxResults?: number,
525
+ continuationToken?: string
526
+ ): Promise<ListFilesResult> {
527
+ this.assertNotDestroyed();
528
+ if (prefix && hasPathTraversal(prefix)) {
529
+ return {
530
+ success: false,
531
+ error: 'Invalid prefix: path traversal sequences are not allowed',
532
+ code: 'PATH_TRAVERSAL',
533
+ };
534
+ }
535
+
536
+ return this.driver.listFiles(prefix, maxResults, continuationToken);
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // File metadata
541
+ // ---------------------------------------------------------------------------
542
+
543
+ /**
544
+ * Returns metadata about a file without downloading it.
545
+ *
546
+ * @param reference - The full path from uploadFile result or generateUploadUrl
547
+ * @returns FileInfo with name, size, contentType, lastModified — or null if not found
548
+ *
549
+ * @example
550
+ * const info = await storage.getMetadata(uploadResult.reference);
551
+ * if (info) {
552
+ * console.log(`${info.name}: ${info.size} bytes, ${info.contentType}`);
553
+ * }
554
+ */
555
+ async getMetadata(reference: string): Promise<FileInfo | null> {
556
+ this.assertNotDestroyed();
557
+ if (hasPathTraversal(reference)) {
558
+ return null;
559
+ }
560
+ return this.driver.getMetadata(reference);
561
+ }
562
+
563
+ /**
564
+ * Returns true if a file exists at the given reference.
565
+ *
566
+ * @param reference - The full path from uploadFile result or generateUploadUrl
567
+ */
568
+ async exists(reference: string): Promise<boolean> {
569
+ const metadata = await this.getMetadata(reference);
570
+ return metadata !== null;
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // Configuration accessors
575
+ // ---------------------------------------------------------------------------
576
+
577
+ /**
578
+ * Returns a copy of the current configuration without credentials.
579
+ * Safe to log, expose in admin panels, or include in error reports.
580
+ */
581
+ getConfig(): PublicStorageConfig {
582
+ return {
583
+ driver: this.config.driver,
584
+ bucketName: this.config.bucketName,
585
+ bucketPath: this.config.bucketPath,
586
+ localPath: this.config.localPath,
587
+ presignedUrlExpiry: this.config.presignedUrlExpiry,
588
+ maxFileSize: this.config.maxFileSize,
589
+ awsRegion: this.config.awsRegion,
590
+ gcsProjectId: this.config.gcsProjectId,
591
+ azureAccountName: this.config.azureAccountName,
592
+ azureContainerName: this.config.azureContainerName,
593
+ };
594
+ }
595
+
596
+ getDriverType(): StorageDriver {
597
+ return this.config.driver;
598
+ }
599
+
600
+ /**
601
+ * Returns true if the driver operates in presigned mode.
602
+ * In presigned mode, upload() returns URLs instead of uploading directly.
603
+ */
604
+ isPresignedUploadMode(): boolean {
605
+ return this.config.driver.includes('-presigned');
606
+ }
607
+
608
+ /**
609
+ * Returns rate limit status information.
610
+ * Returns null if rate limiting is not configured.
611
+ */
612
+ async getRateLimitStatus(): Promise<{ remainingRequests: number; resetTimeMs: number } | null> {
613
+ this.assertNotDestroyed();
614
+ if (!this.rateLimiter) {
615
+ return null;
616
+ }
617
+ return {
618
+ remainingRequests: await this.rateLimiter.getRemainingRequests(),
619
+ resetTimeMs: await this.rateLimiter.getResetTime(),
620
+ };
621
+ }
622
+
623
+ static getAvailableDrivers(): StorageDriver[] {
624
+ return getAvailableDrivers() as StorageDriver[];
625
+ }
626
+
627
+ /**
628
+ * Releases resources held by this StorageManager instance.
629
+ * Clears the rate limiter and hooks. The instance should not be reused
630
+ * after calling this method.
631
+ *
632
+ * @example
633
+ * const storage = new StorageManager({ driver: 's3' });
634
+ * // ... use storage ...
635
+ * storage.destroy(); // free resources
636
+ */
637
+ destroy(): void {
638
+ if (this.destroyed) return;
639
+ this.destroyed = true;
640
+ this.driver.destroy();
641
+ this.rateLimiter = null;
642
+ this.hooks = {};
643
+ this.logger.info('StorageManager destroyed');
644
+ }
645
+
646
+ isDestroyed(): boolean {
647
+ return this.destroyed;
648
+ }
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // Private helpers
652
+ // ---------------------------------------------------------------------------
653
+
654
+ /**
655
+ * Shared upload orchestration: validation → beforeUpload hook → driver.upload → afterUpload hook.
656
+ * Used by both uploadFile() and uploadFiles() to eliminate duplication.
657
+ */
658
+ private async executeSingleUpload(
659
+ file: Express.Multer.File,
660
+ validation: FileValidationOptions | undefined,
661
+ uploadOptions: UploadOptions | undefined,
662
+ operation: 'upload' | 'uploadMultiple'
663
+ ): Promise<FileUploadResult> {
664
+ if (validation) {
665
+ const validationResult = validateFileForUpload(file, validation);
666
+ if (validationResult) {
667
+ const error = operation === 'uploadMultiple'
668
+ ? `File '${file.originalname || 'unknown'}': ${validationResult.error}`
669
+ : validationResult.error;
670
+ this.logger.warn('File validation failed', { error: validationResult.error });
671
+ return { success: false, error, code: validationResult.code };
672
+ }
673
+ }
674
+
675
+ if (uploadOptions?.contentType && !isValidMimeType(uploadOptions.contentType)) {
676
+ const error = `Invalid contentType format: '${uploadOptions.contentType}'. Expected format: type/subtype (e.g., 'image/jpeg')`;
677
+ return { success: false, error, code: 'INVALID_INPUT' };
678
+ }
679
+
680
+ try {
681
+ await this.hooks.beforeUpload?.(file, uploadOptions);
682
+ } catch (error) {
683
+ const hookError = error instanceof Error ? error : new Error(String(error));
684
+ await this.invokeOnError(hookError, { operation, file });
685
+ const msg = operation === 'uploadMultiple'
686
+ ? `File '${file.originalname}': Upload aborted by hook: ${hookError.message}`
687
+ : `Upload aborted by hook: ${hookError.message}`;
688
+ this.logger.warn('beforeUpload hook aborted upload', { error: hookError.message });
689
+ return { success: false, error: msg, code: 'HOOK_ABORTED' };
690
+ }
691
+
692
+ let result: FileUploadResult;
693
+ try {
694
+ result = await this.driver.upload(file, uploadOptions);
695
+ } catch (error) {
696
+ const errorMsg = error instanceof Error ? error.message : 'Failed to upload file';
697
+ result = {
698
+ success: false,
699
+ error: operation === 'uploadMultiple' ? `File '${file.originalname}': ${errorMsg}` : errorMsg,
700
+ code: 'PROVIDER_ERROR',
701
+ };
702
+ }
703
+
704
+ if (result.success) {
705
+ this.logger.info('File uploaded successfully', { reference: result.reference });
706
+ } else {
707
+ this.logger.error('File upload failed', { error: result.error });
708
+ await this.invokeOnError(new Error(result.error), { operation, file });
709
+ }
710
+
711
+ try {
712
+ await this.hooks.afterUpload?.(result, file);
713
+ } catch (hookError) {
714
+ this.logger.warn('afterUpload hook threw', { error: hookError instanceof Error ? hookError.message : String(hookError) });
715
+ }
716
+
717
+ return result;
718
+ }
719
+
720
+ /**
721
+ * Shared delete orchestration: path check → beforeDelete hook → driver.delete → afterDelete hook.
722
+ * Used by both deleteFile() and deleteFiles() to eliminate duplication.
723
+ */
724
+ private async executeSingleDelete(reference: string, operation: 'delete' | 'deleteMultiple'): Promise<DeleteResult> {
725
+ if (hasPathTraversal(reference)) {
726
+ this.logger.warn('delete rejected: path traversal attempt', { reference });
727
+ return { success: false, reference, error: 'Invalid reference: path traversal sequences are not allowed', code: 'PATH_TRAVERSAL' };
728
+ }
729
+
730
+ try {
731
+ await this.hooks.beforeDelete?.(reference);
732
+ } catch (error) {
733
+ const hookError = error instanceof Error ? error : new Error(String(error));
734
+ await this.invokeOnError(hookError, { operation, reference });
735
+ return { success: false, reference, error: `Deletion aborted by hook: ${hookError.message}`, code: 'HOOK_ABORTED' };
736
+ }
737
+
738
+ let result: DeleteResult;
739
+ try {
740
+ result = await this.driver.delete(reference);
741
+ } catch (error) {
742
+ result = { success: false, reference, error: error instanceof Error ? error.message : 'Failed to delete file', code: 'PROVIDER_ERROR' };
743
+ }
744
+
745
+ try {
746
+ await this.hooks.afterDelete?.(reference, result.success);
747
+ } catch {
748
+ // afterDelete hook errors are non-fatal
749
+ }
750
+
751
+ if (result.success) {
752
+ this.logger.info('File deleted successfully', { reference });
753
+ } else {
754
+ this.logger.warn('File deletion failed', { reference, error: result.error });
755
+ }
756
+
757
+ return result;
758
+ }
759
+
760
+ private async checkRateLimit(): Promise<PresignedUrlError | null> {
761
+ if (!this.rateLimiter) return null;
762
+
763
+ const allowed = await this.rateLimiter.tryAcquire();
764
+ if (!allowed) {
765
+ const resetTime = await this.rateLimiter.getResetTime();
766
+ this.logger.warn('Rate limit exceeded for presigned URL generation', { resetTimeMs: resetTime });
767
+ return {
768
+ success: false,
769
+ error: `Rate limit exceeded. Try again in ${Math.ceil(resetTime / 1000)} seconds.`,
770
+ code: 'RATE_LIMITED',
771
+ };
772
+ }
773
+
774
+ return null;
775
+ }
776
+
777
+ private buildFilePath(fileName: string, folder?: string): string {
778
+ if (!folder) {
779
+ return fileName;
780
+ }
781
+
782
+ const normalizedFolder = folder.replace(/^\/+|\/+$/g, '');
783
+ if (!normalizedFolder) {
784
+ return fileName;
785
+ }
786
+
787
+ return `${normalizedFolder}/${fileName}`;
788
+ }
789
+
790
+ /**
791
+ * Safely invokes the onError hook. Swallows hook exceptions to prevent
792
+ * error-in-error-handler cascades.
793
+ */
794
+ private async invokeOnError(error: Error, context: HookErrorContext): Promise<void> {
795
+ try {
796
+ await this.hooks.onError?.(error, context);
797
+ } catch {
798
+ // Never let an error hook crash the caller
799
+ }
800
+ }
801
+ }