express-storage 2.0.3 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +366 -34
- package/dist/cjs/config/index.d.ts +10 -0
- package/dist/cjs/config/index.d.ts.map +1 -0
- package/dist/cjs/config/index.js +19 -0
- package/dist/cjs/config/index.js.map +1 -0
- package/dist/cjs/drivers/azure.driver.d.ts +27 -42
- package/dist/cjs/drivers/azure.driver.d.ts.map +1 -1
- package/dist/cjs/drivers/azure.driver.js +206 -212
- package/dist/cjs/drivers/azure.driver.js.map +1 -1
- package/dist/cjs/drivers/base.driver.d.ts +69 -103
- package/dist/cjs/drivers/base.driver.d.ts.map +1 -1
- package/dist/cjs/drivers/base.driver.js +170 -167
- package/dist/cjs/drivers/base.driver.js.map +1 -1
- package/dist/cjs/drivers/gcs.driver.d.ts +20 -38
- package/dist/cjs/drivers/gcs.driver.d.ts.map +1 -1
- package/dist/cjs/drivers/gcs.driver.js +160 -176
- package/dist/cjs/drivers/gcs.driver.js.map +1 -1
- package/dist/cjs/drivers/index.d.ts +15 -0
- package/dist/cjs/drivers/index.d.ts.map +1 -0
- package/dist/cjs/drivers/index.js +26 -0
- package/dist/cjs/drivers/index.js.map +1 -0
- package/dist/cjs/drivers/local.driver.d.ts +24 -45
- package/dist/cjs/drivers/local.driver.d.ts.map +1 -1
- package/dist/cjs/drivers/local.driver.js +266 -338
- package/dist/cjs/drivers/local.driver.js.map +1 -1
- package/dist/cjs/drivers/s3.driver.d.ts +19 -39
- package/dist/cjs/drivers/s3.driver.d.ts.map +1 -1
- package/dist/cjs/drivers/s3.driver.js +205 -197
- package/dist/cjs/drivers/s3.driver.js.map +1 -1
- package/dist/cjs/factory/driver.factory.d.ts +32 -51
- package/dist/cjs/factory/driver.factory.d.ts.map +1 -1
- package/dist/cjs/factory/driver.factory.js +75 -155
- package/dist/cjs/factory/driver.factory.js.map +1 -1
- package/dist/cjs/index.d.ts +11 -15
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +14 -47
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/storage-manager.d.ts +107 -125
- package/dist/cjs/storage-manager.d.ts.map +1 -1
- package/dist/cjs/storage-manager.js +346 -416
- package/dist/cjs/storage-manager.js.map +1 -1
- package/dist/cjs/types/storage.types.d.ts +250 -107
- package/dist/cjs/types/storage.types.d.ts.map +1 -1
- package/dist/cjs/utils/file.utils.d.ts +62 -8
- package/dist/cjs/utils/file.utils.d.ts.map +1 -1
- package/dist/cjs/utils/file.utils.js +196 -29
- package/dist/cjs/utils/file.utils.js.map +1 -1
- package/dist/cjs/utils/index.d.ts +12 -0
- package/dist/cjs/utils/index.d.ts.map +1 -0
- package/dist/cjs/utils/index.js +36 -0
- package/dist/cjs/utils/index.js.map +1 -0
- package/dist/cjs/utils/rate-limiter.d.ts +40 -0
- package/dist/cjs/utils/rate-limiter.d.ts.map +1 -0
- package/dist/cjs/utils/rate-limiter.js +87 -0
- package/dist/cjs/utils/rate-limiter.js.map +1 -0
- package/dist/esm/config/index.d.ts +10 -0
- package/dist/esm/config/index.d.ts.map +1 -0
- package/dist/esm/config/index.js +10 -0
- package/dist/esm/config/index.js.map +1 -0
- package/dist/esm/drivers/azure.driver.d.ts +27 -42
- package/dist/esm/drivers/azure.driver.d.ts.map +1 -1
- package/dist/esm/drivers/azure.driver.js +172 -210
- package/dist/esm/drivers/azure.driver.js.map +1 -1
- package/dist/esm/drivers/base.driver.d.ts +69 -103
- package/dist/esm/drivers/base.driver.d.ts.map +1 -1
- package/dist/esm/drivers/base.driver.js +171 -168
- package/dist/esm/drivers/base.driver.js.map +1 -1
- package/dist/esm/drivers/gcs.driver.d.ts +20 -38
- package/dist/esm/drivers/gcs.driver.d.ts.map +1 -1
- package/dist/esm/drivers/gcs.driver.js +126 -174
- package/dist/esm/drivers/gcs.driver.js.map +1 -1
- package/dist/esm/drivers/index.d.ts +15 -0
- package/dist/esm/drivers/index.d.ts.map +1 -0
- package/dist/esm/drivers/index.js +15 -0
- package/dist/esm/drivers/index.js.map +1 -0
- package/dist/esm/drivers/local.driver.d.ts +24 -45
- package/dist/esm/drivers/local.driver.d.ts.map +1 -1
- package/dist/esm/drivers/local.driver.js +266 -338
- package/dist/esm/drivers/local.driver.js.map +1 -1
- package/dist/esm/drivers/s3.driver.d.ts +19 -39
- package/dist/esm/drivers/s3.driver.d.ts.map +1 -1
- package/dist/esm/drivers/s3.driver.js +171 -195
- package/dist/esm/drivers/s3.driver.js.map +1 -1
- package/dist/esm/factory/driver.factory.d.ts +32 -51
- package/dist/esm/factory/driver.factory.d.ts.map +1 -1
- package/dist/esm/factory/driver.factory.js +73 -158
- package/dist/esm/factory/driver.factory.js.map +1 -1
- package/dist/esm/index.d.ts +11 -15
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +12 -19
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/storage-manager.d.ts +107 -125
- package/dist/esm/storage-manager.d.ts.map +1 -1
- package/dist/esm/storage-manager.js +348 -418
- package/dist/esm/storage-manager.js.map +1 -1
- package/dist/esm/types/storage.types.d.ts +250 -107
- package/dist/esm/types/storage.types.d.ts.map +1 -1
- package/dist/esm/utils/file.utils.d.ts +62 -8
- package/dist/esm/utils/file.utils.d.ts.map +1 -1
- package/dist/esm/utils/file.utils.js +190 -29
- package/dist/esm/utils/file.utils.js.map +1 -1
- package/dist/esm/utils/index.d.ts +12 -0
- package/dist/esm/utils/index.d.ts.map +1 -0
- package/dist/esm/utils/index.js +11 -0
- package/dist/esm/utils/index.js.map +1 -0
- package/dist/esm/utils/rate-limiter.d.ts +40 -0
- package/dist/esm/utils/rate-limiter.d.ts.map +1 -0
- package/dist/esm/utils/rate-limiter.js +82 -0
- package/dist/esm/utils/rate-limiter.js.map +1 -0
- package/package.json +83 -48
- package/src/config/index.ts +17 -0
- package/src/drivers/azure.driver.ts +434 -0
- package/src/drivers/base.driver.ts +436 -0
- package/src/drivers/gcs.driver.ts +366 -0
- package/src/drivers/index.ts +15 -0
- package/src/drivers/local.driver.ts +626 -0
- package/src/drivers/s3.driver.ts +459 -0
- package/src/factory/driver.factory.ts +101 -0
- package/src/index.ts +72 -0
- package/src/storage-manager.ts +801 -0
- package/src/types/storage.types.ts +561 -0
- package/src/utils/config.utils.ts +229 -0
- package/src/utils/file.utils.ts +536 -0
- package/src/utils/index.ts +35 -0
- package/src/utils/rate-limiter.ts +94 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fsPromises from 'fs/promises';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import type { StorageErrorCode } from '../types/storage.types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a unique filename that won't collide with existing files.
|
|
8
|
+
*
|
|
9
|
+
* Format: {timestamp}_{random}_{sanitized_name}.{extension}
|
|
10
|
+
* Example: 1769104576000_a1b2c3d4e5_my_image.jpeg
|
|
11
|
+
*
|
|
12
|
+
* The random part uses crypto.randomBytes() for extra collision resistance
|
|
13
|
+
* in high-throughput scenarios.
|
|
14
|
+
*/
|
|
15
|
+
export function generateUniqueFileName(originalName: string): string {
|
|
16
|
+
const timestamp = Date.now();
|
|
17
|
+
const randomSuffix = crypto.randomBytes(5).toString('hex');
|
|
18
|
+
|
|
19
|
+
// Handle dotfiles like .gitignore or .env (they have no extension)
|
|
20
|
+
let extension: string;
|
|
21
|
+
let baseName: string;
|
|
22
|
+
|
|
23
|
+
if (originalName.startsWith('.') && !originalName.slice(1).includes('.')) {
|
|
24
|
+
extension = '';
|
|
25
|
+
baseName = sanitizeFileName(originalName);
|
|
26
|
+
} else {
|
|
27
|
+
extension = path.extname(originalName).toLowerCase();
|
|
28
|
+
const sanitizedName = sanitizeFileName(originalName);
|
|
29
|
+
baseName = path.basename(sanitizedName, path.extname(sanitizedName));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!baseName || baseName.trim() === '') {
|
|
33
|
+
baseName = 'file';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return `${timestamp}_${randomSuffix}_${baseName}${extension}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Makes a filename safe for storage by removing problematic characters.
|
|
41
|
+
*
|
|
42
|
+
* Replaces anything that isn't alphanumeric, a dot, or a hyphen with underscores.
|
|
43
|
+
* This ensures compatibility with all filesystems and cloud storage providers.
|
|
44
|
+
*
|
|
45
|
+
* Note: Unicode characters like Chinese or emojis become underscores.
|
|
46
|
+
* If you need to preserve these, consider using your own sanitization function.
|
|
47
|
+
*/
|
|
48
|
+
export function sanitizeFileName(fileName: string): string {
|
|
49
|
+
const sanitized = fileName
|
|
50
|
+
.normalize('NFC')
|
|
51
|
+
.replace(/[^a-zA-Z0-9.-]/g, '_')
|
|
52
|
+
.replace(/_{2,}/g, '_')
|
|
53
|
+
.replace(/^_+|_+$/g, '');
|
|
54
|
+
|
|
55
|
+
return sanitized || 'file';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Checks if a filename is safe to use.
|
|
60
|
+
*
|
|
61
|
+
* Rejects:
|
|
62
|
+
* - Empty filenames
|
|
63
|
+
* - Filenames over 255 characters
|
|
64
|
+
* - Path traversal attempts (../, /, \)
|
|
65
|
+
* - Null bytes
|
|
66
|
+
*
|
|
67
|
+
* Returns an error message if invalid, null if OK.
|
|
68
|
+
*/
|
|
69
|
+
export function validateFileName(fileName: string): string | null {
|
|
70
|
+
if (!fileName || typeof fileName !== 'string') {
|
|
71
|
+
return 'Filename is required';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const trimmed = fileName.trim();
|
|
75
|
+
if (trimmed.length === 0) {
|
|
76
|
+
return 'Filename cannot be empty';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (trimmed.length > 255) {
|
|
80
|
+
return 'Filename is too long (max 255 characters)';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
|
|
84
|
+
return 'Filename cannot contain path separators or traversal sequences';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (trimmed.includes('\0')) {
|
|
88
|
+
return 'Filename cannot contain null bytes';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns true if the value contains path traversal sequences (`..`) or null bytes.
|
|
96
|
+
* Checks both the raw value and its URL-decoded form to catch encoded attacks
|
|
97
|
+
* like `%2e%2e/etc/passwd`.
|
|
98
|
+
*/
|
|
99
|
+
export function hasPathTraversal(value: string): boolean {
|
|
100
|
+
if (value.includes('..') || value.includes('\0')) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const decoded = decodeURIComponent(value);
|
|
105
|
+
return decoded.includes('..') || decoded.includes('\0');
|
|
106
|
+
} catch {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* URL-encodes each segment of a `/`-separated path individually.
|
|
113
|
+
* Preserves the `/` separators while encoding special characters within segments.
|
|
114
|
+
*/
|
|
115
|
+
export function encodePathSegments(filePath: string): string {
|
|
116
|
+
return filePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a date-based folder path: YYYY/MM
|
|
121
|
+
*
|
|
122
|
+
* Uses UTC to keep things consistent across timezones.
|
|
123
|
+
* Example: For January 2026 -> 'uploads/2026/01'
|
|
124
|
+
*/
|
|
125
|
+
export function createMonthBasedPath(basePath: string): string {
|
|
126
|
+
const now = new Date();
|
|
127
|
+
const year = now.getUTCFullYear();
|
|
128
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
129
|
+
|
|
130
|
+
return path.join(basePath, year.toString(), month);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates a directory if it doesn't exist.
|
|
135
|
+
* Also creates any parent directories needed (recursive).
|
|
136
|
+
*/
|
|
137
|
+
export async function ensureDirectoryExists(dirPath: string): Promise<void> {
|
|
138
|
+
await fsPromises.mkdir(dirPath, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Converts bytes to a human-readable string.
|
|
143
|
+
*
|
|
144
|
+
* Examples:
|
|
145
|
+
* - 1024 -> "1 KB"
|
|
146
|
+
* - 1048576 -> "1 MB"
|
|
147
|
+
* - 0 -> "0 Bytes"
|
|
148
|
+
*/
|
|
149
|
+
export function formatFileSize(bytes: number): string {
|
|
150
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
151
|
+
|
|
152
|
+
if (typeof bytes !== 'number' || Number.isNaN(bytes)) {
|
|
153
|
+
return 'Invalid size';
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isFinite(bytes)) {
|
|
156
|
+
return bytes > 0 ? 'Infinite' : 'Invalid size';
|
|
157
|
+
}
|
|
158
|
+
if (bytes < 0) {
|
|
159
|
+
return 'Invalid size (negative)';
|
|
160
|
+
}
|
|
161
|
+
if (bytes === 0) {
|
|
162
|
+
return '0 Bytes';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let i = 0;
|
|
166
|
+
let size = bytes;
|
|
167
|
+
while (size >= 1024 && i < sizes.length - 1) {
|
|
168
|
+
size /= 1024;
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Math.round(size * 100) / 100 + ' ' + sizes[i];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Checks if a file size is within the allowed limit.
|
|
177
|
+
*/
|
|
178
|
+
export function validateFileSize(fileSize: number, maxSize: number): boolean {
|
|
179
|
+
return fileSize <= maxSize;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Checks if a MIME type is in the allowed list.
|
|
184
|
+
*/
|
|
185
|
+
export function validateFileType(mimeType: string, allowedTypes: string[]): boolean {
|
|
186
|
+
return allowedTypes.includes(mimeType);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extracts the file extension (lowercase, includes the dot).
|
|
191
|
+
*
|
|
192
|
+
* Examples:
|
|
193
|
+
* - 'photo.jpg' -> '.jpg'
|
|
194
|
+
* - '.gitignore' -> '' (dotfiles have no extension)
|
|
195
|
+
* - 'archive.tar.gz' -> '.gz' (only the last extension)
|
|
196
|
+
*/
|
|
197
|
+
export function getFileExtension(fileName: string): string {
|
|
198
|
+
if (!fileName) return '';
|
|
199
|
+
|
|
200
|
+
// Dotfiles like .gitignore don't have extensions
|
|
201
|
+
if (fileName.startsWith('.') && !fileName.slice(1).includes('.')) {
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return path.extname(fileName).toLowerCase();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Checks if a MIME type indicates an image.
|
|
210
|
+
*/
|
|
211
|
+
export function isImageFile(mimeType: string): boolean {
|
|
212
|
+
return mimeType.startsWith('image/');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Checks if a MIME type indicates a document (PDF, Word, Excel, etc.).
|
|
217
|
+
*/
|
|
218
|
+
export function isDocumentFile(mimeType: string): boolean {
|
|
219
|
+
const documentTypes = [
|
|
220
|
+
'application/pdf',
|
|
221
|
+
'application/msword',
|
|
222
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
223
|
+
'application/vnd.ms-excel',
|
|
224
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
225
|
+
'text/plain',
|
|
226
|
+
'text/csv'
|
|
227
|
+
];
|
|
228
|
+
return documentTypes.includes(mimeType);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Configuration for retry behavior.
|
|
233
|
+
*/
|
|
234
|
+
export interface RetryOptions {
|
|
235
|
+
/** Total attempts including the first one. Default: 3 */
|
|
236
|
+
maxAttempts?: number;
|
|
237
|
+
/** Starting delay between retries in ms. Default: 1000 */
|
|
238
|
+
baseDelay?: number;
|
|
239
|
+
/** Maximum delay between retries in ms. Default: 10000 */
|
|
240
|
+
maxDelay?: number;
|
|
241
|
+
/** Use exponential backoff. Default: true */
|
|
242
|
+
exponentialBackoff?: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Retries an async operation with exponential backoff.
|
|
247
|
+
*
|
|
248
|
+
* Great for cloud operations that might fail due to network blips
|
|
249
|
+
* or rate limiting.
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* // Retry up to 3 times with increasing delays
|
|
253
|
+
* const result = await withRetry(() => storage.uploadFile(file));
|
|
254
|
+
*
|
|
255
|
+
* // More aggressive retry strategy
|
|
256
|
+
* const result = await withRetry(() => fetchData(), {
|
|
257
|
+
* maxAttempts: 5,
|
|
258
|
+
* baseDelay: 500
|
|
259
|
+
* });
|
|
260
|
+
*/
|
|
261
|
+
export async function withRetry<T>(
|
|
262
|
+
operation: () => Promise<T>,
|
|
263
|
+
options: RetryOptions = {}
|
|
264
|
+
): Promise<T> {
|
|
265
|
+
const {
|
|
266
|
+
maxAttempts = 3,
|
|
267
|
+
baseDelay = 1000,
|
|
268
|
+
maxDelay = 10000,
|
|
269
|
+
exponentialBackoff = true,
|
|
270
|
+
} = options;
|
|
271
|
+
|
|
272
|
+
let lastError: Error | undefined;
|
|
273
|
+
|
|
274
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
275
|
+
try {
|
|
276
|
+
return await operation();
|
|
277
|
+
} catch (error) {
|
|
278
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
279
|
+
|
|
280
|
+
if (attempt < maxAttempts) {
|
|
281
|
+
const delay = exponentialBackoff
|
|
282
|
+
? Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay)
|
|
283
|
+
: baseDelay;
|
|
284
|
+
|
|
285
|
+
await sleep(delay);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
throw lastError || new Error(`Operation failed after ${maxAttempts} attempts`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Returns true if the string is a valid MIME type format (type/subtype).
|
|
295
|
+
*/
|
|
296
|
+
export function isValidMimeType(mimeType: string): boolean {
|
|
297
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/.test(mimeType);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Validates a folder path for use in storage operations.
|
|
302
|
+
* Returns an error message if invalid, null if OK.
|
|
303
|
+
*/
|
|
304
|
+
export function validateFolderPath(folder: string): string | null {
|
|
305
|
+
if (hasPathTraversal(folder)) {
|
|
306
|
+
return folder.includes('..')
|
|
307
|
+
? 'Folder path cannot contain path traversal sequences (..)'
|
|
308
|
+
: 'Folder path cannot contain null bytes';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (/[<>:"|?*\\;$`']/.test(folder)) {
|
|
312
|
+
return "Folder path contains invalid characters. Avoid: < > : \" | ? * \\ ; $ ` '";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (/\/{2,}/.test(folder)) {
|
|
316
|
+
return 'Folder path cannot contain consecutive slashes';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Validates a file against upload constraints (size, MIME type, extension).
|
|
324
|
+
* Returns `{ error, code }` on failure, `null` if the file passes all checks.
|
|
325
|
+
*/
|
|
326
|
+
export function validateFileForUpload(
|
|
327
|
+
file: Express.Multer.File,
|
|
328
|
+
options: { maxSize?: number; allowedMimeTypes?: string[]; allowedExtensions?: string[] }
|
|
329
|
+
): { error: string; code: StorageErrorCode } | null {
|
|
330
|
+
if (!file) {
|
|
331
|
+
return { error: 'No file provided', code: 'NO_FILE' };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (options.maxSize !== undefined && file.size > options.maxSize) {
|
|
335
|
+
return { error: `File size ${file.size} exceeds maximum allowed size of ${options.maxSize} bytes`, code: 'FILE_TOO_LARGE' };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (options.allowedMimeTypes) {
|
|
339
|
+
if (options.allowedMimeTypes.length === 0) {
|
|
340
|
+
return { error: 'No MIME types are allowed (allowedMimeTypes is empty). To allow all types, omit this option or use ["*/*"]', code: 'INVALID_MIME_TYPE' };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const allowsAll = options.allowedMimeTypes.includes('*/*') || options.allowedMimeTypes.includes('*');
|
|
344
|
+
|
|
345
|
+
if (!allowsAll && !options.allowedMimeTypes.includes(file.mimetype)) {
|
|
346
|
+
return { error: `File type '${file.mimetype}' is not allowed. Allowed types: ${options.allowedMimeTypes.join(', ')}`, code: 'INVALID_MIME_TYPE' };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (options.allowedExtensions) {
|
|
351
|
+
if (options.allowedExtensions.length === 0) {
|
|
352
|
+
return { error: 'No file extensions are allowed (allowedExtensions is empty). To allow all extensions, use ["*"]', code: 'INVALID_EXTENSION' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const ext = getFileExtension(file.originalname || '').toLowerCase();
|
|
356
|
+
const normalizedAllowed = options.allowedExtensions.map(e => e.toLowerCase());
|
|
357
|
+
const SPECIAL_VALUES = ['', '*', 'none'];
|
|
358
|
+
|
|
359
|
+
if (ext === '') {
|
|
360
|
+
const allowsNoExtension = normalizedAllowed.some(e => SPECIAL_VALUES.includes(e));
|
|
361
|
+
if (!allowsNoExtension) {
|
|
362
|
+
return { error: `File has no extension. Allowed extensions: ${options.allowedExtensions.join(', ')} (use '' or '*' to allow files without extensions)`, code: 'INVALID_EXTENSION' };
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
const normalizedExtensions = normalizedAllowed
|
|
366
|
+
.filter(e => !SPECIAL_VALUES.includes(e))
|
|
367
|
+
.map(e => e.startsWith('.') ? e : `.${e}`);
|
|
368
|
+
const allowsAllExt = normalizedAllowed.includes('*');
|
|
369
|
+
|
|
370
|
+
if (!allowsAllExt && !normalizedExtensions.includes(ext)) {
|
|
371
|
+
return { error: `File extension '${ext}' is not allowed. Allowed extensions: ${options.allowedExtensions.join(', ')}`, code: 'INVALID_EXTENSION' };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// MIME type detection from file content (magic bytes)
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
const MAGIC_SIGNATURES: Array<{ bytes: number[]; mimeType: string; offset?: number }> = [
|
|
384
|
+
{ bytes: [0xFF, 0xD8, 0xFF], mimeType: 'image/jpeg' },
|
|
385
|
+
{ bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], mimeType: 'image/png' },
|
|
386
|
+
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], mimeType: 'image/gif' },
|
|
387
|
+
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], mimeType: 'image/gif' },
|
|
388
|
+
{ bytes: [0x42, 0x4D], mimeType: 'image/bmp' },
|
|
389
|
+
{ bytes: [0x25, 0x50, 0x44, 0x46], mimeType: 'application/pdf' },
|
|
390
|
+
{ bytes: [0x50, 0x4B, 0x03, 0x04], mimeType: 'application/zip' },
|
|
391
|
+
{ bytes: [0x50, 0x4B, 0x05, 0x06], mimeType: 'application/zip' },
|
|
392
|
+
{ bytes: [0x50, 0x4B, 0x07, 0x08], mimeType: 'application/zip' },
|
|
393
|
+
{ bytes: [0x1F, 0x8B], mimeType: 'application/gzip' },
|
|
394
|
+
{ bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], mimeType: 'application/vnd.rar' },
|
|
395
|
+
{ bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], mimeType: 'application/x-7z-compressed' },
|
|
396
|
+
{ bytes: [0x49, 0x44, 0x33], mimeType: 'audio/mpeg' },
|
|
397
|
+
{ bytes: [0xFF, 0xFB], mimeType: 'audio/mpeg' },
|
|
398
|
+
{ bytes: [0xFF, 0xFA], mimeType: 'audio/mpeg' },
|
|
399
|
+
{ bytes: [0x4F, 0x67, 0x67, 0x53], mimeType: 'audio/ogg' },
|
|
400
|
+
{ bytes: [0x66, 0x74, 0x79, 0x70], mimeType: 'video/mp4', offset: 4 },
|
|
401
|
+
{ bytes: [0x4D, 0x5A], mimeType: 'application/x-msdownload' },
|
|
402
|
+
{ bytes: [0x7F, 0x45, 0x4C, 0x46], mimeType: 'application/x-executable' },
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Detects MIME type from file content by examining magic bytes.
|
|
407
|
+
*
|
|
408
|
+
* Useful in `beforeUpload` hooks to verify that a file's actual content
|
|
409
|
+
* matches its declared MIME type — particularly for cloud uploads where
|
|
410
|
+
* the driver trusts the client-provided MIME type.
|
|
411
|
+
*
|
|
412
|
+
* @param data - Buffer containing at least the first 12 bytes of the file
|
|
413
|
+
* @returns Detected MIME type, or undefined if unknown
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* const storage = new StorageManager({
|
|
417
|
+
* hooks: {
|
|
418
|
+
* beforeUpload: async (file) => {
|
|
419
|
+
* const actual = detectMimeType(file.buffer);
|
|
420
|
+
* if (actual && actual !== file.mimetype) {
|
|
421
|
+
* throw new Error(`Content mismatch: declared ${file.mimetype}, detected ${actual}`);
|
|
422
|
+
* }
|
|
423
|
+
* },
|
|
424
|
+
* },
|
|
425
|
+
* });
|
|
426
|
+
*/
|
|
427
|
+
export function detectMimeType(data: Buffer): string | undefined {
|
|
428
|
+
if (!data || data.length === 0) return undefined;
|
|
429
|
+
|
|
430
|
+
for (const sig of MAGIC_SIGNATURES) {
|
|
431
|
+
const offset = sig.offset || 0;
|
|
432
|
+
if (offset + sig.bytes.length > data.length) continue;
|
|
433
|
+
|
|
434
|
+
let match = true;
|
|
435
|
+
for (let i = 0; i < sig.bytes.length; i++) {
|
|
436
|
+
if (data[offset + i] !== sig.bytes[i]) {
|
|
437
|
+
match = false;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (match) return sig.mimeType;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// RIFF container: bytes 0-3 = 'RIFF', bytes 8-11 = sub-format
|
|
445
|
+
if (data.length >= 12 &&
|
|
446
|
+
data[0] === 0x52 && data[1] === 0x49 &&
|
|
447
|
+
data[2] === 0x46 && data[3] === 0x46) {
|
|
448
|
+
const sub = data.subarray(8, 12).toString('ascii');
|
|
449
|
+
switch (sub) {
|
|
450
|
+
case 'WEBP': return 'image/webp';
|
|
451
|
+
case 'WAVE': return 'audio/wav';
|
|
452
|
+
case 'AVI ': return 'video/x-msvideo';
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Pauses execution for the specified number of milliseconds.
|
|
461
|
+
*/
|
|
462
|
+
export function sleep(ms: number): Promise<void> {
|
|
463
|
+
return new Promise(resolve => globalThis.setTimeout(resolve, ms));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Configuration for concurrent execution.
|
|
468
|
+
*/
|
|
469
|
+
export interface ConcurrencyOptions {
|
|
470
|
+
/** Maximum parallel operations. Default: 10 */
|
|
471
|
+
maxConcurrent?: number;
|
|
472
|
+
/** Pass an AbortSignal to cancel remaining work mid-flight. */
|
|
473
|
+
signal?: AbortSignal | undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Processes an array with a concurrency limit.
|
|
478
|
+
*
|
|
479
|
+
* Prevents overwhelming APIs or running out of resources by limiting
|
|
480
|
+
* how many operations run at once.
|
|
481
|
+
*
|
|
482
|
+
* Uses a shared-index work-stealing approach: workers pull the next
|
|
483
|
+
* available item as soon as they finish one, ensuring even load
|
|
484
|
+
* distribution regardless of per-item processing time.
|
|
485
|
+
*
|
|
486
|
+
* @example
|
|
487
|
+
* const results = await withConcurrencyLimit(
|
|
488
|
+
* files,
|
|
489
|
+
* (file) => uploadFile(file),
|
|
490
|
+
* { maxConcurrent: 10 }
|
|
491
|
+
* );
|
|
492
|
+
*/
|
|
493
|
+
export async function withConcurrencyLimit<T, R>(
|
|
494
|
+
items: T[],
|
|
495
|
+
operation: (item: T, index: number) => Promise<R>,
|
|
496
|
+
options: ConcurrencyOptions = {}
|
|
497
|
+
): Promise<R[]> {
|
|
498
|
+
const { maxConcurrent = 10, signal } = options;
|
|
499
|
+
|
|
500
|
+
if (items.length === 0) {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
signal?.throwIfAborted();
|
|
505
|
+
|
|
506
|
+
const itemsCopy = [...items];
|
|
507
|
+
const itemCount = itemsCopy.length;
|
|
508
|
+
|
|
509
|
+
if (itemCount <= maxConcurrent) {
|
|
510
|
+
return Promise.all(itemsCopy.map((item, index) => operation(item, index)));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const results: R[] = new Array(itemCount);
|
|
514
|
+
const workerCount = Math.min(maxConcurrent, itemCount);
|
|
515
|
+
let nextIndex = 0;
|
|
516
|
+
|
|
517
|
+
const createWorker = async (): Promise<void> => {
|
|
518
|
+
while (nextIndex < itemCount) {
|
|
519
|
+
signal?.throwIfAborted();
|
|
520
|
+
const index = nextIndex++;
|
|
521
|
+
if (index >= itemCount) break;
|
|
522
|
+
const item = itemsCopy[index];
|
|
523
|
+
if (item !== undefined) {
|
|
524
|
+
results[index] = await operation(item, index);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const workers: Promise<void>[] = [];
|
|
530
|
+
for (let i = 0; i < workerCount; i++) {
|
|
531
|
+
workers.push(createWorker());
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await Promise.all(workers);
|
|
535
|
+
return results;
|
|
536
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* express-storage/utils
|
|
3
|
+
*
|
|
4
|
+
* Standalone utility functions for file handling, retries, and concurrency.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { withRetry, formatFileSize, withConcurrencyLimit } from 'express-storage/utils';
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
generateUniqueFileName,
|
|
12
|
+
sanitizeFileName,
|
|
13
|
+
validateFileName,
|
|
14
|
+
hasPathTraversal,
|
|
15
|
+
encodePathSegments,
|
|
16
|
+
isValidMimeType,
|
|
17
|
+
validateFolderPath,
|
|
18
|
+
validateFileForUpload,
|
|
19
|
+
createMonthBasedPath,
|
|
20
|
+
ensureDirectoryExists,
|
|
21
|
+
formatFileSize,
|
|
22
|
+
validateFileSize,
|
|
23
|
+
validateFileType,
|
|
24
|
+
getFileExtension,
|
|
25
|
+
isImageFile,
|
|
26
|
+
isDocumentFile,
|
|
27
|
+
detectMimeType,
|
|
28
|
+
withRetry,
|
|
29
|
+
sleep,
|
|
30
|
+
withConcurrencyLimit,
|
|
31
|
+
} from './file.utils.js';
|
|
32
|
+
|
|
33
|
+
export type { RetryOptions, ConcurrencyOptions } from './file.utils.js';
|
|
34
|
+
|
|
35
|
+
export { InMemoryRateLimiter, isRateLimiterAdapter } from './rate-limiter.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { RateLimiterAdapter, RateLimitOptions } from '../types/storage.types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* O(1) sliding window counter rate limiter for presigned URL generation.
|
|
5
|
+
*
|
|
6
|
+
* Uses a two-bucket sliding window algorithm: tracks request counts for the
|
|
7
|
+
* current and previous windows, then estimates the effective count with a
|
|
8
|
+
* time-weighted blend. This provides smooth rate limiting without storing
|
|
9
|
+
* individual timestamps.
|
|
10
|
+
*
|
|
11
|
+
* All operations are O(1) time and O(1) space regardless of request volume.
|
|
12
|
+
*
|
|
13
|
+
* Suitable for single-process applications. For clustered/multi-process
|
|
14
|
+
* deployments, implement `RateLimiterAdapter` backed by a shared store.
|
|
15
|
+
*/
|
|
16
|
+
export class InMemoryRateLimiter implements RateLimiterAdapter {
|
|
17
|
+
private maxRequests: number;
|
|
18
|
+
private windowMs: number;
|
|
19
|
+
private currentCount: number = 0;
|
|
20
|
+
private previousCount: number = 0;
|
|
21
|
+
private windowStart: number;
|
|
22
|
+
|
|
23
|
+
constructor(options: RateLimitOptions) {
|
|
24
|
+
this.maxRequests = options.maxRequests;
|
|
25
|
+
this.windowMs = options.windowMs || 60000;
|
|
26
|
+
this.windowStart = Date.now();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rotates the window buckets if the current window has elapsed.
|
|
31
|
+
*/
|
|
32
|
+
private slide(): void {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const elapsed = now - this.windowStart;
|
|
35
|
+
|
|
36
|
+
if (elapsed >= this.windowMs * 2) {
|
|
37
|
+
this.previousCount = 0;
|
|
38
|
+
this.currentCount = 0;
|
|
39
|
+
this.windowStart = now;
|
|
40
|
+
} else if (elapsed >= this.windowMs) {
|
|
41
|
+
this.previousCount = this.currentCount;
|
|
42
|
+
this.currentCount = 0;
|
|
43
|
+
this.windowStart += this.windowMs;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the estimated request count across the sliding window,
|
|
49
|
+
* blending the previous window's count proportionally with elapsed time.
|
|
50
|
+
*/
|
|
51
|
+
private getEstimatedCount(): number {
|
|
52
|
+
const elapsed = Date.now() - this.windowStart;
|
|
53
|
+
const weight = Math.max(0, (this.windowMs - elapsed) / this.windowMs);
|
|
54
|
+
return this.previousCount * weight + this.currentCount;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
tryAcquire(): boolean {
|
|
58
|
+
this.slide();
|
|
59
|
+
|
|
60
|
+
if (this.getEstimatedCount() >= this.maxRequests) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.currentCount++;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getRemainingRequests(): number {
|
|
69
|
+
this.slide();
|
|
70
|
+
return Math.max(0, Math.floor(this.maxRequests - this.getEstimatedCount()));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getResetTime(): number {
|
|
74
|
+
this.slide();
|
|
75
|
+
if (this.currentCount === 0 && this.previousCount === 0) {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
const elapsed = Date.now() - this.windowStart;
|
|
79
|
+
return Math.max(0, this.windowMs - elapsed);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns true if the value is a RateLimiterAdapter (has the required methods),
|
|
85
|
+
* as opposed to plain RateLimitOptions.
|
|
86
|
+
*/
|
|
87
|
+
export function isRateLimiterAdapter(value: unknown): value is RateLimiterAdapter {
|
|
88
|
+
return (
|
|
89
|
+
typeof value === 'object' &&
|
|
90
|
+
value !== null &&
|
|
91
|
+
'tryAcquire' in value &&
|
|
92
|
+
typeof (value as RateLimiterAdapter).tryAcquire === 'function'
|
|
93
|
+
);
|
|
94
|
+
}
|