express-storage 1.0.0 → 1.1.2
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 +519 -348
- package/dist/drivers/azure.driver.d.ts +88 -0
- package/dist/drivers/azure.driver.d.ts.map +1 -0
- package/dist/drivers/azure.driver.js +367 -0
- package/dist/drivers/azure.driver.js.map +1 -0
- package/dist/drivers/base.driver.d.ts +125 -24
- package/dist/drivers/base.driver.d.ts.map +1 -1
- package/dist/drivers/base.driver.js +248 -62
- package/dist/drivers/base.driver.js.map +1 -1
- package/dist/drivers/gcs.driver.d.ts +60 -13
- package/dist/drivers/gcs.driver.d.ts.map +1 -1
- package/dist/drivers/gcs.driver.js +242 -41
- package/dist/drivers/gcs.driver.js.map +1 -1
- package/dist/drivers/local.driver.d.ts +89 -12
- package/dist/drivers/local.driver.d.ts.map +1 -1
- package/dist/drivers/local.driver.js +533 -45
- package/dist/drivers/local.driver.js.map +1 -1
- package/dist/drivers/s3.driver.d.ts +64 -13
- package/dist/drivers/s3.driver.d.ts.map +1 -1
- package/dist/drivers/s3.driver.js +269 -41
- package/dist/drivers/s3.driver.js.map +1 -1
- package/dist/factory/driver.factory.d.ts +35 -29
- package/dist/factory/driver.factory.d.ts.map +1 -1
- package/dist/factory/driver.factory.js +119 -59
- package/dist/factory/driver.factory.js.map +1 -1
- package/dist/index.d.ts +23 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -46
- package/dist/index.js.map +1 -1
- package/dist/storage-manager.d.ts +205 -52
- package/dist/storage-manager.d.ts.map +1 -1
- package/dist/storage-manager.js +644 -73
- package/dist/storage-manager.js.map +1 -1
- package/dist/types/storage.types.d.ts +243 -18
- package/dist/types/storage.types.d.ts.map +1 -1
- package/dist/utils/config.utils.d.ts +28 -4
- package/dist/utils/config.utils.d.ts.map +1 -1
- package/dist/utils/config.utils.js +121 -47
- package/dist/utils/config.utils.js.map +1 -1
- package/dist/utils/file.utils.d.ts +111 -14
- package/dist/utils/file.utils.d.ts.map +1 -1
- package/dist/utils/file.utils.js +215 -32
- package/dist/utils/file.utils.js.map +1 -1
- package/package.json +51 -27
- package/dist/drivers/oci.driver.d.ts +0 -37
- package/dist/drivers/oci.driver.d.ts.map +0 -1
- package/dist/drivers/oci.driver.js +0 -84
- package/dist/drivers/oci.driver.js.map +0 -1
package/dist/storage-manager.js
CHANGED
|
@@ -1,144 +1,715 @@
|
|
|
1
1
|
import { StorageDriverFactory } from './factory/driver.factory.js';
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
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.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if
|
|
12
|
-
|
|
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.
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
*
|
|
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.
|
|
226
|
+
return this.uploadFile(input.file, validation, uploadOptions);
|
|
58
227
|
}
|
|
59
228
|
else {
|
|
60
|
-
return this.
|
|
229
|
+
return this.uploadFiles(input.files, validation, uploadOptions);
|
|
61
230
|
}
|
|
62
231
|
}
|
|
63
232
|
/**
|
|
64
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
*
|
|
328
|
+
* Combines folder and filename into a full path.
|
|
329
|
+
* Handles edge cases like leading/trailing slashes.
|
|
72
330
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
342
|
+
* Checks folder paths for security issues.
|
|
343
|
+
* Blocks path traversal attempts and other sneaky tricks.
|
|
79
344
|
*/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
*
|
|
362
|
+
* Checks if a string looks like a valid MIME type.
|
|
86
363
|
*/
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return
|
|
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
|
-
*
|
|
369
|
+
* Converts bytes to a human-readable string like "5.2 MB".
|
|
93
370
|
*/
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return this.driver.delete(fileName);
|
|
371
|
+
formatBytes(bytes) {
|
|
372
|
+
return formatFileSize(bytes);
|
|
97
373
|
}
|
|
98
374
|
/**
|
|
99
|
-
*
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
627
|
+
isPresignedUploadMode() {
|
|
121
628
|
return this.config.driver.includes('-presigned');
|
|
122
629
|
}
|
|
123
630
|
/**
|
|
124
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
663
|
+
* Validates a file against the provided rules.
|
|
664
|
+
* Returns an error message if validation fails, null if it passes.
|
|
137
665
|
*/
|
|
138
|
-
|
|
139
|
-
if (!
|
|
140
|
-
|
|
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
|