@od-oneapp/storage 2026.1.1301
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 +854 -0
- package/dist/client-next.d.mts +61 -0
- package/dist/client-next.d.mts.map +1 -0
- package/dist/client-next.mjs +111 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client-utils-Dx6W25iz.d.mts +43 -0
- package/dist/client-utils-Dx6W25iz.d.mts.map +1 -0
- package/dist/client.d.mts +28 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +183 -0
- package/dist/client.mjs.map +1 -0
- package/dist/env-BVHLmQdh.mjs +128 -0
- package/dist/env-BVHLmQdh.mjs.map +1 -0
- package/dist/env.mjs +3 -0
- package/dist/health-check-D7LnnDec.mjs +746 -0
- package/dist/health-check-D7LnnDec.mjs.map +1 -0
- package/dist/health-check-im_huJ59.d.mts +116 -0
- package/dist/health-check-im_huJ59.d.mts.map +1 -0
- package/dist/index.d.mts +60 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/keys.d.mts +37 -0
- package/dist/keys.d.mts.map +1 -0
- package/dist/keys.mjs +253 -0
- package/dist/keys.mjs.map +1 -0
- package/dist/server-edge.d.mts +28 -0
- package/dist/server-edge.d.mts.map +1 -0
- package/dist/server-edge.mjs +88 -0
- package/dist/server-edge.mjs.map +1 -0
- package/dist/server-next.d.mts +183 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +1353 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +70 -0
- package/dist/server.d.mts.map +1 -0
- package/dist/server.mjs +384 -0
- package/dist/server.mjs.map +1 -0
- package/dist/types.d.mts +321 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +3 -0
- package/dist/validation.d.mts +101 -0
- package/dist/validation.d.mts.map +1 -0
- package/dist/validation.mjs +590 -0
- package/dist/validation.mjs.map +1 -0
- package/dist/vercel-blob-07Sx0Akn.d.mts +31 -0
- package/dist/vercel-blob-07Sx0Akn.d.mts.map +1 -0
- package/dist/vercel-blob-DA8HaYuw.mjs +158 -0
- package/dist/vercel-blob-DA8HaYuw.mjs.map +1 -0
- package/package.json +111 -0
- package/src/actions/blob-upload.ts +171 -0
- package/src/actions/index.ts +23 -0
- package/src/actions/mediaActions.ts +1071 -0
- package/src/actions/productMediaActions.ts +538 -0
- package/src/auth-helpers.ts +386 -0
- package/src/capabilities.ts +225 -0
- package/src/client-next.ts +184 -0
- package/src/client-utils.ts +292 -0
- package/src/client.ts +102 -0
- package/src/constants.ts +88 -0
- package/src/health-check.ts +81 -0
- package/src/multi-storage.ts +230 -0
- package/src/multipart.ts +497 -0
- package/src/retry-utils.test.ts +118 -0
- package/src/retry-utils.ts +59 -0
- package/src/server-edge.ts +129 -0
- package/src/server-next.ts +14 -0
- package/src/server.ts +666 -0
- package/src/validation.test.ts +312 -0
- package/src/validation.ts +827 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Storage Validation and Error Handling
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive validation utilities and error types for storage operations.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - File size validation
|
|
8
|
+
* - MIME type validation
|
|
9
|
+
* - Storage key validation
|
|
10
|
+
* - Error types and handling
|
|
11
|
+
* - SSRF protection
|
|
12
|
+
*
|
|
13
|
+
* @module @repo/storage/validation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Storage error codes
|
|
18
|
+
*/
|
|
19
|
+
export enum StorageErrorCode {
|
|
20
|
+
// Validation errors
|
|
21
|
+
INVALID_KEY = 'INVALID_KEY',
|
|
22
|
+
INVALID_FILE_SIZE = 'INVALID_FILE_SIZE',
|
|
23
|
+
INVALID_MIME_TYPE = 'INVALID_MIME_TYPE',
|
|
24
|
+
INVALID_OPTIONS = 'INVALID_OPTIONS',
|
|
25
|
+
|
|
26
|
+
// Provider errors
|
|
27
|
+
PROVIDER_ERROR = 'PROVIDER_ERROR',
|
|
28
|
+
PROVIDER_UNAVAILABLE = 'PROVIDER_UNAVAILABLE',
|
|
29
|
+
PROVIDER_QUOTA_EXCEEDED = 'PROVIDER_QUOTA_EXCEEDED',
|
|
30
|
+
|
|
31
|
+
// Network errors
|
|
32
|
+
NETWORK_ERROR = 'NETWORK_ERROR',
|
|
33
|
+
TIMEOUT = 'TIMEOUT',
|
|
34
|
+
CONNECTION_FAILED = 'CONNECTION_FAILED',
|
|
35
|
+
|
|
36
|
+
// Upload errors
|
|
37
|
+
UPLOAD_FAILED = 'UPLOAD_FAILED',
|
|
38
|
+
UPLOAD_CANCELLED = 'UPLOAD_CANCELLED',
|
|
39
|
+
UPLOAD_PARTIAL = 'UPLOAD_PARTIAL',
|
|
40
|
+
|
|
41
|
+
// Download errors
|
|
42
|
+
DOWNLOAD_FAILED = 'DOWNLOAD_FAILED',
|
|
43
|
+
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
|
44
|
+
ACCESS_DENIED = 'ACCESS_DENIED',
|
|
45
|
+
|
|
46
|
+
// Configuration errors
|
|
47
|
+
CONFIG_ERROR = 'CONFIG_ERROR',
|
|
48
|
+
MISSING_CREDENTIALS = 'MISSING_CREDENTIALS',
|
|
49
|
+
INVALID_CONFIG = 'INVALID_CONFIG',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Base storage error class
|
|
54
|
+
*/
|
|
55
|
+
export class StorageError extends Error {
|
|
56
|
+
public readonly code: StorageErrorCode;
|
|
57
|
+
public readonly details?: Record<string, any>;
|
|
58
|
+
public readonly retryable: boolean;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
message: string,
|
|
62
|
+
code: StorageErrorCode,
|
|
63
|
+
details?: Record<string, any>,
|
|
64
|
+
retryable: boolean = false,
|
|
65
|
+
) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'StorageError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.details = details;
|
|
70
|
+
this.retryable = retryable;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validation error for invalid input
|
|
76
|
+
*/
|
|
77
|
+
export class ValidationError extends StorageError {
|
|
78
|
+
constructor(message: string, details?: Record<string, any>) {
|
|
79
|
+
super(message, StorageErrorCode.INVALID_OPTIONS, details, false);
|
|
80
|
+
this.name = 'ValidationError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Provider-specific error
|
|
86
|
+
*/
|
|
87
|
+
export class ProviderError extends StorageError {
|
|
88
|
+
constructor(
|
|
89
|
+
message: string,
|
|
90
|
+
provider: string,
|
|
91
|
+
details?: Record<string, any>,
|
|
92
|
+
retryable: boolean = false,
|
|
93
|
+
) {
|
|
94
|
+
super(message, StorageErrorCode.PROVIDER_ERROR, { ...details, provider }, retryable);
|
|
95
|
+
this.name = 'ProviderError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Network-related error
|
|
101
|
+
*/
|
|
102
|
+
export class NetworkError extends StorageError {
|
|
103
|
+
constructor(message: string, details?: Record<string, any>, retryable: boolean = true) {
|
|
104
|
+
super(message, StorageErrorCode.NETWORK_ERROR, details, retryable);
|
|
105
|
+
this.name = 'NetworkError';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Upload-specific error
|
|
111
|
+
*/
|
|
112
|
+
export class UploadError extends StorageError {
|
|
113
|
+
constructor(message: string, details?: Record<string, any>, retryable: boolean = true) {
|
|
114
|
+
super(message, StorageErrorCode.UPLOAD_FAILED, details, retryable);
|
|
115
|
+
this.name = 'UploadError';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Download-specific error
|
|
121
|
+
*/
|
|
122
|
+
export class DownloadError extends StorageError {
|
|
123
|
+
constructor(message: string, details?: Record<string, any>, retryable: boolean = true) {
|
|
124
|
+
super(message, StorageErrorCode.DOWNLOAD_FAILED, details, retryable);
|
|
125
|
+
this.name = 'DownloadError';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Configuration error
|
|
131
|
+
*/
|
|
132
|
+
export class ConfigError extends StorageError {
|
|
133
|
+
constructor(message: string, details?: Record<string, any>) {
|
|
134
|
+
super(message, StorageErrorCode.CONFIG_ERROR, details, false);
|
|
135
|
+
this.name = 'ConfigError';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validation options
|
|
141
|
+
*/
|
|
142
|
+
export interface ValidationOptions {
|
|
143
|
+
maxFileSize?: number;
|
|
144
|
+
allowedMimeTypes?: string[];
|
|
145
|
+
allowedExtensions?: string[];
|
|
146
|
+
maxKeyLength?: number;
|
|
147
|
+
forbiddenKeyPatterns?: RegExp[];
|
|
148
|
+
requireContentType?: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Quota information
|
|
153
|
+
*/
|
|
154
|
+
export interface QuotaInfo {
|
|
155
|
+
used: number;
|
|
156
|
+
limit: number;
|
|
157
|
+
remaining: number;
|
|
158
|
+
resetAt?: Date;
|
|
159
|
+
provider: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate file size
|
|
164
|
+
*
|
|
165
|
+
* @param size - File size in bytes
|
|
166
|
+
* @param maxSize - Maximum allowed size in bytes
|
|
167
|
+
* @returns Validation result
|
|
168
|
+
*/
|
|
169
|
+
export function validateFileSize(
|
|
170
|
+
size: number,
|
|
171
|
+
maxSize: number,
|
|
172
|
+
): { valid: boolean; error?: string } {
|
|
173
|
+
if (size < 0) {
|
|
174
|
+
return { valid: false, error: 'File size cannot be negative' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (size > maxSize) {
|
|
178
|
+
return {
|
|
179
|
+
valid: false,
|
|
180
|
+
error: `File size ${size} bytes exceeds maximum ${maxSize} bytes`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { valid: true };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Dangerous MIME types that should be blocked for security reasons
|
|
189
|
+
*
|
|
190
|
+
* These types can execute code or present security risks:
|
|
191
|
+
* - Executables (.exe, .dll, .app)
|
|
192
|
+
* - Scripts (.sh, .bat, .cmd, .js, .php, .py, .pl)
|
|
193
|
+
* - Archives with executables (.jar)
|
|
194
|
+
* - Windows system files (.com, .pif, .scr)
|
|
195
|
+
*/
|
|
196
|
+
const DANGEROUS_MIME_TYPES = [
|
|
197
|
+
// Executables
|
|
198
|
+
'application/x-msdownload', // .exe
|
|
199
|
+
'application/x-executable',
|
|
200
|
+
'application/x-dosexec',
|
|
201
|
+
'application/x-msdos-program',
|
|
202
|
+
'application/x-dll',
|
|
203
|
+
'application/x-app',
|
|
204
|
+
|
|
205
|
+
// Scripts
|
|
206
|
+
'application/x-sh', // Shell scripts
|
|
207
|
+
'text/x-sh',
|
|
208
|
+
'application/x-bat', // Batch files
|
|
209
|
+
'application/x-cmd',
|
|
210
|
+
'text/x-python', // Python scripts
|
|
211
|
+
'application/x-python-code',
|
|
212
|
+
'text/x-perl', // Perl scripts
|
|
213
|
+
'application/x-perl',
|
|
214
|
+
'application/javascript', // JavaScript (unless explicitly allowed)
|
|
215
|
+
'text/javascript',
|
|
216
|
+
'application/x-javascript',
|
|
217
|
+
'application/x-php', // PHP
|
|
218
|
+
'application/x-httpd-php',
|
|
219
|
+
'text/x-php',
|
|
220
|
+
|
|
221
|
+
// Windows system files
|
|
222
|
+
'application/x-com',
|
|
223
|
+
'application/x-pif',
|
|
224
|
+
'application/x-scr',
|
|
225
|
+
'application/x-vbs', // VBScript
|
|
226
|
+
'text/vbscript',
|
|
227
|
+
|
|
228
|
+
// Java
|
|
229
|
+
'application/java-archive', // .jar (can contain malicious code)
|
|
230
|
+
'application/x-java-applet',
|
|
231
|
+
] as const;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* MIME type to file extension mappings for validation
|
|
235
|
+
*
|
|
236
|
+
* Maps common MIME types to their expected file extensions to prevent
|
|
237
|
+
* extension spoofing attacks (e.g., executable disguised as image)
|
|
238
|
+
*/
|
|
239
|
+
const MIME_TO_EXTENSIONS: Record<string, readonly string[]> = {
|
|
240
|
+
// Images
|
|
241
|
+
'image/jpeg': ['.jpg', '.jpeg'],
|
|
242
|
+
'image/png': ['.png'],
|
|
243
|
+
'image/gif': ['.gif'],
|
|
244
|
+
'image/webp': ['.webp'],
|
|
245
|
+
'image/svg+xml': ['.svg'],
|
|
246
|
+
'image/bmp': ['.bmp'],
|
|
247
|
+
'image/tiff': ['.tif', '.tiff'],
|
|
248
|
+
'image/avif': ['.avif'],
|
|
249
|
+
|
|
250
|
+
// Documents
|
|
251
|
+
'application/pdf': ['.pdf'],
|
|
252
|
+
'text/plain': ['.txt'],
|
|
253
|
+
'text/csv': ['.csv'],
|
|
254
|
+
'text/html': ['.html', '.htm'],
|
|
255
|
+
'text/xml': ['.xml'],
|
|
256
|
+
'application/json': ['.json'],
|
|
257
|
+
|
|
258
|
+
// Microsoft Office
|
|
259
|
+
'application/msword': ['.doc'],
|
|
260
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
|
261
|
+
'application/vnd.ms-excel': ['.xls'],
|
|
262
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
|
263
|
+
'application/vnd.ms-powerpoint': ['.ppt'],
|
|
264
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
|
|
265
|
+
|
|
266
|
+
// Archives (safe ones)
|
|
267
|
+
'application/zip': ['.zip'],
|
|
268
|
+
'application/x-gzip': ['.gz'],
|
|
269
|
+
'application/x-tar': ['.tar'],
|
|
270
|
+
'application/x-7z-compressed': ['.7z'],
|
|
271
|
+
|
|
272
|
+
// Audio
|
|
273
|
+
'audio/mpeg': ['.mp3'],
|
|
274
|
+
'audio/wav': ['.wav'],
|
|
275
|
+
'audio/ogg': ['.ogg'],
|
|
276
|
+
'audio/mp4': ['.m4a'],
|
|
277
|
+
|
|
278
|
+
// Video
|
|
279
|
+
'video/mp4': ['.mp4'],
|
|
280
|
+
'video/mpeg': ['.mpeg', '.mpg'],
|
|
281
|
+
'video/quicktime': ['.mov'],
|
|
282
|
+
'video/webm': ['.webm'],
|
|
283
|
+
'video/x-msvideo': ['.avi'],
|
|
284
|
+
} as const;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate MIME type against allowed types and dangerous content
|
|
288
|
+
*
|
|
289
|
+
* This function:
|
|
290
|
+
* 1. Blocks dangerous MIME types (executables, scripts) for security
|
|
291
|
+
* 2. Validates against allowed types list if provided
|
|
292
|
+
* 3. Supports wildcard patterns (e.g., image/*)
|
|
293
|
+
*
|
|
294
|
+
* @param mimeType - MIME type to validate
|
|
295
|
+
* @param allowedTypes - Array of allowed MIME types (optional)
|
|
296
|
+
* @returns Validation result with error message if invalid
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* ```typescript
|
|
300
|
+
* // Block dangerous types
|
|
301
|
+
* validateMimeType('application/x-msdownload', ['image/*'])
|
|
302
|
+
* // => { valid: false, error: '...' }
|
|
303
|
+
*
|
|
304
|
+
* // Allow specific safe types
|
|
305
|
+
* validateMimeType('image/jpeg', ['image/*'])
|
|
306
|
+
* // => { valid: true }
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
export function validateMimeType(
|
|
310
|
+
mimeType: string,
|
|
311
|
+
allowedTypes: string[] = [],
|
|
312
|
+
): { valid: boolean; error?: string } {
|
|
313
|
+
if (!mimeType) {
|
|
314
|
+
return { valid: false, error: 'MIME type is required' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const normalizedMimeType = mimeType.toLowerCase().trim();
|
|
318
|
+
|
|
319
|
+
// Block dangerous MIME types first (security check)
|
|
320
|
+
if (DANGEROUS_MIME_TYPES.includes(normalizedMimeType as (typeof DANGEROUS_MIME_TYPES)[number])) {
|
|
321
|
+
return {
|
|
322
|
+
valid: false,
|
|
323
|
+
error: `Content type '${mimeType}' is not allowed for security reasons (executable or script content)`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// If no allowed types specified, allow all safe types
|
|
328
|
+
if (allowedTypes.length === 0) {
|
|
329
|
+
return { valid: true };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const normalizedAllowed = allowedTypes.map(t => t.toLowerCase().trim());
|
|
333
|
+
|
|
334
|
+
// Check exact match
|
|
335
|
+
if (normalizedAllowed.includes(normalizedMimeType)) {
|
|
336
|
+
return { valid: true };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Check wildcard patterns (e.g., image/*)
|
|
340
|
+
const wildcardMatch = normalizedAllowed.some(allowed => {
|
|
341
|
+
if (allowed.endsWith('/*')) {
|
|
342
|
+
const prefix = allowed.slice(0, -2);
|
|
343
|
+
return normalizedMimeType.startsWith(prefix);
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (wildcardMatch) {
|
|
349
|
+
return { valid: true };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
valid: false,
|
|
354
|
+
error: `MIME type '${mimeType}' is not allowed. Allowed types: ${allowedTypes.join(', ')}`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Validate file extension matches expected extension for MIME type
|
|
360
|
+
*
|
|
361
|
+
* Prevents extension spoofing attacks where malicious files are disguised
|
|
362
|
+
* with incorrect extensions (e.g., executable.exe renamed to image.jpg)
|
|
363
|
+
*
|
|
364
|
+
* @param filename - File name with extension
|
|
365
|
+
* @param contentType - MIME type of the file
|
|
366
|
+
* @returns Validation result with error message if mismatch detected
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* ```typescript
|
|
370
|
+
* // Valid: JPEG file with .jpg extension
|
|
371
|
+
* validateFileExtension('photo.jpg', 'image/jpeg')
|
|
372
|
+
* // => { valid: true }
|
|
373
|
+
*
|
|
374
|
+
* // Invalid: JPEG MIME type with .exe extension (spoofing)
|
|
375
|
+
* validateFileExtension('photo.exe', 'image/jpeg')
|
|
376
|
+
* // => { valid: false, error: '...' }
|
|
377
|
+
*
|
|
378
|
+
* // Unknown MIME type: allowed with warning
|
|
379
|
+
* validateFileExtension('data.custom', 'application/x-custom')
|
|
380
|
+
* // => { valid: true } (with warning logged)
|
|
381
|
+
* ```
|
|
382
|
+
*/
|
|
383
|
+
export function validateFileExtension(
|
|
384
|
+
filename: string,
|
|
385
|
+
contentType: string,
|
|
386
|
+
): { valid: boolean; error?: string } {
|
|
387
|
+
if (!filename || !contentType) {
|
|
388
|
+
return { valid: false, error: 'Filename and content type are required' };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Extract file extension
|
|
392
|
+
const lastDotIndex = filename.lastIndexOf('.');
|
|
393
|
+
if (lastDotIndex === -1) {
|
|
394
|
+
return { valid: false, error: 'File must have an extension' };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const extension = filename.substring(lastDotIndex).toLowerCase();
|
|
398
|
+
const normalizedMimeType = contentType.toLowerCase().trim();
|
|
399
|
+
|
|
400
|
+
// Check if we have extension mapping for this MIME type
|
|
401
|
+
const allowedExtensions = MIME_TO_EXTENSIONS[normalizedMimeType];
|
|
402
|
+
|
|
403
|
+
if (!allowedExtensions) {
|
|
404
|
+
// Unknown MIME type - allow but this should be logged upstream
|
|
405
|
+
// Don't fail validation for unknown types (extensibility)
|
|
406
|
+
return { valid: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Validate extension matches expected extensions for this MIME type
|
|
410
|
+
if (!allowedExtensions.includes(extension)) {
|
|
411
|
+
return {
|
|
412
|
+
valid: false,
|
|
413
|
+
error: `File extension '${extension}' does not match content type '${contentType}'. Expected: ${allowedExtensions.join(', ')}`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { valid: true };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Import validateStorageKey for internal use in this file
|
|
421
|
+
import { validateStorageKey } from '../keys';
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Validate storage key
|
|
425
|
+
*
|
|
426
|
+
* Re-exports from keys.ts for backwards compatibility.
|
|
427
|
+
* Use validateStorageKey from '../keys' for the canonical implementation.
|
|
428
|
+
*
|
|
429
|
+
* @param key - Storage key to validate
|
|
430
|
+
* @param options - Validation options
|
|
431
|
+
* @returns Validation result
|
|
432
|
+
*/
|
|
433
|
+
export { validateStorageKey };
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Validate upload options
|
|
437
|
+
*
|
|
438
|
+
* @param options - Upload options to validate
|
|
439
|
+
* @param validationOptions - Validation constraints
|
|
440
|
+
* @returns Validation result
|
|
441
|
+
*/
|
|
442
|
+
export function validateUploadOptions(
|
|
443
|
+
options: {
|
|
444
|
+
fileSize?: number;
|
|
445
|
+
contentType?: string;
|
|
446
|
+
key?: string;
|
|
447
|
+
},
|
|
448
|
+
validationOptions: ValidationOptions = {},
|
|
449
|
+
): { valid: boolean; errors: string[] } {
|
|
450
|
+
const errors: string[] = [];
|
|
451
|
+
|
|
452
|
+
// Validate file size
|
|
453
|
+
if (options?.fileSize && validationOptions.maxFileSize) {
|
|
454
|
+
const sizeValidation = validateFileSize(options.fileSize, validationOptions.maxFileSize);
|
|
455
|
+
if (!sizeValidation.valid) {
|
|
456
|
+
if (sizeValidation.error) {
|
|
457
|
+
errors.push(sizeValidation.error);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Validate MIME type
|
|
463
|
+
if (options?.contentType && validationOptions.allowedMimeTypes) {
|
|
464
|
+
const mimeValidation = validateMimeType(
|
|
465
|
+
options.contentType,
|
|
466
|
+
validationOptions.allowedMimeTypes,
|
|
467
|
+
);
|
|
468
|
+
if (!mimeValidation.valid) {
|
|
469
|
+
if (mimeValidation.error) {
|
|
470
|
+
errors.push(mimeValidation.error);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Validate key
|
|
476
|
+
if (options?.key) {
|
|
477
|
+
const keyValidation = validateStorageKey(options.key, {
|
|
478
|
+
maxLength: validationOptions.maxKeyLength,
|
|
479
|
+
forbiddenPatterns: validationOptions.forbiddenKeyPatterns,
|
|
480
|
+
});
|
|
481
|
+
if (!keyValidation.valid && keyValidation.errors.length > 0) {
|
|
482
|
+
errors.push(...keyValidation.errors);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Require content type
|
|
487
|
+
if (validationOptions.requireContentType && !options?.contentType) {
|
|
488
|
+
errors.push('Content type is required');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
valid: errors.length === 0,
|
|
493
|
+
errors,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Determines if an error should be retried based on error type and message
|
|
499
|
+
*
|
|
500
|
+
* Checks for common transient failures that are safe to retry:
|
|
501
|
+
* - Network timeouts
|
|
502
|
+
* - Connection failures (ECONNRESET, ENOTFOUND, ETIMEDOUT)
|
|
503
|
+
* - Rate limiting (429, 503 status codes)
|
|
504
|
+
* - Server errors (500-599 status codes)
|
|
505
|
+
*
|
|
506
|
+
* @param error - Error instance to analyze (any type accepted)
|
|
507
|
+
* @returns `true` if the error indicates a transient failure that should be retried
|
|
508
|
+
*
|
|
509
|
+
* @example
|
|
510
|
+
* ```typescript
|
|
511
|
+
* try {
|
|
512
|
+
* await storage.upload(key, data);
|
|
513
|
+
* } catch (error) {
|
|
514
|
+
* if (isRetryableError(error)) {
|
|
515
|
+
* // Retry the operation
|
|
516
|
+
* await retryWithBackoff(() => storage.upload(key, data));
|
|
517
|
+
* } else {
|
|
518
|
+
* // Permanent failure - don't retry
|
|
519
|
+
* throw error;
|
|
520
|
+
* }
|
|
521
|
+
* }
|
|
522
|
+
* ```
|
|
523
|
+
*/
|
|
524
|
+
export function isRetryableError(error: unknown): boolean {
|
|
525
|
+
if (error instanceof StorageError) {
|
|
526
|
+
return error.retryable;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Check for common retryable error patterns
|
|
530
|
+
if (error instanceof Error) {
|
|
531
|
+
const message = error.message.toLowerCase();
|
|
532
|
+
return (
|
|
533
|
+
message.includes('timeout') ||
|
|
534
|
+
message.includes('network') ||
|
|
535
|
+
message.includes('connection') ||
|
|
536
|
+
message.includes('rate limit') ||
|
|
537
|
+
message.includes('temporary')
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Extracts a standardized error code from any error type
|
|
546
|
+
*
|
|
547
|
+
* Maps common error messages to StorageErrorCode enum values:
|
|
548
|
+
* - "not found" → FILE_NOT_FOUND
|
|
549
|
+
* - "access denied" → ACCESS_DENIED
|
|
550
|
+
* - "timeout" → TIMEOUT
|
|
551
|
+
* - "network" → NETWORK_ERROR
|
|
552
|
+
* - Others → PROVIDER_ERROR
|
|
553
|
+
*
|
|
554
|
+
* @param error - Error of any type (StorageError, Error, string, unknown)
|
|
555
|
+
* @returns StorageErrorCode enum value for categorization
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* ```typescript
|
|
559
|
+
* try {
|
|
560
|
+
* await storage.delete(key);
|
|
561
|
+
* } catch (error) {
|
|
562
|
+
* const code = getErrorCode(error);
|
|
563
|
+
* if (code === StorageErrorCode.FILE_NOT_FOUND) {
|
|
564
|
+
* // File already deleted - ignore
|
|
565
|
+
* return { success: true };
|
|
566
|
+
* }
|
|
567
|
+
* throw error;
|
|
568
|
+
* }
|
|
569
|
+
* ```
|
|
570
|
+
*/
|
|
571
|
+
export function getErrorCode(error: unknown): StorageErrorCode {
|
|
572
|
+
if (error instanceof StorageError) {
|
|
573
|
+
return error.code;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (error instanceof Error) {
|
|
577
|
+
const message = error.message.toLowerCase();
|
|
578
|
+
if (message.includes('not found')) return StorageErrorCode.FILE_NOT_FOUND;
|
|
579
|
+
if (message.includes('access denied')) return StorageErrorCode.ACCESS_DENIED;
|
|
580
|
+
if (message.includes('timeout')) return StorageErrorCode.TIMEOUT;
|
|
581
|
+
if (message.includes('network')) return StorageErrorCode.NETWORK_ERROR;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return StorageErrorCode.PROVIDER_ERROR;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Wraps any error into a standardized StorageError with additional context
|
|
589
|
+
*
|
|
590
|
+
* Converts plain errors into structured StorageError instances with:
|
|
591
|
+
* - Proper error code classification
|
|
592
|
+
* - Operation context (upload, delete, etc.)
|
|
593
|
+
* - Provider information
|
|
594
|
+
* - Storage key reference
|
|
595
|
+
*
|
|
596
|
+
* @param error - Original error of any type
|
|
597
|
+
* @param context - Additional context to attach to the error
|
|
598
|
+
* @param context.operation - Storage operation being performed (e.g., "upload", "delete")
|
|
599
|
+
* @param context.provider - Storage provider name (e.g., "cloudflare-r2", "vercel-blob")
|
|
600
|
+
* @param context.key - Storage key being operated on
|
|
601
|
+
* @returns Standardized StorageError instance with full context
|
|
602
|
+
*
|
|
603
|
+
* @example
|
|
604
|
+
* ```typescript
|
|
605
|
+
* try {
|
|
606
|
+
* await provider.upload(key, data);
|
|
607
|
+
* } catch (error) {
|
|
608
|
+
* throw createStorageError(error, {
|
|
609
|
+
* operation: 'upload',
|
|
610
|
+
* provider: 'vercel-blob',
|
|
611
|
+
* key,
|
|
612
|
+
* });
|
|
613
|
+
* }
|
|
614
|
+
* ```
|
|
615
|
+
*/
|
|
616
|
+
export function createStorageError(
|
|
617
|
+
error: unknown,
|
|
618
|
+
context: {
|
|
619
|
+
operation?: string;
|
|
620
|
+
provider?: string;
|
|
621
|
+
key?: string;
|
|
622
|
+
} = {},
|
|
623
|
+
): StorageError {
|
|
624
|
+
if (error instanceof StorageError) {
|
|
625
|
+
return error;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
629
|
+
const code = getErrorCode(error);
|
|
630
|
+
const retryable = isRetryableError(error);
|
|
631
|
+
|
|
632
|
+
return new StorageError(
|
|
633
|
+
message,
|
|
634
|
+
code,
|
|
635
|
+
{
|
|
636
|
+
originalError: error,
|
|
637
|
+
...context,
|
|
638
|
+
},
|
|
639
|
+
retryable,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Check quota information
|
|
645
|
+
*
|
|
646
|
+
* @param used - Used quota in bytes
|
|
647
|
+
* @param limit - Quota limit in bytes
|
|
648
|
+
* @returns Quota information
|
|
649
|
+
*/
|
|
650
|
+
export function getQuotaInfo(used: number, limit: number, provider: string): QuotaInfo {
|
|
651
|
+
return {
|
|
652
|
+
used,
|
|
653
|
+
limit,
|
|
654
|
+
remaining: Math.max(0, limit - used),
|
|
655
|
+
provider,
|
|
656
|
+
resetAt: undefined, // Provider-specific
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Check if quota is exceeded
|
|
662
|
+
*
|
|
663
|
+
* @param used - Used quota in bytes
|
|
664
|
+
* @param limit - Quota limit in bytes
|
|
665
|
+
* @returns True if quota is exceeded
|
|
666
|
+
*/
|
|
667
|
+
export function isQuotaExceeded(used: number, limit: number): boolean {
|
|
668
|
+
return used >= limit;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Format file size for display
|
|
673
|
+
*
|
|
674
|
+
* @param bytes - Size in bytes
|
|
675
|
+
* @returns Formatted size string
|
|
676
|
+
*/
|
|
677
|
+
export function formatFileSize(bytes: number): string {
|
|
678
|
+
if (bytes === 0) return '0 B';
|
|
679
|
+
|
|
680
|
+
const k = 1024;
|
|
681
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
682
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
683
|
+
|
|
684
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Parse file size from string
|
|
689
|
+
*
|
|
690
|
+
* @param sizeString - Size string (e.g., "10MB", "1.5GB")
|
|
691
|
+
* @returns Size in bytes
|
|
692
|
+
*/
|
|
693
|
+
export function parseFileSize(sizeString: string): number {
|
|
694
|
+
// eslint-disable-next-line security/detect-unsafe-regex
|
|
695
|
+
const match = sizeString.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i);
|
|
696
|
+
if (!match) {
|
|
697
|
+
throw new ValidationError(`Invalid file size format: ${sizeString}`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const valueStr = match[1];
|
|
701
|
+
const unitStr = match[2];
|
|
702
|
+
if (!valueStr || !unitStr) {
|
|
703
|
+
throw new ValidationError(`Invalid file size format: ${sizeString}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const value = parseFloat(valueStr);
|
|
707
|
+
const unit = unitStr.toUpperCase();
|
|
708
|
+
|
|
709
|
+
const multipliers: Record<string, number> = {
|
|
710
|
+
B: 1,
|
|
711
|
+
KB: 1024,
|
|
712
|
+
MB: 1024 * 1024,
|
|
713
|
+
GB: 1024 * 1024 * 1024,
|
|
714
|
+
TB: 1024 * 1024 * 1024 * 1024,
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const multiplier = multipliers[unit];
|
|
718
|
+
if (multiplier === undefined) {
|
|
719
|
+
throw new ValidationError(`Unknown unit: ${unit}`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return value * multiplier;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Blocked hostname patterns for SSRF prevention
|
|
727
|
+
* Comprehensive list covering IPv4, IPv6, and special addresses
|
|
728
|
+
*/
|
|
729
|
+
const SSRF_BLOCKED_PATTERNS: RegExp[] = [
|
|
730
|
+
// IPv4 localhost and loopback
|
|
731
|
+
/^localhost$/i,
|
|
732
|
+
/^127\./,
|
|
733
|
+
/^0\./,
|
|
734
|
+
|
|
735
|
+
// IPv4 private ranges (RFC 1918)
|
|
736
|
+
/^10\./,
|
|
737
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
738
|
+
/^192\.168\./,
|
|
739
|
+
|
|
740
|
+
// Link-local IPv4 (includes cloud metadata endpoints)
|
|
741
|
+
/^169\.254\./,
|
|
742
|
+
|
|
743
|
+
// Carrier-grade NAT (RFC 6598)
|
|
744
|
+
/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./,
|
|
745
|
+
|
|
746
|
+
// Class E reserved
|
|
747
|
+
/^240\./,
|
|
748
|
+
|
|
749
|
+
// IPv6 localhost
|
|
750
|
+
/^::1$/,
|
|
751
|
+
/^\[::1\]$/,
|
|
752
|
+
|
|
753
|
+
// IPv6 link-local
|
|
754
|
+
/^fe80:/i,
|
|
755
|
+
/^\[fe80:/i,
|
|
756
|
+
|
|
757
|
+
// IPv6 unique local (private)
|
|
758
|
+
/^fc00:/i,
|
|
759
|
+
/^fd00:/i,
|
|
760
|
+
|
|
761
|
+
// IPv4-mapped IPv6
|
|
762
|
+
/^::ffff:0:0:/i,
|
|
763
|
+
|
|
764
|
+
// IPv6 multicast
|
|
765
|
+
/^ff00:/i,
|
|
766
|
+
|
|
767
|
+
// Domain patterns
|
|
768
|
+
/\.local$/i,
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Validates URL safety to prevent SSRF (Server-Side Request OneAppry) attacks
|
|
773
|
+
*
|
|
774
|
+
* This function blocks:
|
|
775
|
+
* - Non-HTTPS URLs (only HTTPS is allowed)
|
|
776
|
+
* - Localhost and loopback addresses (127.0.0.1, ::1, etc.)
|
|
777
|
+
* - Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
|
778
|
+
* - Cloud metadata endpoints (169.254.169.254)
|
|
779
|
+
* - Link-local addresses (169.254.0.0/16, fe80::/10)
|
|
780
|
+
* - Carrier-grade NAT ranges (100.64.0.0/10)
|
|
781
|
+
* - IPv6 private/special addresses (fc00::/7, ff00::/8)
|
|
782
|
+
* - .local domain suffix
|
|
783
|
+
*
|
|
784
|
+
* @param url - The URL to validate
|
|
785
|
+
* @returns Object with valid boolean and optional error message
|
|
786
|
+
*
|
|
787
|
+
* @example
|
|
788
|
+
* ```typescript
|
|
789
|
+
* const result = validateUrlForSSRF('https://example.com/image.jpg');
|
|
790
|
+
* if (!result.valid) {
|
|
791
|
+
* throw new Error(result.error);
|
|
792
|
+
* }
|
|
793
|
+
* ```
|
|
794
|
+
*/
|
|
795
|
+
export function validateUrlForSSRF(url: string): {
|
|
796
|
+
valid: boolean;
|
|
797
|
+
error?: string;
|
|
798
|
+
} {
|
|
799
|
+
try {
|
|
800
|
+
const parsed = new URL(url);
|
|
801
|
+
|
|
802
|
+
// Only allow HTTPS (not HTTP or other protocols)
|
|
803
|
+
if (parsed.protocol !== 'https:') {
|
|
804
|
+
return {
|
|
805
|
+
valid: false,
|
|
806
|
+
error: 'Only HTTPS URLs are allowed',
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
811
|
+
|
|
812
|
+
// Check against all blocked patterns
|
|
813
|
+
if (SSRF_BLOCKED_PATTERNS.some(pattern => pattern.test(hostname))) {
|
|
814
|
+
return {
|
|
815
|
+
valid: false,
|
|
816
|
+
error: `URL hostname "${hostname}" is blocked for security reasons (private IP, localhost, or reserved address)`,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return { valid: true };
|
|
821
|
+
} catch {
|
|
822
|
+
return {
|
|
823
|
+
valid: false,
|
|
824
|
+
error: 'Invalid URL format',
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
}
|