@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,1071 @@
1
+ /**
2
+ * @fileoverview Media storage server actions
3
+ *
4
+ * Provides Next.js server actions for media file operations including upload,
5
+ * download, delete, move, and list operations with authentication and validation.
6
+ *
7
+ * Features:
8
+ * - Authentication and authorization
9
+ * - Rate limiting
10
+ * - File validation (size, MIME type)
11
+ * - SSRF protection
12
+ * - Bulk operations
13
+ *
14
+ * @module @repo/storage/actions/mediaActions
15
+ */
16
+
17
+ 'use server';
18
+
19
+ import { safeEnv } from '../../env';
20
+ import { validateStorageKey } from '../../keys';
21
+ import { checkRateLimit, getClientIP, getSession } from '../auth-helpers';
22
+ import { STORAGE_CONSTANTS } from '../constants';
23
+ import { getMultiStorage, getStorage } from '../server';
24
+ import { validateMimeType, validateUrlForSSRF } from '../validation';
25
+
26
+ import type {
27
+ BulkDeleteResponse,
28
+ BulkMoveResponse,
29
+ ListOptions,
30
+ MediaActionResponse,
31
+ StorageObject,
32
+ UploadOptions,
33
+ } from '../../types';
34
+
35
+ //==============================================================================
36
+ // MEDIA STORAGE SERVER ACTIONS
37
+ //==============================================================================
38
+
39
+ /**
40
+ * Upload binary data to the configured storage provider with optional validations.
41
+ *
42
+ * Validations performed before upload include storage key safety checks, optional
43
+ * authentication and rate limiting, maximum file size enforcement, and MIME type
44
+ * validation when `options.allowedMimeTypes` and `options.contentType` are provided.
45
+ *
46
+ * @param key - Destination storage key (path) where the file will be stored
47
+ * @param data - File contents to upload (Buffer, ArrayBuffer, Blob, File, or ReadableStream)
48
+ * @param options - Upload options and validation overrides. Notable fields:
49
+ * - `maxFileSize` — override maximum allowed file size in bytes for this upload
50
+ * - `allowedMimeTypes` — list of permitted MIME types; validated against `options.contentType` if present
51
+ * - other provider-specific upload options (e.g., `contentType`, `metadata`) are passed through to the storage provider
52
+ * @returns An object with `success: true` and the uploaded `StorageObject` on success, or `success: false` and an `error` message on failure
53
+ */
54
+ export async function uploadMediaAction(
55
+ key: string,
56
+ data: ArrayBuffer | Blob | Buffer | File | ReadableStream,
57
+ options?: UploadOptions & {
58
+ maxFileSize?: number;
59
+ allowedMimeTypes?: string[];
60
+ },
61
+ ): Promise<MediaActionResponse<StorageObject>> {
62
+ 'use server';
63
+
64
+ // Get environment configuration once
65
+ const storageEnv = safeEnv();
66
+
67
+ // Authentication check (configurable)
68
+ const session = await getSession();
69
+ if (storageEnv.STORAGE_ENFORCE_AUTH && !session?.user) {
70
+ return {
71
+ success: false,
72
+ error: 'Unauthorized: Authentication required',
73
+ };
74
+ }
75
+
76
+ // Rate limiting
77
+ const identifier = session?.user?.id ?? getClientIP();
78
+ const rateLimitResult = await checkRateLimit(
79
+ identifier,
80
+ 'storage:upload',
81
+ storageEnv.STORAGE_RATE_LIMIT_REQUESTS,
82
+ storageEnv.STORAGE_RATE_LIMIT_WINDOW_MS,
83
+ );
84
+ if (storageEnv.STORAGE_ENABLE_RATE_LIMIT && !rateLimitResult.allowed) {
85
+ return {
86
+ success: false,
87
+ error: 'Rate limit exceeded. Please try again later.',
88
+ };
89
+ }
90
+
91
+ try {
92
+ // Validate storage key to prevent path traversal
93
+ const keyValidation = validateStorageKey(key, {
94
+ maxLength: 1024,
95
+ forbiddenPatterns: [/\.\./, /\/\//],
96
+ });
97
+ if (!keyValidation.valid) {
98
+ return {
99
+ success: false,
100
+ error: `Invalid storage key: ${keyValidation.errors.join(', ')}`,
101
+ };
102
+ }
103
+
104
+ // Validate file size
105
+ const maxSize = options?.maxFileSize ?? storageEnv.STORAGE_MAX_FILE_SIZE;
106
+ let fileSize = 0;
107
+
108
+ if (data instanceof File || data instanceof Blob) {
109
+ fileSize = data.size;
110
+ } else if (data instanceof ArrayBuffer) {
111
+ fileSize = data.byteLength;
112
+ } else if (data instanceof Buffer) {
113
+ fileSize = data.length;
114
+ }
115
+
116
+ if (fileSize > maxSize) {
117
+ return {
118
+ success: false,
119
+ error: `File size ${fileSize} bytes exceeds maximum ${maxSize} bytes`,
120
+ };
121
+ }
122
+
123
+ // Validate MIME type if restrictions provided
124
+ if (options?.allowedMimeTypes && options.contentType) {
125
+ const mimeValidation = validateMimeType(options.contentType, options.allowedMimeTypes);
126
+ if (!mimeValidation.valid) {
127
+ return {
128
+ success: false,
129
+ error: mimeValidation.error ?? 'Invalid file type',
130
+ };
131
+ }
132
+ }
133
+
134
+ const result = await getStorage().upload(key, data, options);
135
+ return { success: true, data: result };
136
+ } catch (error) {
137
+ return {
138
+ success: false,
139
+ error: error instanceof Error ? error.message : 'Failed to upload media',
140
+ };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Get media file metadata
146
+ */
147
+ export async function getMediaAction(key: string): Promise<MediaActionResponse<StorageObject>> {
148
+ 'use server';
149
+ try {
150
+ const metadata = await getStorage().getMetadata(key);
151
+ return { success: true, data: metadata };
152
+ } catch (error) {
153
+ return {
154
+ success: false,
155
+ error: error instanceof Error ? error.message : 'Failed to get media metadata',
156
+ };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * List media files
162
+ */
163
+ export async function listMediaAction(
164
+ options?: ListOptions,
165
+ ): Promise<MediaActionResponse<StorageObject[]>> {
166
+ 'use server';
167
+ try {
168
+ const items = await getStorage().list(options);
169
+ return { success: true, data: items };
170
+ } catch (error) {
171
+ return {
172
+ success: false,
173
+ error: error instanceof Error ? error.message : 'Failed to list media',
174
+ };
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Delete a media file
180
+ */
181
+ export async function deleteMediaAction(key: string): Promise<MediaActionResponse<void>> {
182
+ 'use server';
183
+ try {
184
+ await getStorage().delete(key);
185
+ return { success: true };
186
+ } catch (error) {
187
+ return {
188
+ success: false,
189
+ error: error instanceof Error ? error.message : 'Failed to delete media',
190
+ };
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Determine whether a media object exists at the given storage key.
196
+ *
197
+ * @param key - The storage key (path) of the media object to check
198
+ * @returns A response object whose `data` is `true` if the object exists, `false` otherwise. On error the response will have `success: false` and an `error` message
199
+ */
200
+ export async function existsMediaAction(key: string): Promise<MediaActionResponse<boolean>> {
201
+ 'use server';
202
+ try {
203
+ const exists = await getStorage().exists(key);
204
+ return { success: true, data: exists };
205
+ } catch (error) {
206
+ return {
207
+ success: false,
208
+ error: error instanceof Error ? error.message : 'Failed to check media existence',
209
+ };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Retrieve a public or signed URL for a media object.
215
+ *
216
+ * When the context is `product`, when `forceSign` is true, or when `expiresIn` is provided,
217
+ * a signed URL is returned using the specified or default expiry; otherwise a direct URL is returned.
218
+ *
219
+ * @param key - The storage key of the media object
220
+ * @param options.expiresIn - Expiration time in seconds for a signed URL
221
+ * @param options.context - Context of the media; `product` forces signed URLs for product photos
222
+ * @param options.forceSign - When true, force generation of a signed URL regardless of context
223
+ * @returns The URL for the storage key (signed if signing rules apply)
224
+ */
225
+ export async function getMediaUrlAction(
226
+ key: string,
227
+ options?: {
228
+ expiresIn?: number;
229
+ context?: 'product' | 'user' | 'admin' | 'public';
230
+ forceSign?: boolean;
231
+ },
232
+ ): Promise<MediaActionResponse<string>> {
233
+ 'use server';
234
+ try {
235
+ const storage = getStorage();
236
+
237
+ // Product photos always need signed URLs for protection
238
+ const isProductPhoto = options?.context === 'product' || key.includes('/products/');
239
+ const shouldSign = (isProductPhoto || options?.forceSign) ?? Boolean(options?.expiresIn);
240
+
241
+ if (shouldSign) {
242
+ const expiresIn =
243
+ options?.expiresIn ??
244
+ (isProductPhoto
245
+ ? STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS
246
+ : STORAGE_CONSTANTS.UPLOAD_URL_EXPIRY_SECONDS);
247
+ const signedUrl = await storage.getUrl(key, { expiresIn });
248
+ return { success: true, data: signedUrl };
249
+ }
250
+
251
+ // For public content, return direct URL
252
+ const url = await storage.getUrl(key);
253
+ return { success: true, data: url };
254
+ } catch (error) {
255
+ return {
256
+ success: false,
257
+ error: error instanceof Error ? error.message : 'Failed to get media URL',
258
+ };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Generate signed URLs for multiple product media keys.
264
+ *
265
+ * Appends the provided `variant` to keys that reference Cloudflare Images and uses
266
+ * `options.expiresIn` or the product URL expiry constant as the signing duration.
267
+ *
268
+ * @param keys - Array of storage keys identifying product media items
269
+ * @param options - Optional settings: `expiresIn` (seconds) overrides the default signed URL expiry; `variant` is appended to Cloudflare Images keys to request a specific image variant
270
+ * @returns A MediaActionResponse whose `data` is an array of objects with the original `key` and the generated `url` when successful; on failure the response contains an error message
271
+ */
272
+ export async function getProductMediaUrlsAction(
273
+ keys: string[],
274
+ options?: { expiresIn?: number; variant?: string },
275
+ ): Promise<MediaActionResponse<Array<{ key: string; url: string }>>> {
276
+ 'use server';
277
+ try {
278
+ const storage = getStorage();
279
+ const expiresIn = options?.expiresIn ?? STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS;
280
+
281
+ const mediaWithSignedUrls = await Promise.all(
282
+ keys.map(async key => {
283
+ let finalKey = key;
284
+
285
+ // For Cloudflare Images, append variant if specified
286
+ if (options?.variant && key.includes('cloudflare-images')) {
287
+ finalKey = `${key}/${options.variant}`;
288
+ }
289
+
290
+ const signedUrl = await storage.getUrl(finalKey, { expiresIn });
291
+
292
+ return {
293
+ key,
294
+ url: signedUrl,
295
+ };
296
+ }),
297
+ );
298
+
299
+ return {
300
+ success: true,
301
+ data: mediaWithSignedUrls,
302
+ };
303
+ } catch (error) {
304
+ return {
305
+ success: false,
306
+ error: error instanceof Error ? error.message : 'Failed to get product media URLs',
307
+ };
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Get presigned upload URL for product photos (admin only)
313
+ */
314
+ export async function getProductUploadUrlAction(
315
+ filename: string,
316
+ productId: string,
317
+ options?: {
318
+ expiresIn?: number;
319
+ contentType?: string;
320
+ maxSizeBytes?: number;
321
+ },
322
+ ): Promise<MediaActionResponse<{ uploadUrl: string; key: string }>> {
323
+ 'use server';
324
+ try {
325
+ const storage = getStorage();
326
+ const key = `products/${productId}/${Date.now()}-${filename}`;
327
+
328
+ // Get presigned upload URL (this would depend on the storage provider)
329
+ // For now, return a structure that indicates what should be implemented
330
+ const uploadUrl = await storage.getUrl(key, {
331
+ expiresIn: options?.expiresIn ?? 1800, // 30 minutes for uploads
332
+ });
333
+
334
+ return {
335
+ success: true,
336
+ data: {
337
+ uploadUrl,
338
+ key,
339
+ },
340
+ };
341
+ } catch (error) {
342
+ return {
343
+ success: false,
344
+ error: error instanceof Error ? error.message : 'Failed to get product upload URL',
345
+ };
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Download a media file
351
+ */
352
+ export async function downloadMediaAction(key: string): Promise<MediaActionResponse<Blob>> {
353
+ 'use server';
354
+ try {
355
+ const blob = await getStorage().download(key);
356
+ return { success: true, data: blob };
357
+ } catch (error) {
358
+ return {
359
+ success: false,
360
+ error: error instanceof Error ? error.message : 'Failed to download media',
361
+ };
362
+ }
363
+ }
364
+
365
+ //==============================================================================
366
+ // BULK OPERATIONS
367
+ //==============================================================================
368
+
369
+ /**
370
+ * Delete multiple media files
371
+ */
372
+ export async function bulkDeleteMediaAction(
373
+ keys: string[],
374
+ ): Promise<MediaActionResponse<BulkDeleteResponse>> {
375
+ 'use server';
376
+ const results = {
377
+ succeeded: [] as string[],
378
+ failed: [] as { key: string; error: string }[],
379
+ };
380
+
381
+ try {
382
+ // Process deletions in parallel with error handling for each
383
+ await Promise.all(
384
+ keys.map(async key => {
385
+ try {
386
+ await getStorage().delete(key);
387
+ results.succeeded.push(key);
388
+ } catch (error) {
389
+ results.failed.push({
390
+ key,
391
+ error: error instanceof Error ? error.message : 'Unknown error',
392
+ });
393
+ }
394
+ }),
395
+ );
396
+
397
+ return {
398
+ success: results.failed.length === 0,
399
+ data: results,
400
+ };
401
+ } catch (error) {
402
+ return {
403
+ success: false,
404
+ error: error instanceof Error ? error.message : 'Bulk delete operation failed',
405
+ };
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Move/rename multiple media files
411
+ */
412
+ export async function bulkMoveMediaAction(
413
+ operations: Array<{ sourceKey: string; destinationKey: string }>,
414
+ ): Promise<MediaActionResponse<BulkMoveResponse>> {
415
+ 'use server';
416
+ const results = {
417
+ succeeded: [] as { sourceKey: string; destinationKey: string }[],
418
+ failed: [] as { sourceKey: string; destinationKey: string; error: string }[],
419
+ };
420
+
421
+ try {
422
+ // Process moves in parallel
423
+ await Promise.all(
424
+ operations.map(async ({ sourceKey, destinationKey }) => {
425
+ try {
426
+ // Download the file
427
+ const blob = await getStorage().download(sourceKey);
428
+
429
+ // Get metadata from source
430
+ const metadata = await getStorage().getMetadata(sourceKey);
431
+
432
+ // Upload to new location
433
+ await getStorage().upload(destinationKey, blob, {
434
+ contentType: metadata.contentType,
435
+ });
436
+
437
+ // Delete the source
438
+ await getStorage().delete(sourceKey);
439
+
440
+ results.succeeded.push({ sourceKey, destinationKey });
441
+ } catch (error) {
442
+ results.failed.push({
443
+ sourceKey,
444
+ destinationKey,
445
+ error: error instanceof Error ? error.message : 'Unknown error',
446
+ });
447
+ }
448
+ }),
449
+ );
450
+
451
+ return {
452
+ success: results.failed.length === 0,
453
+ data: results,
454
+ };
455
+ } catch (error) {
456
+ return {
457
+ success: false,
458
+ error: error instanceof Error ? error.message : 'Bulk move operation failed',
459
+ };
460
+ }
461
+ }
462
+
463
+ //==============================================================================
464
+ // BULK IMPORT OPERATIONS
465
+ //==============================================================================
466
+
467
+ /**
468
+ * Imports multiple external URLs into storage, processing them in configurable batches.
469
+ *
470
+ * Each source URL is validated for safety, fetched with a per-request timeout, and streamed
471
+ * into the chosen storage provider (or routed to Cloudflare Images for images when available).
472
+ * Successful imports are recorded with their destination key and resulting StorageObject;
473
+ * failures record the source URL and an error message. Processing continues after individual failures.
474
+ *
475
+ * @param imports - Array of import entries, each with a required `sourceUrl`, optional `destinationKey`
476
+ * (if omitted a key is generated), and optional `metadata` (altText, productId, userId, type).
477
+ * @param options - Optional settings:
478
+ * - `batchSize`: number of concurrent imports per batch (defaults to STORAGE_CONSTANTS.DEFAULT_BATCH_SIZE).
479
+ * - `provider`: name of the storage provider to use (if omitted uses default provider or Cloudflare Images for images).
480
+ * - `timeout`: per-request fetch timeout in milliseconds (defaults to STORAGE_CONSTANTS.DEFAULT_REQUEST_TIMEOUT_MS).
481
+ * @returns An object with:
482
+ * - `succeeded`: array of { sourceUrl, destinationKey, storageObject } for successful imports;
483
+ * - `failed`: array of { sourceUrl, error } for failed imports;
484
+ * - `totalProcessed`: total number of import attempts processed.
485
+ */
486
+ export async function bulkImportFromUrlsAction(
487
+ imports: Array<{
488
+ sourceUrl: string;
489
+ destinationKey?: string;
490
+ metadata?: {
491
+ altText?: string;
492
+ productId?: string;
493
+ userId?: string;
494
+ type?: 'IMAGE' | 'VIDEO' | 'DOCUMENT';
495
+ };
496
+ }>,
497
+ options?: {
498
+ batchSize?: number;
499
+ provider?: string;
500
+ timeout?: number;
501
+ },
502
+ ): Promise<
503
+ MediaActionResponse<{
504
+ succeeded: Array<{
505
+ sourceUrl: string;
506
+ destinationKey: string;
507
+ storageObject: StorageObject;
508
+ }>;
509
+ failed: Array<{
510
+ sourceUrl: string;
511
+ error: string;
512
+ }>;
513
+ totalProcessed: number;
514
+ }>
515
+ > {
516
+ 'use server';
517
+
518
+ const results = {
519
+ succeeded: [] as Array<{
520
+ sourceUrl: string;
521
+ destinationKey: string;
522
+ storageObject: StorageObject;
523
+ }>,
524
+ failed: [] as Array<{
525
+ sourceUrl: string;
526
+ error: string;
527
+ }>,
528
+ totalProcessed: 0,
529
+ };
530
+
531
+ const batchSize = options?.batchSize ?? STORAGE_CONSTANTS.DEFAULT_BATCH_SIZE;
532
+ const timeout = options?.timeout ?? STORAGE_CONSTANTS.DEFAULT_REQUEST_TIMEOUT_MS;
533
+
534
+ try {
535
+ // Process imports in batches to avoid overwhelming the system
536
+ for (let i = 0; i < imports.length; i += batchSize) {
537
+ const batch = imports.slice(i, i + batchSize);
538
+
539
+ await Promise.all(
540
+ batch.map(async importItem => {
541
+ try {
542
+ // Validate URL to prevent SSRF attacks
543
+ const urlValidation = validateUrlForSSRF(importItem.sourceUrl);
544
+ if (!urlValidation.valid) {
545
+ throw new Error(urlValidation.error ?? 'Invalid or unsafe URL');
546
+ }
547
+
548
+ // Fetch with timeout
549
+ const controller = new AbortController();
550
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
551
+
552
+ const response = await fetch(importItem.sourceUrl, {
553
+ signal: controller.signal,
554
+ // Add additional security headers
555
+ headers: {
556
+ 'User-Agent': 'Storage-Service/1.0',
557
+ },
558
+ // Prevent redirects to internal URLs
559
+ redirect: 'error',
560
+ });
561
+
562
+ clearTimeout(timeoutId);
563
+
564
+ if (!response.ok) {
565
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
566
+ }
567
+
568
+ // Get content type and determine storage provider
569
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
570
+ const isImage = contentType.startsWith('image/');
571
+
572
+ // Generate destination key if not provided
573
+ const destinationKey =
574
+ importItem.destinationKey ??
575
+ generateStorageKey(importItem.sourceUrl, importItem.metadata);
576
+
577
+ // Stream the content to storage
578
+ const storage = options?.provider
579
+ ? getMultiStorage().getProvider(options.provider)
580
+ : getStorage();
581
+
582
+ if (!storage) {
583
+ throw new Error('Storage provider not available');
584
+ }
585
+
586
+ // For images, use Cloudflare Images if configured
587
+ if (isImage && !options?.provider) {
588
+ const multiStorage = getMultiStorage();
589
+ const imageProvider = multiStorage.getProvider('cloudflare-images');
590
+ if (imageProvider) {
591
+ // Stream to Cloudflare Images
592
+ const blob = await response.blob();
593
+ const result = await imageProvider.upload(destinationKey, blob, {
594
+ contentType,
595
+ metadata: importItem.metadata,
596
+ });
597
+
598
+ results.succeeded.push({
599
+ sourceUrl: importItem.sourceUrl,
600
+ destinationKey,
601
+ storageObject: result,
602
+ });
603
+ results.totalProcessed++;
604
+ return;
605
+ }
606
+ }
607
+
608
+ // Stream to default storage (R2 or local)
609
+ const stream = response.body;
610
+ if (!stream) {
611
+ throw new Error('No response body available');
612
+ }
613
+
614
+ const result = await storage.upload(destinationKey, stream, {
615
+ contentType,
616
+ metadata: importItem.metadata,
617
+ });
618
+
619
+ results.succeeded.push({
620
+ sourceUrl: importItem.sourceUrl,
621
+ destinationKey,
622
+ storageObject: result,
623
+ });
624
+ results.totalProcessed++;
625
+ } catch (error) {
626
+ results.failed.push({
627
+ sourceUrl: importItem.sourceUrl,
628
+ error: error instanceof Error ? error.message : 'Unknown error',
629
+ });
630
+ results.totalProcessed++;
631
+ }
632
+ }),
633
+ );
634
+ }
635
+
636
+ return {
637
+ success: results.failed.length === 0,
638
+ data: results,
639
+ };
640
+ } catch (error) {
641
+ return {
642
+ success: false,
643
+ error: error instanceof Error ? error.message : 'Bulk import operation failed',
644
+ data: results,
645
+ };
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Import a single media item from a remote HTTPS URL into storage.
651
+ *
652
+ * Validates the source URL for safety, fetches the resource, and uploads it to storage.
653
+ * If `options.onProgress` is provided and the response exposes a `content-length`, progress
654
+ * updates are emitted as a fraction between 0 and 1 during download.
655
+ *
656
+ * @param sourceUrl - The HTTPS URL of the resource to import; must pass SSRF-safe validation.
657
+ * @param destinationKey - Optional destination storage key; generated automatically when omitted.
658
+ * @param options.metadata - Optional metadata to attach to the uploaded object.
659
+ * @param options.onProgress - Optional callback invoked with a number in [0, 1] representing download progress.
660
+ * @returns On success, a response containing the uploaded `StorageObject`; on failure, a response containing an error message.
661
+ */
662
+ export async function importFromUrlAction(
663
+ sourceUrl: string,
664
+ destinationKey?: string,
665
+ options?: {
666
+ metadata?: Record<string, any>;
667
+ onProgress?: (progress: number) => void;
668
+ },
669
+ ): Promise<MediaActionResponse<StorageObject>> {
670
+ 'use server';
671
+
672
+ try {
673
+ // Validate URL to prevent SSRF attacks
674
+ const urlValidation = validateUrlForSSRF(sourceUrl);
675
+ if (!urlValidation.valid) {
676
+ throw new Error(urlValidation.error ?? 'Invalid or unsafe URL');
677
+ }
678
+
679
+ const response = await fetch(sourceUrl, {
680
+ // Add additional security headers
681
+ headers: {
682
+ 'User-Agent': 'Storage-Service/1.0',
683
+ },
684
+ // Prevent redirects to internal URLs
685
+ redirect: 'error',
686
+ });
687
+
688
+ if (!response.ok) {
689
+ throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
690
+ }
691
+
692
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
693
+ const contentLength = response.headers.get('content-length');
694
+
695
+ // Generate key if not provided
696
+ const key = destinationKey ?? generateStorageKey(sourceUrl);
697
+
698
+ // For progress tracking, we need to read the stream manually
699
+ if (options?.onProgress && contentLength && response.body) {
700
+ const total = parseInt(contentLength);
701
+ let loaded = 0;
702
+
703
+ const reader = response.body.getReader();
704
+ const chunks: Uint8Array[] = [];
705
+
706
+ while (true) {
707
+ const { done, value } = await reader.read();
708
+ if (done) break;
709
+
710
+ chunks.push(value);
711
+ loaded += value.length;
712
+ options.onProgress(loaded / total);
713
+ }
714
+
715
+ // Combine chunks into a single blob
716
+ const blob = new Blob(
717
+ chunks.map(
718
+ chunk =>
719
+ chunk.buffer.slice(
720
+ chunk.byteOffset,
721
+ chunk.byteOffset + chunk.byteLength,
722
+ ) as ArrayBuffer,
723
+ ),
724
+ { type: contentType },
725
+ );
726
+
727
+ const storage = getStorage();
728
+ const result = await storage.upload(key, blob, {
729
+ contentType,
730
+ metadata: options.metadata,
731
+ });
732
+
733
+ return { success: true, data: result };
734
+ } else {
735
+ // Simple stream without progress
736
+ const storage = getStorage();
737
+ const stream = response.body;
738
+
739
+ if (!stream) {
740
+ throw new Error('No response body available');
741
+ }
742
+
743
+ const result = await storage.upload(key, stream, {
744
+ contentType,
745
+ metadata: options?.metadata,
746
+ });
747
+
748
+ return { success: true, data: result };
749
+ }
750
+ } catch (error) {
751
+ return {
752
+ success: false,
753
+ error: error instanceof Error ? error.message : 'Failed to import from URL',
754
+ };
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Helper function to generate storage key from URL
760
+ */
761
+ function generateStorageKey(sourceUrl: string, metadata?: Record<string, any>): string {
762
+ try {
763
+ const url = new URL(sourceUrl);
764
+ const filename = url.pathname.split('/').pop() ?? 'imported-file';
765
+ const timestamp = Date.now();
766
+
767
+ // Determine prefix based on metadata
768
+ let prefix = 'imports';
769
+ if (metadata?.productId) {
770
+ prefix = `products/${metadata.productId}`;
771
+ } else if (metadata?.userId) {
772
+ prefix = `users/${metadata.userId}`;
773
+ } else if (metadata?.type === 'IMAGE') {
774
+ prefix = 'images';
775
+ } else if (metadata?.type === 'VIDEO') {
776
+ prefix = 'videos';
777
+ } else if (metadata?.type === 'DOCUMENT') {
778
+ prefix = 'documents';
779
+ }
780
+
781
+ return `${prefix}/${timestamp}-${filename}`;
782
+ } catch {
783
+ // Fallback for invalid URLs
784
+ return `imports/${Date.now()}-imported-file`;
785
+ }
786
+ }
787
+
788
+ //==============================================================================
789
+ // MULTI-STORAGE OPERATIONS
790
+ //==============================================================================
791
+
792
+ /**
793
+ * Upload to a specific storage provider
794
+ */
795
+ export async function uploadToProviderAction(
796
+ providerName: string,
797
+ key: string,
798
+ data: ArrayBuffer | Blob | Buffer | File | ReadableStream,
799
+ options?: UploadOptions,
800
+ ): Promise<MediaActionResponse<StorageObject>> {
801
+ 'use server';
802
+ try {
803
+ const provider = getMultiStorage().getProvider(providerName);
804
+ if (!provider) {
805
+ throw new Error(`Provider '${providerName}' not found`);
806
+ }
807
+ const result = await provider.upload(key, data, options);
808
+ return { success: true, data: result };
809
+ } catch (error) {
810
+ return {
811
+ success: false,
812
+ error: error instanceof Error ? error.message : 'Failed to upload to provider',
813
+ };
814
+ }
815
+ }
816
+
817
+ /**
818
+ * List available storage providers
819
+ */
820
+ export async function listProvidersAction(): Promise<MediaActionResponse<string[]>> {
821
+ 'use server';
822
+ try {
823
+ const providers = getMultiStorage().getProviderNames();
824
+ return { success: true, data: providers };
825
+ } catch (error) {
826
+ return {
827
+ success: false,
828
+ error: error instanceof Error ? error.message : 'Failed to list providers',
829
+ };
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Copy media between providers
835
+ */
836
+ export async function copyBetweenProvidersAction(
837
+ sourceProvider: string,
838
+ destinationProvider: string,
839
+ key: string,
840
+ options?: UploadOptions,
841
+ ): Promise<MediaActionResponse<StorageObject>> {
842
+ 'use server';
843
+ try {
844
+ const multiStorage = getMultiStorage();
845
+ const source = multiStorage.getProvider(sourceProvider);
846
+ const destination = multiStorage.getProvider(destinationProvider);
847
+
848
+ if (!source) {
849
+ throw new Error(`Source provider '${sourceProvider}' not found`);
850
+ }
851
+ if (!destination) {
852
+ throw new Error(`Destination provider '${destinationProvider}' not found`);
853
+ }
854
+
855
+ // Download from source
856
+ const blob = await source.download(key);
857
+ const metadata = await source.getMetadata(key);
858
+
859
+ // Upload to destination
860
+ const result = await destination.upload(key, blob, {
861
+ contentType: metadata.contentType,
862
+ ...options,
863
+ });
864
+
865
+ return { success: true, data: result };
866
+ } catch (error) {
867
+ return {
868
+ success: false,
869
+ error: error instanceof Error ? error.message : 'Failed to copy between providers',
870
+ };
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Create an empty folder (placeholder with trailing slash)
876
+ */
877
+ export async function createFolderAction(
878
+ key: string,
879
+ options?: UploadOptions,
880
+ ): Promise<MediaActionResponse<StorageObject>> {
881
+ 'use server';
882
+ try {
883
+ // Ensure key ends with slash for folder
884
+ const folderKey = key.endsWith('/') ? key : `${key}/`;
885
+
886
+ // Create empty folder by uploading empty content
887
+ const emptyContent = Buffer.from(new Uint8Array(0));
888
+ const result = await getStorage().upload(folderKey, emptyContent, {
889
+ contentType: 'application/x-directory',
890
+ ...options,
891
+ });
892
+
893
+ return { success: true, data: result };
894
+ } catch (error) {
895
+ return {
896
+ success: false,
897
+ error: error instanceof Error ? error.message : 'Failed to create folder',
898
+ };
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Copy media between storage locations
904
+ */
905
+ export async function copyMediaAction(
906
+ sourceKey: string,
907
+ destinationKey: string,
908
+ options?: UploadOptions,
909
+ ): Promise<MediaActionResponse<StorageObject>> {
910
+ 'use server';
911
+ try {
912
+ // Download from source
913
+ const blob = await getStorage().download(sourceKey);
914
+
915
+ // Upload to destination
916
+ const result = await getStorage().upload(destinationKey, blob, {
917
+ contentType: options?.contentType,
918
+ ...options,
919
+ });
920
+
921
+ return { success: true, data: result };
922
+ } catch (error) {
923
+ return {
924
+ success: false,
925
+ error: error instanceof Error ? error.message : 'Failed to copy media',
926
+ };
927
+ }
928
+ }
929
+
930
+ /**
931
+ * Get presigned upload URL for direct client uploads
932
+ */
933
+ export async function getPresignedUploadUrlAction(
934
+ key: string,
935
+ options?: { expiresIn?: number; contentType?: string },
936
+ ): Promise<MediaActionResponse<{ url: string; fields: Record<string, string>; expiresAt: Date }>> {
937
+ 'use server';
938
+ try {
939
+ const provider = getStorage();
940
+
941
+ if (!provider.getPresignedUploadUrl) {
942
+ return {
943
+ success: false,
944
+ error: 'Provider does not support presigned URLs',
945
+ };
946
+ }
947
+
948
+ const result = await provider.getPresignedUploadUrl(key, options);
949
+
950
+ return { success: true, data: result };
951
+ } catch (error) {
952
+ return {
953
+ success: false,
954
+ error: error instanceof Error ? error.message : 'Failed to get presigned upload URL',
955
+ };
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Get storage provider capabilities
961
+ */
962
+ export async function getStorageCapabilitiesAction(): Promise<
963
+ MediaActionResponse<{
964
+ multipart: boolean;
965
+ presignedUrls: boolean;
966
+ progressTracking: boolean;
967
+ abortSignal: boolean;
968
+ metadata: boolean;
969
+ customDomains: boolean;
970
+ edgeCompatible: boolean;
971
+ }>
972
+ > {
973
+ 'use server';
974
+ try {
975
+ const provider = getStorage();
976
+ const capabilities = provider.getCapabilities?.() ?? {
977
+ multipart: false,
978
+ presignedUrls: false,
979
+ progressTracking: false,
980
+ abortSignal: false,
981
+ metadata: false,
982
+ customDomains: false,
983
+ edgeCompatible: false,
984
+ };
985
+
986
+ return { success: true, data: capabilities };
987
+ } catch (error) {
988
+ return {
989
+ success: false,
990
+ error: error instanceof Error ? error.message : 'Failed to get storage capabilities',
991
+ };
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Validate a file's size, MIME type, and extension against provided constraints.
997
+ *
998
+ * Checks the file's byte size against `maxFileSize`, verifies the MIME type against
999
+ * `allowedMimeTypes` (supports wildcard patterns like `image/*`), and verifies the
1000
+ * filename extension against `allowedExtensions`.
1001
+ *
1002
+ * @param file - The file to validate; must include `size`, `type`, and `name`.
1003
+ * @param options.maxFileSize - Maximum allowed file size in bytes.
1004
+ * @param options.allowedMimeTypes - Allowed MIME types; supports exact types and wildcards (e.g., `image/*`).
1005
+ * @param options.allowedExtensions - Allowed file extensions including the leading dot (e.g., `.jpg`, `.png`).
1006
+ * @returns An object with `valid` set to `true` when no validation errors were found, and `errors` listing any validation messages.
1007
+ */
1008
+ export async function validateFileAction(
1009
+ file: { size: number; type: string; name: string },
1010
+ options?: {
1011
+ maxFileSize?: number;
1012
+ allowedMimeTypes?: string[];
1013
+ allowedExtensions?: string[];
1014
+ },
1015
+ ): Promise<MediaActionResponse<{ valid: boolean; errors: string[] }>> {
1016
+ 'use server';
1017
+ try {
1018
+ const errors: string[] = [];
1019
+
1020
+ // Validate file size
1021
+ if (options?.maxFileSize && file.size > options.maxFileSize) {
1022
+ errors.push(`File size ${file.size} bytes exceeds maximum ${options.maxFileSize} bytes`);
1023
+ }
1024
+
1025
+ // Validate MIME type
1026
+ if (options?.allowedMimeTypes && options.allowedMimeTypes.length > 0) {
1027
+ const normalizedMimeType = file.type.toLowerCase().trim();
1028
+ const normalizedAllowed = options.allowedMimeTypes.map(t => t.toLowerCase().trim());
1029
+
1030
+ if (!normalizedAllowed.includes(normalizedMimeType)) {
1031
+ // Check wildcard patterns
1032
+ const wildcardMatch = normalizedAllowed.some(allowed => {
1033
+ if (allowed.endsWith('/*')) {
1034
+ const prefix = allowed.slice(0, -2);
1035
+ return normalizedMimeType.startsWith(prefix);
1036
+ }
1037
+ return false;
1038
+ });
1039
+
1040
+ if (!wildcardMatch) {
1041
+ errors.push(
1042
+ `MIME type '${file.type}' is not allowed. Allowed types: ${options.allowedMimeTypes.join(', ')}`,
1043
+ );
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ // Validate file extension
1049
+ if (options?.allowedExtensions && options.allowedExtensions.length > 0) {
1050
+ const extension = file.name.match(/\.[^/.]+$/)?.[0]?.toLowerCase();
1051
+ if (extension && !options.allowedExtensions.includes(extension)) {
1052
+ errors.push(
1053
+ `File extension ${extension} is not allowed. Allowed: ${options.allowedExtensions.join(', ')}`,
1054
+ );
1055
+ }
1056
+ }
1057
+
1058
+ return {
1059
+ success: true,
1060
+ data: {
1061
+ valid: errors.length === 0,
1062
+ errors,
1063
+ },
1064
+ };
1065
+ } catch (error) {
1066
+ return {
1067
+ success: false,
1068
+ error: error instanceof Error ? error.message : 'Failed to validate file',
1069
+ };
1070
+ }
1071
+ }