@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.
Files changed (69) hide show
  1. package/README.md +854 -0
  2. package/dist/client-next.d.mts +61 -0
  3. package/dist/client-next.d.mts.map +1 -0
  4. package/dist/client-next.mjs +111 -0
  5. package/dist/client-next.mjs.map +1 -0
  6. package/dist/client-utils-Dx6W25iz.d.mts +43 -0
  7. package/dist/client-utils-Dx6W25iz.d.mts.map +1 -0
  8. package/dist/client.d.mts +28 -0
  9. package/dist/client.d.mts.map +1 -0
  10. package/dist/client.mjs +183 -0
  11. package/dist/client.mjs.map +1 -0
  12. package/dist/env-BVHLmQdh.mjs +128 -0
  13. package/dist/env-BVHLmQdh.mjs.map +1 -0
  14. package/dist/env.mjs +3 -0
  15. package/dist/health-check-D7LnnDec.mjs +746 -0
  16. package/dist/health-check-D7LnnDec.mjs.map +1 -0
  17. package/dist/health-check-im_huJ59.d.mts +116 -0
  18. package/dist/health-check-im_huJ59.d.mts.map +1 -0
  19. package/dist/index.d.mts +60 -0
  20. package/dist/index.d.mts.map +1 -0
  21. package/dist/index.mjs +3 -0
  22. package/dist/keys.d.mts +37 -0
  23. package/dist/keys.d.mts.map +1 -0
  24. package/dist/keys.mjs +253 -0
  25. package/dist/keys.mjs.map +1 -0
  26. package/dist/server-edge.d.mts +28 -0
  27. package/dist/server-edge.d.mts.map +1 -0
  28. package/dist/server-edge.mjs +88 -0
  29. package/dist/server-edge.mjs.map +1 -0
  30. package/dist/server-next.d.mts +183 -0
  31. package/dist/server-next.d.mts.map +1 -0
  32. package/dist/server-next.mjs +1353 -0
  33. package/dist/server-next.mjs.map +1 -0
  34. package/dist/server.d.mts +70 -0
  35. package/dist/server.d.mts.map +1 -0
  36. package/dist/server.mjs +384 -0
  37. package/dist/server.mjs.map +1 -0
  38. package/dist/types.d.mts +321 -0
  39. package/dist/types.d.mts.map +1 -0
  40. package/dist/types.mjs +3 -0
  41. package/dist/validation.d.mts +101 -0
  42. package/dist/validation.d.mts.map +1 -0
  43. package/dist/validation.mjs +590 -0
  44. package/dist/validation.mjs.map +1 -0
  45. package/dist/vercel-blob-07Sx0Akn.d.mts +31 -0
  46. package/dist/vercel-blob-07Sx0Akn.d.mts.map +1 -0
  47. package/dist/vercel-blob-DA8HaYuw.mjs +158 -0
  48. package/dist/vercel-blob-DA8HaYuw.mjs.map +1 -0
  49. package/package.json +111 -0
  50. package/src/actions/blob-upload.ts +171 -0
  51. package/src/actions/index.ts +23 -0
  52. package/src/actions/mediaActions.ts +1071 -0
  53. package/src/actions/productMediaActions.ts +538 -0
  54. package/src/auth-helpers.ts +386 -0
  55. package/src/capabilities.ts +225 -0
  56. package/src/client-next.ts +184 -0
  57. package/src/client-utils.ts +292 -0
  58. package/src/client.ts +102 -0
  59. package/src/constants.ts +88 -0
  60. package/src/health-check.ts +81 -0
  61. package/src/multi-storage.ts +230 -0
  62. package/src/multipart.ts +497 -0
  63. package/src/retry-utils.test.ts +118 -0
  64. package/src/retry-utils.ts +59 -0
  65. package/src/server-edge.ts +129 -0
  66. package/src/server-next.ts +14 -0
  67. package/src/server.ts +666 -0
  68. package/src/validation.test.ts +312 -0
  69. 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
+ }