express-storage 1.0.0 → 1.1.3

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 (48) hide show
  1. package/README.md +519 -348
  2. package/dist/drivers/azure.driver.d.ts +88 -0
  3. package/dist/drivers/azure.driver.d.ts.map +1 -0
  4. package/dist/drivers/azure.driver.js +367 -0
  5. package/dist/drivers/azure.driver.js.map +1 -0
  6. package/dist/drivers/base.driver.d.ts +125 -24
  7. package/dist/drivers/base.driver.d.ts.map +1 -1
  8. package/dist/drivers/base.driver.js +248 -62
  9. package/dist/drivers/base.driver.js.map +1 -1
  10. package/dist/drivers/gcs.driver.d.ts +60 -13
  11. package/dist/drivers/gcs.driver.d.ts.map +1 -1
  12. package/dist/drivers/gcs.driver.js +242 -41
  13. package/dist/drivers/gcs.driver.js.map +1 -1
  14. package/dist/drivers/local.driver.d.ts +89 -12
  15. package/dist/drivers/local.driver.d.ts.map +1 -1
  16. package/dist/drivers/local.driver.js +533 -45
  17. package/dist/drivers/local.driver.js.map +1 -1
  18. package/dist/drivers/s3.driver.d.ts +64 -13
  19. package/dist/drivers/s3.driver.d.ts.map +1 -1
  20. package/dist/drivers/s3.driver.js +269 -41
  21. package/dist/drivers/s3.driver.js.map +1 -1
  22. package/dist/factory/driver.factory.d.ts +35 -29
  23. package/dist/factory/driver.factory.d.ts.map +1 -1
  24. package/dist/factory/driver.factory.js +119 -59
  25. package/dist/factory/driver.factory.js.map +1 -1
  26. package/dist/index.d.ts +23 -22
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +26 -46
  29. package/dist/index.js.map +1 -1
  30. package/dist/storage-manager.d.ts +205 -52
  31. package/dist/storage-manager.d.ts.map +1 -1
  32. package/dist/storage-manager.js +644 -73
  33. package/dist/storage-manager.js.map +1 -1
  34. package/dist/types/storage.types.d.ts +243 -18
  35. package/dist/types/storage.types.d.ts.map +1 -1
  36. package/dist/utils/config.utils.d.ts +28 -4
  37. package/dist/utils/config.utils.d.ts.map +1 -1
  38. package/dist/utils/config.utils.js +121 -47
  39. package/dist/utils/config.utils.js.map +1 -1
  40. package/dist/utils/file.utils.d.ts +111 -14
  41. package/dist/utils/file.utils.d.ts.map +1 -1
  42. package/dist/utils/file.utils.js +215 -32
  43. package/dist/utils/file.utils.js.map +1 -1
  44. package/package.json +51 -27
  45. package/dist/drivers/oci.driver.d.ts +0 -37
  46. package/dist/drivers/oci.driver.d.ts.map +0 -1
  47. package/dist/drivers/oci.driver.js +0 -84
  48. package/dist/drivers/oci.driver.js.map +0 -1
@@ -1,144 +1,715 @@
1
1
  import { StorageDriverFactory } from './factory/driver.factory.js';
2
- import { loadAndValidateConfig } from './utils/config.utils.js';
2
+ import { validateStorageConfig, loadEnvironmentConfig, environmentToStorageConfig } from './utils/config.utils.js';
3
+ import { getFileExtension, generateUniqueFileName, validateFileName, withConcurrencyLimit, formatFileSize } from './utils/file.utils.js';
3
4
  /**
4
- * Main storage manager class
5
+ * Simple sliding window rate limiter for presigned URL generation.
6
+ * Tracks request timestamps and rejects requests that exceed the limit.
5
7
  */
8
+ class RateLimiter {
9
+ constructor(options) {
10
+ this.requests = [];
11
+ this.maxRequests = options.maxRequests;
12
+ this.windowMs = options.windowMs || 60000; // Default: 1 minute
13
+ }
14
+ /**
15
+ * Check if a request is allowed and record it if so.
16
+ * @returns true if allowed, false if rate limited
17
+ */
18
+ tryAcquire() {
19
+ const now = Date.now();
20
+ const windowStart = now - this.windowMs;
21
+ // Remove expired entries (outside the window)
22
+ this.requests = this.requests.filter(timestamp => timestamp > windowStart);
23
+ // Check if we're at the limit
24
+ if (this.requests.length >= this.maxRequests) {
25
+ return false;
26
+ }
27
+ // Record this request
28
+ this.requests.push(now);
29
+ return true;
30
+ }
31
+ /**
32
+ * Get the number of remaining requests in the current window.
33
+ */
34
+ getRemainingRequests() {
35
+ const now = Date.now();
36
+ const windowStart = now - this.windowMs;
37
+ this.requests = this.requests.filter(timestamp => timestamp > windowStart);
38
+ return Math.max(0, this.maxRequests - this.requests.length);
39
+ }
40
+ /**
41
+ * Get the time until the rate limit resets (in ms).
42
+ */
43
+ getResetTime() {
44
+ if (this.requests.length === 0) {
45
+ return 0;
46
+ }
47
+ const oldestRequest = Math.min(...this.requests);
48
+ const resetTime = oldestRequest + this.windowMs - Date.now();
49
+ return Math.max(0, resetTime);
50
+ }
51
+ }
52
+ /**
53
+ * StorageManager - Your single point of contact for all file operations.
54
+ *
55
+ * Think of it as a universal remote that works with any storage provider.
56
+ * You don't need to know the specifics of S3, GCS, Azure, or local storage —
57
+ * just tell StorageManager what you want to do and it handles the rest.
58
+ *
59
+ * @example
60
+ * // The simplest setup - just reads from your .env file
61
+ * const storage = new StorageManager();
62
+ *
63
+ * // Or configure it yourself
64
+ * const storage = new StorageManager({
65
+ * driver: 's3',
66
+ * credentials: {
67
+ * bucketName: 'my-bucket',
68
+ * awsRegion: 'us-east-1'
69
+ * }
70
+ * });
71
+ */
72
+ // Silent logger when no custom logger is provided
73
+ const noopLogger = {
74
+ debug: () => { },
75
+ info: () => { },
76
+ warn: () => { },
77
+ error: () => { },
78
+ };
6
79
  export class StorageManager {
7
- constructor() {
8
- this.isInitialized = false;
9
- // Initialize with default config
10
- const result = loadAndValidateConfig();
11
- if (!result.validation.isValid) {
12
- throw new Error(`Configuration validation failed: ${result.validation.errors.join(', ')}`);
80
+ constructor(options) {
81
+ this.rateLimiter = null;
82
+ this.logger = options?.logger || noopLogger;
83
+ this.config = this.buildConfig(options);
84
+ // Initialize rate limiter if configured
85
+ if (options?.rateLimit) {
86
+ this.rateLimiter = new RateLimiter(options.rateLimit);
87
+ this.logger.debug('Rate limiting enabled', {
88
+ maxRequests: options.rateLimit.maxRequests,
89
+ windowMs: options.rateLimit.windowMs || 60000
90
+ });
13
91
  }
14
- this.config = result.config;
15
- this.driver = StorageDriverFactory.createDriver(result.config);
16
- this.isInitialized = true;
92
+ this.logger.debug('StorageManager initializing', { driver: this.config.driver });
93
+ // Make sure the configuration makes sense before proceeding
94
+ const validation = validateStorageConfig(this.config);
95
+ if (!validation.isValid) {
96
+ this.logger.error('Configuration validation failed', { errors: validation.errors });
97
+ throw new Error(`Configuration validation failed: ${validation.errors.join(', ')}`);
98
+ }
99
+ this.driver = StorageDriverFactory.createDriver(this.config);
100
+ this.logger.info('StorageManager initialized', { driver: this.config.driver });
17
101
  }
18
102
  /**
19
- * Initialize storage manager with custom configuration
103
+ * Builds the final configuration by merging environment variables with any
104
+ * options you passed in. Your explicit options always win over env vars.
20
105
  */
21
- static initialize(config) {
22
- const result = loadAndValidateConfig();
23
- // Merge custom config with default
24
- const mergedConfig = { ...result.config, ...config };
25
- // Validate merged config
26
- const { validateStorageConfig } = require('./utils/config.utils');
27
- const validationResult = validateStorageConfig(mergedConfig);
28
- if (!validationResult.isValid) {
29
- throw new Error(`Configuration validation failed: ${validationResult.errors.join(', ')}`);
106
+ buildConfig(options) {
107
+ const envConfig = loadEnvironmentConfig();
108
+ const baseConfig = environmentToStorageConfig(envConfig);
109
+ if (!options) {
110
+ return {
111
+ ...baseConfig,
112
+ driver: baseConfig.driver || 'local',
113
+ maxFileSize: baseConfig.maxFileSize || 5 * 1024 * 1024 * 1024,
114
+ };
30
115
  }
31
- const manager = new StorageManager();
32
- manager.config = mergedConfig;
33
- manager.driver = StorageDriverFactory.createDriver(mergedConfig);
34
- manager.isInitialized = true;
35
- return manager;
116
+ const creds = options.credentials || {};
117
+ // Use nullish coalescing (??) for numeric values to allow explicit 0 values
118
+ // Using || would treat 0 as falsy and override with defaults
119
+ return {
120
+ driver: options.driver || baseConfig.driver || 'local',
121
+ bucketName: creds.bucketName || baseConfig.bucketName,
122
+ bucketPath: creds.bucketPath ?? baseConfig.bucketPath ?? '',
123
+ localPath: creds.localPath || baseConfig.localPath || 'public/express-storage',
124
+ presignedUrlExpiry: creds.presignedUrlExpiry ?? baseConfig.presignedUrlExpiry ?? 600,
125
+ maxFileSize: creds.maxFileSize ?? baseConfig.maxFileSize ?? 5 * 1024 * 1024 * 1024,
126
+ awsRegion: creds.awsRegion || baseConfig.awsRegion,
127
+ awsAccessKey: creds.awsAccessKey || baseConfig.awsAccessKey,
128
+ awsSecretKey: creds.awsSecretKey || baseConfig.awsSecretKey,
129
+ gcsProjectId: creds.gcsProjectId || baseConfig.gcsProjectId,
130
+ gcsCredentials: creds.gcsCredentials || baseConfig.gcsCredentials,
131
+ azureConnectionString: creds.azureConnectionString || baseConfig.azureConnectionString,
132
+ azureAccountName: creds.azureAccountName || baseConfig.azureAccountName,
133
+ azureAccountKey: creds.azureAccountKey || baseConfig.azureAccountKey,
134
+ azureContainerName: creds.azureContainerName || baseConfig.azureContainerName,
135
+ };
36
136
  }
37
137
  /**
38
- * Upload a single file
138
+ * Uploads a single file to your configured storage.
139
+ *
140
+ * This is the method you'll use most often. It handles everything:
141
+ * validation, unique naming, and the actual upload.
142
+ *
143
+ * @param file - The file from Multer (req.file)
144
+ * @param validation - Optional rules like max size and allowed types
145
+ * @param uploadOptions - Optional metadata, cache headers, etc.
146
+ *
147
+ * @example
148
+ * // Simple upload
149
+ * const result = await storage.uploadFile(req.file);
150
+ *
151
+ * // With validation (reject files over 5MB or wrong type)
152
+ * const result = await storage.uploadFile(req.file, {
153
+ * maxSize: 5 * 1024 * 1024,
154
+ * allowedMimeTypes: ['image/jpeg', 'image/png']
155
+ * });
156
+ *
157
+ * // With custom metadata
158
+ * const result = await storage.uploadFile(req.file, undefined, {
159
+ * metadata: { uploadedBy: 'user123' },
160
+ * cacheControl: 'max-age=31536000'
161
+ * });
39
162
  */
40
- async uploadFile(file) {
41
- this.ensureInitialized();
42
- return this.driver.upload(file);
163
+ async uploadFile(file, validation, uploadOptions) {
164
+ if (!file) {
165
+ this.logger.warn('uploadFile called with null/undefined file');
166
+ return { success: false, error: 'No file provided' };
167
+ }
168
+ this.logger.debug('uploadFile called', {
169
+ originalName: file.originalname,
170
+ size: file.size,
171
+ mimeType: file.mimetype
172
+ });
173
+ if (validation) {
174
+ const validationError = this.validateFile(file, validation);
175
+ if (validationError) {
176
+ this.logger.warn('File validation failed', { error: validationError });
177
+ return { success: false, error: validationError };
178
+ }
179
+ }
180
+ const result = await this.driver.upload(file, uploadOptions);
181
+ if (result.success) {
182
+ this.logger.info('File uploaded successfully', { fileName: result.fileName });
183
+ }
184
+ else {
185
+ this.logger.error('File upload failed', { error: result.error });
186
+ }
187
+ return result;
43
188
  }
44
189
  /**
45
- * Upload multiple files
190
+ * Uploads multiple files at once.
191
+ *
192
+ * Files are processed in parallel (up to 10 at a time) for speed,
193
+ * but each file gets its own result — so one failure doesn't stop the others.
46
194
  */
47
- async uploadFiles(files) {
48
- this.ensureInitialized();
49
- return this.driver.uploadMultiple(files);
195
+ async uploadFiles(files, validation, uploadOptions) {
196
+ if (!files || files.length === 0) {
197
+ return [];
198
+ }
199
+ return withConcurrencyLimit(files, async (file) => {
200
+ if (validation) {
201
+ const validationError = this.validateFile(file, validation);
202
+ if (validationError) {
203
+ return {
204
+ success: false,
205
+ error: `File '${file.originalname || 'unknown'}': ${validationError}`,
206
+ };
207
+ }
208
+ }
209
+ try {
210
+ return await this.driver.upload(file, uploadOptions);
211
+ }
212
+ catch (error) {
213
+ return {
214
+ success: false,
215
+ error: `File '${file.originalname}': ${error instanceof Error ? error.message : 'Failed to upload file'}`,
216
+ };
217
+ }
218
+ }, { maxConcurrent: 10 });
50
219
  }
51
220
  /**
52
- * Upload files with input type detection
221
+ * Smart upload that handles both single files and arrays.
222
+ * Pass in what you have and it figures out the rest.
53
223
  */
54
- async upload(input) {
55
- this.ensureInitialized();
224
+ async upload(input, validation, uploadOptions) {
56
225
  if (input.type === 'single') {
57
- return this.driver.upload(input.file);
226
+ return this.uploadFile(input.file, validation, uploadOptions);
58
227
  }
59
228
  else {
60
- return this.driver.uploadMultiple(input.files);
229
+ return this.uploadFiles(input.files, validation, uploadOptions);
61
230
  }
62
231
  }
63
232
  /**
64
- * Generate upload URL for presigned uploads
233
+ * Creates a presigned URL that lets clients upload directly to cloud storage.
234
+ *
235
+ * This is powerful for large files — the upload goes straight to S3/GCS/Azure
236
+ * without passing through your server, saving bandwidth and processing time.
237
+ *
238
+ * The URL is time-limited and (for S3/GCS) locked to specific file constraints.
239
+ *
240
+ * Rate limiting: If you configured `rateLimit` in StorageOptions, this method
241
+ * will reject requests that exceed the limit with an error.
242
+ *
243
+ * @param fileName - What the user wants to call their file
244
+ * @param contentType - The MIME type (e.g., 'image/jpeg')
245
+ * @param fileSize - Exact size in bytes (enforced by S3/GCS, advisory for Azure)
246
+ * @param folder - Where to put the file (overrides your default BUCKET_PATH)
247
+ *
248
+ * @example
249
+ * const result = await storage.generateUploadUrl('photo.jpg', 'image/jpeg', 12345);
250
+ * // Give result.uploadUrl to your frontend
251
+ * // Save result.reference — you'll need it to view or delete the file later
65
252
  */
66
- async generateUploadUrl(fileName) {
67
- this.ensureInitialized();
68
- return this.driver.generateUploadUrl(fileName);
253
+ async generateUploadUrl(fileName, contentType, fileSize, folder) {
254
+ // Check rate limit if configured
255
+ if (this.rateLimiter && !this.rateLimiter.tryAcquire()) {
256
+ const resetTime = this.rateLimiter.getResetTime();
257
+ this.logger.warn('Rate limit exceeded for presigned URL generation', { resetTimeMs: resetTime });
258
+ return {
259
+ success: false,
260
+ error: `Rate limit exceeded. Try again in ${Math.ceil(resetTime / 1000)} seconds.`,
261
+ };
262
+ }
263
+ // Make sure the filename is safe
264
+ const fileNameError = validateFileName(fileName);
265
+ if (fileNameError) {
266
+ return { success: false, error: fileNameError };
267
+ }
268
+ // Validate file size if provided
269
+ // Allow fileSize of 0 for empty/placeholder files (e.g., .gitkeep, lock files)
270
+ if (fileSize !== undefined) {
271
+ if (typeof fileSize !== 'number' || Number.isNaN(fileSize) || fileSize < 0) {
272
+ return { success: false, error: 'fileSize must be a non-negative number' };
273
+ }
274
+ const defaultMaxSize = 5 * 1024 * 1024 * 1024;
275
+ const maxAllowedSize = this.config.maxFileSize ?? defaultMaxSize;
276
+ const effectiveMaxSize = maxAllowedSize > 0 ? maxAllowedSize : defaultMaxSize;
277
+ if (fileSize > effectiveMaxSize) {
278
+ return {
279
+ success: false,
280
+ error: `fileSize cannot exceed ${effectiveMaxSize} bytes (${this.formatBytes(effectiveMaxSize)})`,
281
+ };
282
+ }
283
+ }
284
+ // Make sure content type looks valid
285
+ if (contentType && !this.isValidMimeType(contentType)) {
286
+ return {
287
+ success: false,
288
+ error: `Invalid contentType format: '${contentType}'. Expected format: type/subtype (e.g., 'image/jpeg')`,
289
+ };
290
+ }
291
+ // Create a unique filename to prevent overwrites
292
+ const uniqueFileName = generateUniqueFileName(fileName);
293
+ const effectiveFolder = folder !== undefined ? folder : (this.config.bucketPath || '');
294
+ // Security check on the folder path
295
+ if (effectiveFolder) {
296
+ const folderValidationError = this.validateFolderPath(effectiveFolder);
297
+ if (folderValidationError) {
298
+ return { success: false, error: folderValidationError };
299
+ }
300
+ }
301
+ const reference = this.buildFilePath(uniqueFileName, effectiveFolder);
302
+ const result = await this.driver.generateUploadUrl(reference, contentType, fileSize);
303
+ if (result.success) {
304
+ const response = {
305
+ ...result,
306
+ fileName: uniqueFileName,
307
+ reference,
308
+ expiresIn: this.config.presignedUrlExpiry || 600,
309
+ };
310
+ if (effectiveFolder) {
311
+ response.filePath = effectiveFolder;
312
+ }
313
+ if (contentType) {
314
+ response.contentType = contentType;
315
+ }
316
+ if (fileSize !== undefined) {
317
+ response.fileSize = fileSize;
318
+ }
319
+ // Azure doesn't enforce constraints at the URL level
320
+ if (this.config.driver === 'azure-presigned') {
321
+ response.requiresValidation = true;
322
+ }
323
+ return response;
324
+ }
325
+ return result;
69
326
  }
70
327
  /**
71
- * Generate view URL for presigned uploads
328
+ * Combines folder and filename into a full path.
329
+ * Handles edge cases like leading/trailing slashes.
72
330
  */
73
- async generateViewUrl(fileName) {
74
- this.ensureInitialized();
75
- return this.driver.generateViewUrl(fileName);
331
+ buildFilePath(fileName, folder) {
332
+ if (!folder) {
333
+ return fileName;
334
+ }
335
+ const normalizedFolder = folder.replace(/^\/+|\/+$/g, '');
336
+ if (!normalizedFolder) {
337
+ return fileName;
338
+ }
339
+ return `${normalizedFolder}/${fileName}`;
76
340
  }
77
341
  /**
78
- * Generate multiple upload URLs
342
+ * Checks folder paths for security issues.
343
+ * Blocks path traversal attempts and other sneaky tricks.
79
344
  */
80
- async generateUploadUrls(fileNames) {
81
- this.ensureInitialized();
82
- return this.driver.generateMultipleUploadUrls(fileNames);
345
+ validateFolderPath(folder) {
346
+ if (folder.includes('..')) {
347
+ return 'Folder path cannot contain path traversal sequences (..)';
348
+ }
349
+ if (folder.includes('\0')) {
350
+ return 'Folder path cannot contain null bytes';
351
+ }
352
+ const invalidCharsRegex = /[<>:"|?*\\;$`']/;
353
+ if (invalidCharsRegex.test(folder)) {
354
+ return "Folder path contains invalid characters. Avoid: < > : \" | ? * \\ ; $ ` '";
355
+ }
356
+ if (/\/{2,}/.test(folder)) {
357
+ return 'Folder path cannot contain consecutive slashes';
358
+ }
359
+ return null;
83
360
  }
84
361
  /**
85
- * Generate multiple view URLs
362
+ * Checks if a string looks like a valid MIME type.
86
363
  */
87
- async generateViewUrls(fileNames) {
88
- this.ensureInitialized();
89
- return this.driver.generateMultipleViewUrls(fileNames);
364
+ isValidMimeType(mimeType) {
365
+ const mimeTypeRegex = /^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/;
366
+ return mimeTypeRegex.test(mimeType);
90
367
  }
91
368
  /**
92
- * Delete a single file
369
+ * Converts bytes to a human-readable string like "5.2 MB".
93
370
  */
94
- async deleteFile(fileName) {
95
- this.ensureInitialized();
96
- return this.driver.delete(fileName);
371
+ formatBytes(bytes) {
372
+ return formatFileSize(bytes);
97
373
  }
98
374
  /**
99
- * Delete multiple files
375
+ * Creates a presigned URL for viewing/downloading an existing file.
376
+ *
377
+ * Rate limiting: If configured, this counts toward the presigned URL rate limit.
378
+ *
379
+ * @param reference - The full path you got from generateUploadUrl
100
380
  */
101
- async deleteFiles(fileNames) {
102
- this.ensureInitialized();
103
- return this.driver.deleteMultiple(fileNames);
381
+ async generateViewUrl(reference) {
382
+ // Check rate limit if configured
383
+ if (this.rateLimiter && !this.rateLimiter.tryAcquire()) {
384
+ const resetTime = this.rateLimiter.getResetTime();
385
+ this.logger.warn('Rate limit exceeded for presigned URL generation', { resetTimeMs: resetTime });
386
+ return {
387
+ success: false,
388
+ error: `Rate limit exceeded. Try again in ${Math.ceil(resetTime / 1000)} seconds.`,
389
+ };
390
+ }
391
+ if (reference.includes('..') || reference.includes('\0')) {
392
+ return {
393
+ success: false,
394
+ error: 'Invalid reference: path traversal sequences are not allowed',
395
+ };
396
+ }
397
+ const result = await this.driver.generateViewUrl(reference);
398
+ if (result.success) {
399
+ return {
400
+ ...result,
401
+ reference,
402
+ expiresIn: this.config.presignedUrlExpiry || 600,
403
+ };
404
+ }
405
+ return result;
104
406
  }
105
407
  /**
106
- * Get current configuration
408
+ * Verifies that a presigned upload actually happened and the file is valid.
409
+ *
410
+ * For Azure, this is essential — Azure doesn't enforce file constraints at
411
+ * the URL level, so we check the actual blob properties here.
412
+ *
413
+ * For S3/GCS, this confirms the file exists and optionally validates it.
414
+ *
415
+ * @param reference - The file path from generateUploadUrl
416
+ * @param options - Expected content type and size to validate against
417
+ *
418
+ * @example
419
+ * // After the client uploads, verify everything looks right
420
+ * const result = await storage.validateAndConfirmUpload(reference, {
421
+ * expectedContentType: 'image/jpeg',
422
+ * expectedFileSize: 12345
423
+ * });
424
+ */
425
+ async validateAndConfirmUpload(reference, options) {
426
+ if (reference.includes('..') || reference.includes('\0')) {
427
+ return {
428
+ success: false,
429
+ error: 'Invalid reference: path traversal sequences are not allowed',
430
+ };
431
+ }
432
+ return this.driver.validateAndConfirmUpload(reference, options);
433
+ }
434
+ /**
435
+ * Returns true if you're using Azure presigned mode.
436
+ *
437
+ * This is your hint that you MUST call validateAndConfirmUpload()
438
+ * after presigned uploads — Azure doesn't enforce constraints otherwise.
439
+ */
440
+ requiresPostUploadValidation() {
441
+ return this.config.driver === 'azure-presigned';
442
+ }
443
+ /**
444
+ * Creates presigned upload URLs for multiple files at once.
445
+ * Great for batch uploads or when letting users select multiple files.
446
+ *
447
+ * @param files - Array of filenames (strings) or file metadata objects
448
+ * @param folder - Optional folder to put all files in
449
+ */
450
+ async generateUploadUrls(files, folder) {
451
+ if (!files || files.length === 0) {
452
+ return [];
453
+ }
454
+ const effectiveFolder = folder !== undefined ? folder : (this.config.bucketPath || '');
455
+ return withConcurrencyLimit(files, async (file) => {
456
+ if (file === null || file === undefined) {
457
+ return {
458
+ success: false,
459
+ error: 'Invalid input: file entry cannot be null or undefined',
460
+ };
461
+ }
462
+ if (typeof file === 'string') {
463
+ return this.generateUploadUrl(file, undefined, undefined, effectiveFolder);
464
+ }
465
+ if (typeof file !== 'object') {
466
+ return {
467
+ success: false,
468
+ error: `Invalid input type: expected string or FileMetadata object, got ${typeof file}`,
469
+ };
470
+ }
471
+ if (!file.fileName || typeof file.fileName !== 'string') {
472
+ return {
473
+ success: false,
474
+ error: 'FileMetadata must have a valid fileName property',
475
+ };
476
+ }
477
+ return this.generateUploadUrl(file.fileName, file.contentType, file.fileSize, effectiveFolder);
478
+ }, { maxConcurrent: 10 });
479
+ }
480
+ /**
481
+ * Creates presigned view URLs for multiple files at once.
482
+ * Useful when displaying a gallery or list of downloadable files.
483
+ */
484
+ async generateViewUrls(references) {
485
+ if (!references || references.length === 0) {
486
+ return [];
487
+ }
488
+ return withConcurrencyLimit(references, async (reference) => {
489
+ if (reference === null || reference === undefined || typeof reference !== 'string') {
490
+ return {
491
+ success: false,
492
+ error: 'Invalid reference: must be a non-null string',
493
+ };
494
+ }
495
+ if (reference.includes('..') || reference.includes('\0')) {
496
+ return {
497
+ success: false,
498
+ error: 'Invalid reference: path traversal sequences are not allowed',
499
+ };
500
+ }
501
+ return this.generateViewUrl(reference);
502
+ }, { maxConcurrent: 10 });
503
+ }
504
+ /**
505
+ * Deletes a single file from storage.
506
+ *
507
+ * @param reference - The full path from uploadFile result or generateUploadUrl
508
+ * @returns true if deleted, false if not found
509
+ */
510
+ async deleteFile(reference) {
511
+ this.logger.debug('deleteFile called', { reference });
512
+ if (reference.includes('..') || reference.includes('\0')) {
513
+ this.logger.warn('deleteFile rejected: path traversal attempt', { reference });
514
+ return false;
515
+ }
516
+ const result = await this.driver.delete(reference);
517
+ if (result) {
518
+ this.logger.info('File deleted successfully', { reference });
519
+ }
520
+ else {
521
+ this.logger.warn('File deletion failed or file not found', { reference });
522
+ }
523
+ return result;
524
+ }
525
+ /**
526
+ * Deletes multiple files at once.
527
+ * Returns detailed results so you know exactly what succeeded and what failed.
528
+ */
529
+ async deleteFiles(references) {
530
+ if (!references || references.length === 0) {
531
+ return [];
532
+ }
533
+ return withConcurrencyLimit(references, async (reference) => {
534
+ if (reference.includes('..') || reference.includes('\0')) {
535
+ return {
536
+ success: false,
537
+ fileName: reference,
538
+ error: 'Invalid reference: path traversal sequences are not allowed',
539
+ };
540
+ }
541
+ try {
542
+ const success = await this.driver.delete(reference);
543
+ const result = { success, fileName: reference };
544
+ if (!success) {
545
+ result.error = 'File not found or already deleted';
546
+ }
547
+ return result;
548
+ }
549
+ catch (error) {
550
+ return {
551
+ success: false,
552
+ fileName: reference,
553
+ error: error instanceof Error ? error.message : 'Failed to delete file',
554
+ };
555
+ }
556
+ }, { maxConcurrent: 10 });
557
+ }
558
+ /**
559
+ * Lists files in your storage with optional filtering and pagination.
560
+ *
561
+ * @param prefix - Only show files starting with this path (e.g., 'uploads/2026/')
562
+ * @param maxResults - How many files to return per page (default: 1000)
563
+ * @param continuationToken - Pass the nextToken from a previous response to get the next page
564
+ *
565
+ * @example
566
+ * // Get all files
567
+ * const result = await storage.listFiles();
568
+ *
569
+ * // Get files in a specific folder
570
+ * const result = await storage.listFiles('users/123/');
571
+ *
572
+ * // Paginate through large results
573
+ * let result = await storage.listFiles(undefined, 100);
574
+ * while (result.nextToken) {
575
+ * result = await storage.listFiles(undefined, 100, result.nextToken);
576
+ * }
577
+ */
578
+ async listFiles(prefix, maxResults, continuationToken) {
579
+ if (prefix && (prefix.includes('..') || prefix.includes('\0'))) {
580
+ return {
581
+ success: false,
582
+ error: 'Invalid prefix: path traversal sequences are not allowed',
583
+ };
584
+ }
585
+ return this.driver.listFiles(prefix, maxResults, continuationToken);
586
+ }
587
+ /**
588
+ * Returns a copy of the current configuration.
589
+ *
590
+ * WARNING: This includes sensitive credentials like AWS keys, Azure connection strings, etc.
591
+ * Use getSafeConfig() instead if you're logging or exposing this to users.
107
592
  */
108
593
  getConfig() {
109
594
  return { ...this.config };
110
595
  }
111
596
  /**
112
- * Get current driver type
597
+ * Returns a copy of the configuration with sensitive values masked.
598
+ * Safe for logging, debugging, or displaying to users.
599
+ *
600
+ * Masked fields: awsAccessKey, awsSecretKey, azureConnectionString,
601
+ * azureAccountKey, gcsCredentials
602
+ */
603
+ getSafeConfig() {
604
+ const masked = '[REDACTED]';
605
+ return {
606
+ ...this.config,
607
+ awsAccessKey: this.config.awsAccessKey ? masked : undefined,
608
+ awsSecretKey: this.config.awsSecretKey ? masked : undefined,
609
+ azureConnectionString: this.config.azureConnectionString ? masked : undefined,
610
+ azureAccountKey: this.config.azureAccountKey ? masked : undefined,
611
+ gcsCredentials: this.config.gcsCredentials ? masked : undefined,
612
+ };
613
+ }
614
+ /**
615
+ * Returns which storage driver is currently active.
113
616
  */
114
617
  getDriverType() {
115
618
  return this.config.driver;
116
619
  }
117
620
  /**
118
- * Check if presigned URLs are supported
621
+ * Returns true if the driver operates in presigned mode.
622
+ *
623
+ * In presigned mode, upload() returns URLs instead of uploading directly.
624
+ * All cloud drivers can generate presigned URLs via generateUploadUrl()
625
+ * regardless of this setting.
119
626
  */
120
- isPresignedSupported() {
627
+ isPresignedUploadMode() {
121
628
  return this.config.driver.includes('-presigned');
122
629
  }
123
630
  /**
124
- * Get available drivers
631
+ * Returns rate limit status information.
632
+ * Returns null if rate limiting is not configured.
633
+ *
634
+ * @example
635
+ * const status = storage.getRateLimitStatus();
636
+ * if (status && status.remainingRequests === 0) {
637
+ * console.log(`Rate limited. Resets in ${status.resetTimeMs}ms`);
638
+ * }
639
+ */
640
+ getRateLimitStatus() {
641
+ if (!this.rateLimiter) {
642
+ return null;
643
+ }
644
+ return {
645
+ remainingRequests: this.rateLimiter.getRemainingRequests(),
646
+ resetTimeMs: this.rateLimiter.getResetTime(),
647
+ };
648
+ }
649
+ /**
650
+ * Returns all available storage drivers.
125
651
  */
126
652
  static getAvailableDrivers() {
127
653
  return StorageDriverFactory.getAvailableDrivers();
128
654
  }
129
655
  /**
130
- * Clear driver cache
656
+ * Clears the internal driver cache.
657
+ * Useful in tests or when you've changed credentials.
131
658
  */
132
659
  static clearCache() {
133
660
  StorageDriverFactory.clearCache();
134
661
  }
135
662
  /**
136
- * Ensure storage manager is initialized
663
+ * Validates a file against the provided rules.
664
+ * Returns an error message if validation fails, null if it passes.
137
665
  */
138
- ensureInitialized() {
139
- if (!this.isInitialized) {
140
- throw new Error('StorageManager is not initialized. Call StorageManager.initialize() first.');
666
+ validateFile(file, options) {
667
+ if (!file) {
668
+ return 'No file provided';
669
+ }
670
+ // Check file size
671
+ if (options.maxSize !== undefined && file.size > options.maxSize) {
672
+ return `File size ${file.size} exceeds maximum allowed size of ${options.maxSize} bytes`;
673
+ }
674
+ // Check MIME type
675
+ if (options.allowedMimeTypes) {
676
+ // Empty array means "allow nothing" - reject all files (consistent with allowedExtensions)
677
+ if (options.allowedMimeTypes.length === 0) {
678
+ return 'No MIME types are allowed (allowedMimeTypes is empty). To allow all types, omit this option or use ["*/*"]';
679
+ }
680
+ // Check for wildcard that allows all types
681
+ const allowsAll = options.allowedMimeTypes.includes('*/*') || options.allowedMimeTypes.includes('*');
682
+ if (!allowsAll && !options.allowedMimeTypes.includes(file.mimetype)) {
683
+ return `File type '${file.mimetype}' is not allowed. Allowed types: ${options.allowedMimeTypes.join(', ')}`;
684
+ }
685
+ }
686
+ // Check file extension
687
+ if (options.allowedExtensions) {
688
+ // Empty array means "allow nothing" - reject all files
689
+ if (options.allowedExtensions.length === 0) {
690
+ return 'No file extensions are allowed (allowedExtensions is empty). To allow all extensions, use ["*"]';
691
+ }
692
+ const ext = getFileExtension(file.originalname || '').toLowerCase();
693
+ const normalizedAllowed = options.allowedExtensions.map(e => e.toLowerCase());
694
+ const SPECIAL_VALUES = ['', '*', 'none'];
695
+ if (ext === '') {
696
+ // File has no extension — check if that's allowed
697
+ const allowsNoExtension = normalizedAllowed.some(e => SPECIAL_VALUES.includes(e));
698
+ if (!allowsNoExtension) {
699
+ return `File has no extension. Allowed extensions: ${options.allowedExtensions.join(', ')} (use '' or '*' to allow files without extensions)`;
700
+ }
701
+ }
702
+ else {
703
+ const normalizedExtensions = normalizedAllowed
704
+ .filter(e => !SPECIAL_VALUES.includes(e))
705
+ .map(e => e.startsWith('.') ? e : `.${e}`);
706
+ const allowsAll = normalizedAllowed.includes('*');
707
+ if (!allowsAll && !normalizedExtensions.includes(ext)) {
708
+ return `File extension '${ext}' is not allowed. Allowed extensions: ${options.allowedExtensions.join(', ')}`;
709
+ }
710
+ }
141
711
  }
712
+ return null;
142
713
  }
143
714
  }
144
715
  //# sourceMappingURL=storage-manager.js.map