@od-oneapp/storage 2026.2.1501-canary.1

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.
@@ -0,0 +1,1352 @@
1
+ import { r as safeEnv, t as env } from "./env-BVHLmQdh.mjs";
2
+ import { sanitizeStorageKey, validateStorageKey } from "./keys.mjs";
3
+ import { ConfigError, DownloadError, NetworkError, ProviderError, StorageError, StorageErrorCode, UploadError, ValidationError, createStorageError, formatFileSize, getErrorCode, getQuotaInfo, isQuotaExceeded, isRetryableError, parseFileSize, validateFileSize, validateMimeType, validateUploadOptions, validateUrlForSSRF } from "./validation.mjs";
4
+ import { a as getBestProvider, b as CloudflareImagesProvider, c as hasAllCapabilities, d as validateProviderCapabilities, f as MultipartUploadManager, g as MultiStorageManager, h as hasMultipartSupport, i as describeProviderCapabilities, l as hasAnyCapability, m as getOptimalPartSize, n as storageHealthCheck, o as getCapabilityMatrix, p as createMultipartUploadManager, r as checkProviderSuitability, s as getProviderCapabilities, t as checkProviderHealth, u as hasCapability, v as STORAGE_CONSTANTS, x as logError, y as CloudflareR2Provider } from "./health-check-DbHmYLDb.mjs";
5
+ import { t as VercelBlobProvider } from "./vercel-blob-HSvMatlk.mjs";
6
+ import { createStorageProvider, getMultiStorage, getProviderCapabilitiesFromConfig, getStorage, initializeMultiStorage, initializeStorage, multiStorage, resetStorageState, storage, validateStorageConfig } from "./server.mjs";
7
+ import { handleUpload } from "@vercel/blob/client";
8
+
9
+ //#region src/auth-helpers.ts
10
+ /**
11
+ * Retrieves the current authenticated user session
12
+ *
13
+ * This is a placeholder implementation that should be replaced with your actual
14
+ * authentication system integration (NextAuth, @od-oneapp/auth, Clerk, etc.).
15
+ *
16
+ * **Implementation Pattern:**
17
+ * ```typescript
18
+ * // Example with @od-oneapp/auth:
19
+ * const { auth } = await import('@od-oneapp/auth/server');
20
+ * return await auth();
21
+ *
22
+ * // Example with NextAuth:
23
+ * const { getServerSession } = await import('next-auth');
24
+ * return await getServerSession(authOptions);
25
+ * ```
26
+ *
27
+ * @returns Session object containing user info, or `null` if unauthenticated
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * export async function uploadMediaAction(key: string, data: Blob) {
32
+ * 'use server';
33
+ *
34
+ * const session = await getSession();
35
+ * if (!session?.user) {
36
+ * return { success: false, error: 'Unauthorized' };
37
+ * }
38
+ *
39
+ * // Proceed with authenticated upload
40
+ * const storage = getStorage();
41
+ * const result = await storage.upload(key, data);
42
+ *
43
+ * return { success: true, data: result };
44
+ * }
45
+ * ```
46
+ */
47
+ async function getSession() {
48
+ try {
49
+ return null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Checks if a user has permission to manage a specific product
56
+ *
57
+ * This is a placeholder that returns `false` by default (deny-by-default security).
58
+ * Replace with your actual authorization logic that checks:
59
+ * - Product ownership
60
+ * - Role-based permissions (admin, editor, viewer)
61
+ * - Team/organization membership
62
+ *
63
+ * **Implementation Pattern:**
64
+ * ```typescript
65
+ * // Example with database check:
66
+ * const product = await db.product.findUnique({
67
+ * where: { id: productId },
68
+ * include: { team: { members: true } }
69
+ * });
70
+ *
71
+ * return product?.ownerId === userId ||
72
+ * product?.team?.members.some(m => m.userId === userId && m.role === 'admin');
73
+ * ```
74
+ *
75
+ * @param userId - User ID to authorize
76
+ * @param productId - Product ID to check access for
77
+ * @returns `true` if user can manage the product, `false` otherwise
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * export async function deleteProductMediaAction(mediaId: string) {
82
+ * 'use server';
83
+ *
84
+ * const session = await getSession();
85
+ * if (!session?.user) {
86
+ * return { success: false, error: 'Unauthorized' };
87
+ * }
88
+ *
89
+ * const media = await db.productMedia.findUnique({ where: { id: mediaId } });
90
+ * if (!media) {
91
+ * return { success: false, error: 'Not found' };
92
+ * }
93
+ *
94
+ * const canManage = await canUserManageProduct(session.user.id, media.productId);
95
+ * if (!canManage) {
96
+ * return { success: false, error: 'Forbidden' };
97
+ * }
98
+ *
99
+ * await getStorage().delete(media.storageKey);
100
+ * return { success: true };
101
+ * }
102
+ * ```
103
+ */
104
+ async function canUserManageProduct(_userId, _productId) {
105
+ return false;
106
+ }
107
+ /**
108
+ * Extracts client IP address or identifier for rate limiting
109
+ *
110
+ * This placeholder returns `'unknown'`. Implement proper IP extraction based on:
111
+ * - Proxy headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP)
112
+ * - Direct connection IP
113
+ * - User ID for authenticated rate limiting
114
+ *
115
+ * **Implementation Pattern:**
116
+ * ```typescript
117
+ * export function getClientIP(_request?: Request): string {
118
+ * if (!request) return 'unknown';
119
+ *
120
+ * // Cloudflare
121
+ * const cfIP = request.headers.get('cf-connecting-ip');
122
+ * if (cfIP) return cfIP;
123
+ *
124
+ * // Behind proxy
125
+ * const forwarded = request.headers.get('x-forwarded-for');
126
+ * if (forwarded) return forwarded.split(',')[0]?.trim() || 'unknown';
127
+ *
128
+ * // Direct connection
129
+ * const realIP = request.headers.get('x-real-ip');
130
+ * return realIP || 'unknown';
131
+ * }
132
+ * ```
133
+ *
134
+ * @param request - Optional Request object to extract IP from
135
+ * @returns Client IP address or `'unknown'` if not determinable
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * export async function uploadAction(request: Request, data: FormData) {
140
+ * 'use server';
141
+ *
142
+ * const clientIP = getClientIP(request);
143
+ * const rateLimit = await checkRateLimit(
144
+ * clientIP,
145
+ * 'storage:upload',
146
+ * 100, // max requests
147
+ * 60000 // per minute
148
+ * );
149
+ *
150
+ * if (!rateLimit.allowed) {
151
+ * return {
152
+ * success: false,
153
+ * error: 'Rate limit exceeded',
154
+ * resetAt: rateLimit.resetAt
155
+ * };
156
+ * }
157
+ *
158
+ * // Process upload...
159
+ * }
160
+ * ```
161
+ */
162
+ function getClientIP(_request) {
163
+ return "unknown";
164
+ }
165
+ /**
166
+ * Checks if an identifier has exceeded rate limits for a storage action
167
+ *
168
+ * This placeholder always allows requests when `STORAGE_ENABLE_RATE_LIMIT=false`.
169
+ * When enabled, implement with Redis, Upstash, or in-memory store.
170
+ *
171
+ * **Implementation Pattern (Redis):**
172
+ * ```typescript
173
+ * import { Redis } from '@upstash/redis';
174
+ *
175
+ * export async function checkRateLimit(
176
+ * identifier: string,
177
+ * action: string,
178
+ * maxRequests: number,
179
+ * windowMs: number
180
+ * ) {
181
+ * const redis = Redis.fromEnv();
182
+ * const key = `ratelimit:${action}:${identifier}`;
183
+ *
184
+ * const count = await redis.incr(key);
185
+ * if (count === 1) {
186
+ * await redis.pexpire(key, windowMs);
187
+ * }
188
+ *
189
+ * const ttl = await redis.pttl(key);
190
+ * const resetAt = new Date(Date.now() + ttl);
191
+ *
192
+ * return {
193
+ * allowed: count <= maxRequests,
194
+ * remaining: Math.max(0, maxRequests - count),
195
+ * resetAt
196
+ * };
197
+ * }
198
+ * ```
199
+ *
200
+ * @param identifier - Unique identifier (IP address, user ID, API key)
201
+ * @param action - Action key for rate limiting (e.g., `'storage:upload'`, `'storage:delete'`)
202
+ * @param maxRequests - Maximum requests allowed in the time window
203
+ * @param windowMs - Time window in milliseconds (e.g., 60000 for 1 minute)
204
+ * @returns Rate limit status with allowed flag, remaining count, and reset time
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * export async function uploadMediaAction(key: string, data: Blob) {
209
+ * 'use server';
210
+ *
211
+ * const session = await getSession();
212
+ * const identifier = session?.user.id || getClientIP();
213
+ *
214
+ * const limit = await checkRateLimit(identifier, 'storage:upload', 100, 60000);
215
+ * if (!limit.allowed) {
216
+ * return {
217
+ * success: false,
218
+ * error: `Rate limit exceeded. ${limit.remaining} requests remaining. Resets at ${limit.resetAt.toISOString()}`,
219
+ * };
220
+ * }
221
+ *
222
+ * const storage = getStorage();
223
+ * const result = await storage.upload(key, data);
224
+ * return { success: true, data: result };
225
+ * }
226
+ * ```
227
+ */
228
+ async function checkRateLimit(identifier, action, maxRequests, windowMs) {
229
+ const { safeEnv } = await import("./index.mjs");
230
+ if (!safeEnv().STORAGE_ENABLE_RATE_LIMIT) return {
231
+ allowed: true,
232
+ remaining: maxRequests,
233
+ resetAt: new Date(Date.now() + windowMs)
234
+ };
235
+ return {
236
+ allowed: true,
237
+ remaining: Math.max(0, maxRequests - 1),
238
+ resetAt: new Date(Date.now() + windowMs)
239
+ };
240
+ }
241
+ /**
242
+ * Validate a CSRF token according to runtime enforcement settings.
243
+ *
244
+ * If the environment flag `STORAGE_ENFORCE_CSRF` is false or the environment is unavailable, validation is permissive and this function returns `true`. When enforcement is enabled, the function returns `true` only if `token` is a non-empty string.
245
+ *
246
+ * @param token - CSRF token to validate
247
+ * @param request - Optional request object that consuming applications may inspect when implementing real validation
248
+ * @returns `true` if the token is considered valid (or enforcement is disabled / env unavailable), `false` otherwise
249
+ * @deprecated Use validateCSRFOrigin() for better origin-based validation
250
+ */
251
+ function validateCSRFToken(token, _request) {
252
+ try {
253
+ if (!safeEnv().STORAGE_ENFORCE_CSRF) return true;
254
+ } catch {
255
+ return true;
256
+ }
257
+ return typeof token === "string" && token.length > 0;
258
+ }
259
+
260
+ //#endregion
261
+ //#region src/actions/mediaActions.ts
262
+ /**
263
+ * @fileoverview Media storage server actions
264
+ *
265
+ * Provides Next.js server actions for media file operations including upload,
266
+ * download, delete, move, and list operations with authentication and validation.
267
+ *
268
+ * Features:
269
+ * - Authentication and authorization
270
+ * - Rate limiting
271
+ * - File validation (size, MIME type)
272
+ * - SSRF protection
273
+ * - Bulk operations
274
+ *
275
+ * @module @od-oneapp/storage/actions/mediaActions
276
+ */
277
+ /**
278
+ * Upload binary data to the configured storage provider with optional validations.
279
+ *
280
+ * Validations performed before upload include storage key safety checks, optional
281
+ * authentication and rate limiting, maximum file size enforcement, and MIME type
282
+ * validation when `options.allowedMimeTypes` and `options.contentType` are provided.
283
+ *
284
+ * @param key - Destination storage key (path) where the file will be stored
285
+ * @param data - File contents to upload (Buffer, ArrayBuffer, Blob, File, or ReadableStream)
286
+ * @param options - Upload options and validation overrides. Notable fields:
287
+ * - `maxFileSize` — override maximum allowed file size in bytes for this upload
288
+ * - `allowedMimeTypes` — list of permitted MIME types; validated against `options.contentType` if present
289
+ * - other provider-specific upload options (e.g., `contentType`, `metadata`) are passed through to the storage provider
290
+ * @returns An object with `success: true` and the uploaded `StorageObject` on success, or `success: false` and an `error` message on failure
291
+ */
292
+ async function uploadMediaAction(key, data, options) {
293
+ "use server";
294
+ const storageEnv = safeEnv();
295
+ const session = await getSession();
296
+ if (storageEnv.STORAGE_ENFORCE_AUTH && !session?.user) return {
297
+ success: false,
298
+ error: "Unauthorized: Authentication required"
299
+ };
300
+ const rateLimitResult = await checkRateLimit(session?.user?.id ?? getClientIP(), "storage:upload", storageEnv.STORAGE_RATE_LIMIT_REQUESTS, storageEnv.STORAGE_RATE_LIMIT_WINDOW_MS);
301
+ if (storageEnv.STORAGE_ENABLE_RATE_LIMIT && !rateLimitResult.allowed) return {
302
+ success: false,
303
+ error: "Rate limit exceeded. Please try again later."
304
+ };
305
+ try {
306
+ const keyValidation = validateStorageKey(key, {
307
+ maxLength: 1024,
308
+ forbiddenPatterns: [/\.\./, /\/\//]
309
+ });
310
+ if (!keyValidation.valid) return {
311
+ success: false,
312
+ error: `Invalid storage key: ${keyValidation.errors.join(", ")}`
313
+ };
314
+ const maxSize = options?.maxFileSize ?? storageEnv.STORAGE_MAX_FILE_SIZE;
315
+ let fileSize = 0;
316
+ if (data instanceof File || data instanceof Blob) fileSize = data.size;
317
+ else if (data instanceof ArrayBuffer) fileSize = data.byteLength;
318
+ else if (data instanceof Buffer) fileSize = data.length;
319
+ if (fileSize > maxSize) return {
320
+ success: false,
321
+ error: `File size ${fileSize} bytes exceeds maximum ${maxSize} bytes`
322
+ };
323
+ if (options?.allowedMimeTypes && options.contentType) {
324
+ const mimeValidation = validateMimeType(options.contentType, options.allowedMimeTypes);
325
+ if (!mimeValidation.valid) return {
326
+ success: false,
327
+ error: mimeValidation.error ?? "Invalid file type"
328
+ };
329
+ }
330
+ return {
331
+ success: true,
332
+ data: await getStorage().upload(key, data, options)
333
+ };
334
+ } catch (error) {
335
+ return {
336
+ success: false,
337
+ error: error instanceof Error ? error.message : "Failed to upload media"
338
+ };
339
+ }
340
+ }
341
+ /**
342
+ * Get media file metadata
343
+ */
344
+ async function getMediaAction(key) {
345
+ "use server";
346
+ try {
347
+ return {
348
+ success: true,
349
+ data: await getStorage().getMetadata(key)
350
+ };
351
+ } catch (error) {
352
+ return {
353
+ success: false,
354
+ error: error instanceof Error ? error.message : "Failed to get media metadata"
355
+ };
356
+ }
357
+ }
358
+ /**
359
+ * List media files
360
+ */
361
+ async function listMediaAction(options) {
362
+ "use server";
363
+ try {
364
+ return {
365
+ success: true,
366
+ data: await getStorage().list(options)
367
+ };
368
+ } catch (error) {
369
+ return {
370
+ success: false,
371
+ error: error instanceof Error ? error.message : "Failed to list media"
372
+ };
373
+ }
374
+ }
375
+ /**
376
+ * Delete a media file
377
+ */
378
+ async function deleteMediaAction(key) {
379
+ "use server";
380
+ try {
381
+ await getStorage().delete(key);
382
+ return { success: true };
383
+ } catch (error) {
384
+ return {
385
+ success: false,
386
+ error: error instanceof Error ? error.message : "Failed to delete media"
387
+ };
388
+ }
389
+ }
390
+ /**
391
+ * Determine whether a media object exists at the given storage key.
392
+ *
393
+ * @param key - The storage key (path) of the media object to check
394
+ * @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
395
+ */
396
+ async function existsMediaAction(key) {
397
+ "use server";
398
+ try {
399
+ return {
400
+ success: true,
401
+ data: await getStorage().exists(key)
402
+ };
403
+ } catch (error) {
404
+ return {
405
+ success: false,
406
+ error: error instanceof Error ? error.message : "Failed to check media existence"
407
+ };
408
+ }
409
+ }
410
+ /**
411
+ * Retrieve a public or signed URL for a media object.
412
+ *
413
+ * When the context is `product`, when `forceSign` is true, or when `expiresIn` is provided,
414
+ * a signed URL is returned using the specified or default expiry; otherwise a direct URL is returned.
415
+ *
416
+ * @param key - The storage key of the media object
417
+ * @param options.expiresIn - Expiration time in seconds for a signed URL
418
+ * @param options.context - Context of the media; `product` forces signed URLs for product photos
419
+ * @param options.forceSign - When true, force generation of a signed URL regardless of context
420
+ * @returns The URL for the storage key (signed if signing rules apply)
421
+ */
422
+ async function getMediaUrlAction(key, options) {
423
+ "use server";
424
+ try {
425
+ const storage = getStorage();
426
+ const isProductPhoto = options?.context === "product" || key.includes("/products/");
427
+ if ((isProductPhoto || options?.forceSign) ?? Boolean(options?.expiresIn)) {
428
+ const expiresIn = options?.expiresIn ?? (isProductPhoto ? STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS : STORAGE_CONSTANTS.UPLOAD_URL_EXPIRY_SECONDS);
429
+ return {
430
+ success: true,
431
+ data: await storage.getUrl(key, { expiresIn })
432
+ };
433
+ }
434
+ return {
435
+ success: true,
436
+ data: await storage.getUrl(key)
437
+ };
438
+ } catch (error) {
439
+ return {
440
+ success: false,
441
+ error: error instanceof Error ? error.message : "Failed to get media URL"
442
+ };
443
+ }
444
+ }
445
+ /**
446
+ * Generate signed URLs for multiple product media keys.
447
+ *
448
+ * Appends the provided `variant` to keys that reference Cloudflare Images and uses
449
+ * `options.expiresIn` or the product URL expiry constant as the signing duration.
450
+ *
451
+ * @param keys - Array of storage keys identifying product media items
452
+ * @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
453
+ * @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
454
+ */
455
+ async function getProductMediaUrlsAction(keys, options) {
456
+ "use server";
457
+ try {
458
+ const storage = getStorage();
459
+ const expiresIn = options?.expiresIn ?? STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS;
460
+ return {
461
+ success: true,
462
+ data: await Promise.all(keys.map(async (key) => {
463
+ let finalKey = key;
464
+ if (options?.variant && key.includes("cloudflare-images")) finalKey = `${key}/${options.variant}`;
465
+ return {
466
+ key,
467
+ url: await storage.getUrl(finalKey, { expiresIn })
468
+ };
469
+ }))
470
+ };
471
+ } catch (error) {
472
+ return {
473
+ success: false,
474
+ error: error instanceof Error ? error.message : "Failed to get product media URLs"
475
+ };
476
+ }
477
+ }
478
+ /**
479
+ * Get presigned upload URL for product photos (admin only)
480
+ */
481
+ async function getProductUploadUrlAction(filename, productId, options) {
482
+ "use server";
483
+ try {
484
+ const storage = getStorage();
485
+ const key = `products/${productId}/${Date.now()}-${filename}`;
486
+ return {
487
+ success: true,
488
+ data: {
489
+ uploadUrl: await storage.getUrl(key, { expiresIn: options?.expiresIn ?? 1800 }),
490
+ key
491
+ }
492
+ };
493
+ } catch (error) {
494
+ return {
495
+ success: false,
496
+ error: error instanceof Error ? error.message : "Failed to get product upload URL"
497
+ };
498
+ }
499
+ }
500
+ /**
501
+ * Download a media file
502
+ */
503
+ async function downloadMediaAction(key) {
504
+ "use server";
505
+ try {
506
+ return {
507
+ success: true,
508
+ data: await getStorage().download(key)
509
+ };
510
+ } catch (error) {
511
+ return {
512
+ success: false,
513
+ error: error instanceof Error ? error.message : "Failed to download media"
514
+ };
515
+ }
516
+ }
517
+ /**
518
+ * Delete multiple media files
519
+ */
520
+ async function bulkDeleteMediaAction(keys) {
521
+ "use server";
522
+ const results = {
523
+ succeeded: [],
524
+ failed: []
525
+ };
526
+ try {
527
+ await Promise.all(keys.map(async (key) => {
528
+ try {
529
+ await getStorage().delete(key);
530
+ results.succeeded.push(key);
531
+ } catch (error) {
532
+ results.failed.push({
533
+ key,
534
+ error: error instanceof Error ? error.message : "Unknown error"
535
+ });
536
+ }
537
+ }));
538
+ return {
539
+ success: results.failed.length === 0,
540
+ data: results
541
+ };
542
+ } catch (error) {
543
+ return {
544
+ success: false,
545
+ error: error instanceof Error ? error.message : "Bulk delete operation failed"
546
+ };
547
+ }
548
+ }
549
+ /**
550
+ * Move/rename multiple media files
551
+ */
552
+ async function bulkMoveMediaAction(operations) {
553
+ "use server";
554
+ const results = {
555
+ succeeded: [],
556
+ failed: []
557
+ };
558
+ try {
559
+ await Promise.all(operations.map(async ({ sourceKey, destinationKey }) => {
560
+ try {
561
+ const blob = await getStorage().download(sourceKey);
562
+ const metadata = await getStorage().getMetadata(sourceKey);
563
+ await getStorage().upload(destinationKey, blob, { contentType: metadata.contentType });
564
+ await getStorage().delete(sourceKey);
565
+ results.succeeded.push({
566
+ sourceKey,
567
+ destinationKey
568
+ });
569
+ } catch (error) {
570
+ results.failed.push({
571
+ sourceKey,
572
+ destinationKey,
573
+ error: error instanceof Error ? error.message : "Unknown error"
574
+ });
575
+ }
576
+ }));
577
+ return {
578
+ success: results.failed.length === 0,
579
+ data: results
580
+ };
581
+ } catch (error) {
582
+ return {
583
+ success: false,
584
+ error: error instanceof Error ? error.message : "Bulk move operation failed"
585
+ };
586
+ }
587
+ }
588
+ /**
589
+ * Imports multiple external URLs into storage, processing them in configurable batches.
590
+ *
591
+ * Each source URL is validated for safety, fetched with a per-request timeout, and streamed
592
+ * into the chosen storage provider (or routed to Cloudflare Images for images when available).
593
+ * Successful imports are recorded with their destination key and resulting StorageObject;
594
+ * failures record the source URL and an error message. Processing continues after individual failures.
595
+ *
596
+ * @param imports - Array of import entries, each with a required `sourceUrl`, optional `destinationKey`
597
+ * (if omitted a key is generated), and optional `metadata` (altText, productId, userId, type).
598
+ * @param options - Optional settings:
599
+ * - `batchSize`: number of concurrent imports per batch (defaults to STORAGE_CONSTANTS.DEFAULT_BATCH_SIZE).
600
+ * - `provider`: name of the storage provider to use (if omitted uses default provider or Cloudflare Images for images).
601
+ * - `timeout`: per-request fetch timeout in milliseconds (defaults to STORAGE_CONSTANTS.DEFAULT_REQUEST_TIMEOUT_MS).
602
+ * @returns An object with:
603
+ * - `succeeded`: array of { sourceUrl, destinationKey, storageObject } for successful imports;
604
+ * - `failed`: array of { sourceUrl, error } for failed imports;
605
+ * - `totalProcessed`: total number of import attempts processed.
606
+ */
607
+ async function bulkImportFromUrlsAction(imports, options) {
608
+ "use server";
609
+ const results = {
610
+ succeeded: [],
611
+ failed: [],
612
+ totalProcessed: 0
613
+ };
614
+ const batchSize = options?.batchSize ?? STORAGE_CONSTANTS.DEFAULT_BATCH_SIZE;
615
+ const timeout = options?.timeout ?? STORAGE_CONSTANTS.DEFAULT_REQUEST_TIMEOUT_MS;
616
+ try {
617
+ for (let i = 0; i < imports.length; i += batchSize) {
618
+ const batch = imports.slice(i, i + batchSize);
619
+ await Promise.all(batch.map(async (importItem) => {
620
+ try {
621
+ const urlValidation = validateUrlForSSRF(importItem.sourceUrl);
622
+ if (!urlValidation.valid) throw new Error(urlValidation.error ?? "Invalid or unsafe URL");
623
+ const controller = new AbortController();
624
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
625
+ const response = await fetch(importItem.sourceUrl, {
626
+ signal: controller.signal,
627
+ headers: { "User-Agent": "Storage-Service/1.0" },
628
+ redirect: "error"
629
+ });
630
+ clearTimeout(timeoutId);
631
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
632
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
633
+ const isImage = contentType.startsWith("image/");
634
+ const destinationKey = importItem.destinationKey ?? generateStorageKey(importItem.sourceUrl, importItem.metadata);
635
+ const storage = options?.provider ? getMultiStorage().getProvider(options.provider) : getStorage();
636
+ if (!storage) throw new Error("Storage provider not available");
637
+ if (isImage && !options?.provider) {
638
+ const imageProvider = getMultiStorage().getProvider("cloudflare-images");
639
+ if (imageProvider) {
640
+ const blob = await response.blob();
641
+ const result = await imageProvider.upload(destinationKey, blob, {
642
+ contentType,
643
+ metadata: importItem.metadata
644
+ });
645
+ results.succeeded.push({
646
+ sourceUrl: importItem.sourceUrl,
647
+ destinationKey,
648
+ storageObject: result
649
+ });
650
+ results.totalProcessed++;
651
+ return;
652
+ }
653
+ }
654
+ const stream = response.body;
655
+ if (!stream) throw new Error("No response body available");
656
+ const result = await storage.upload(destinationKey, stream, {
657
+ contentType,
658
+ metadata: importItem.metadata
659
+ });
660
+ results.succeeded.push({
661
+ sourceUrl: importItem.sourceUrl,
662
+ destinationKey,
663
+ storageObject: result
664
+ });
665
+ results.totalProcessed++;
666
+ } catch (error) {
667
+ results.failed.push({
668
+ sourceUrl: importItem.sourceUrl,
669
+ error: error instanceof Error ? error.message : "Unknown error"
670
+ });
671
+ results.totalProcessed++;
672
+ }
673
+ }));
674
+ }
675
+ return {
676
+ success: results.failed.length === 0,
677
+ data: results
678
+ };
679
+ } catch (error) {
680
+ return {
681
+ success: false,
682
+ error: error instanceof Error ? error.message : "Bulk import operation failed",
683
+ data: results
684
+ };
685
+ }
686
+ }
687
+ /**
688
+ * Import a single media item from a remote HTTPS URL into storage.
689
+ *
690
+ * Validates the source URL for safety, fetches the resource, and uploads it to storage.
691
+ * If `options.onProgress` is provided and the response exposes a `content-length`, progress
692
+ * updates are emitted as a fraction between 0 and 1 during download.
693
+ *
694
+ * @param sourceUrl - The HTTPS URL of the resource to import; must pass SSRF-safe validation.
695
+ * @param destinationKey - Optional destination storage key; generated automatically when omitted.
696
+ * @param options.metadata - Optional metadata to attach to the uploaded object.
697
+ * @param options.onProgress - Optional callback invoked with a number in [0, 1] representing download progress.
698
+ * @returns On success, a response containing the uploaded `StorageObject`; on failure, a response containing an error message.
699
+ */
700
+ async function importFromUrlAction(sourceUrl, destinationKey, options) {
701
+ "use server";
702
+ try {
703
+ const urlValidation = validateUrlForSSRF(sourceUrl);
704
+ if (!urlValidation.valid) throw new Error(urlValidation.error ?? "Invalid or unsafe URL");
705
+ const response = await fetch(sourceUrl, {
706
+ headers: { "User-Agent": "Storage-Service/1.0" },
707
+ redirect: "error"
708
+ });
709
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
710
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
711
+ const contentLength = response.headers.get("content-length");
712
+ const key = destinationKey ?? generateStorageKey(sourceUrl);
713
+ if (options?.onProgress && contentLength && response.body) {
714
+ const total = parseInt(contentLength);
715
+ let loaded = 0;
716
+ const reader = response.body.getReader();
717
+ const chunks = [];
718
+ while (true) {
719
+ const { done, value } = await reader.read();
720
+ if (done) break;
721
+ chunks.push(value);
722
+ loaded += value.length;
723
+ options.onProgress(loaded / total);
724
+ }
725
+ const blob = new Blob(chunks.map((chunk) => chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)), { type: contentType });
726
+ return {
727
+ success: true,
728
+ data: await getStorage().upload(key, blob, {
729
+ contentType,
730
+ metadata: options.metadata
731
+ })
732
+ };
733
+ } else {
734
+ const storage = getStorage();
735
+ const stream = response.body;
736
+ if (!stream) throw new Error("No response body available");
737
+ return {
738
+ success: true,
739
+ data: await storage.upload(key, stream, {
740
+ contentType,
741
+ metadata: options?.metadata
742
+ })
743
+ };
744
+ }
745
+ } catch (error) {
746
+ return {
747
+ success: false,
748
+ error: error instanceof Error ? error.message : "Failed to import from URL"
749
+ };
750
+ }
751
+ }
752
+ /**
753
+ * Helper function to generate storage key from URL
754
+ */
755
+ function generateStorageKey(sourceUrl, metadata) {
756
+ try {
757
+ const filename = new URL(sourceUrl).pathname.split("/").pop() ?? "imported-file";
758
+ const timestamp = Date.now();
759
+ let prefix = "imports";
760
+ if (metadata?.productId) prefix = `products/${metadata.productId}`;
761
+ else if (metadata?.userId) prefix = `users/${metadata.userId}`;
762
+ else if (metadata?.type === "IMAGE") prefix = "images";
763
+ else if (metadata?.type === "VIDEO") prefix = "videos";
764
+ else if (metadata?.type === "DOCUMENT") prefix = "documents";
765
+ return `${prefix}/${timestamp}-${filename}`;
766
+ } catch {
767
+ return `imports/${Date.now()}-imported-file`;
768
+ }
769
+ }
770
+ /**
771
+ * Upload to a specific storage provider
772
+ */
773
+ async function uploadToProviderAction(providerName, key, data, options) {
774
+ "use server";
775
+ try {
776
+ const provider = getMultiStorage().getProvider(providerName);
777
+ if (!provider) throw new Error(`Provider '${providerName}' not found`);
778
+ return {
779
+ success: true,
780
+ data: await provider.upload(key, data, options)
781
+ };
782
+ } catch (error) {
783
+ return {
784
+ success: false,
785
+ error: error instanceof Error ? error.message : "Failed to upload to provider"
786
+ };
787
+ }
788
+ }
789
+ /**
790
+ * List available storage providers
791
+ */
792
+ async function listProvidersAction() {
793
+ "use server";
794
+ try {
795
+ return {
796
+ success: true,
797
+ data: getMultiStorage().getProviderNames()
798
+ };
799
+ } catch (error) {
800
+ return {
801
+ success: false,
802
+ error: error instanceof Error ? error.message : "Failed to list providers"
803
+ };
804
+ }
805
+ }
806
+ /**
807
+ * Copy media between providers
808
+ */
809
+ async function copyBetweenProvidersAction(sourceProvider, destinationProvider, key, options) {
810
+ "use server";
811
+ try {
812
+ const multiStorage = getMultiStorage();
813
+ const source = multiStorage.getProvider(sourceProvider);
814
+ const destination = multiStorage.getProvider(destinationProvider);
815
+ if (!source) throw new Error(`Source provider '${sourceProvider}' not found`);
816
+ if (!destination) throw new Error(`Destination provider '${destinationProvider}' not found`);
817
+ const blob = await source.download(key);
818
+ const metadata = await source.getMetadata(key);
819
+ return {
820
+ success: true,
821
+ data: await destination.upload(key, blob, {
822
+ contentType: metadata.contentType,
823
+ ...options
824
+ })
825
+ };
826
+ } catch (error) {
827
+ return {
828
+ success: false,
829
+ error: error instanceof Error ? error.message : "Failed to copy between providers"
830
+ };
831
+ }
832
+ }
833
+ /**
834
+ * Create an empty folder (placeholder with trailing slash)
835
+ */
836
+ async function createFolderAction(key, options) {
837
+ "use server";
838
+ try {
839
+ const folderKey = key.endsWith("/") ? key : `${key}/`;
840
+ const emptyContent = Buffer.from(new Uint8Array(0));
841
+ return {
842
+ success: true,
843
+ data: await getStorage().upload(folderKey, emptyContent, {
844
+ contentType: "application/x-directory",
845
+ ...options
846
+ })
847
+ };
848
+ } catch (error) {
849
+ return {
850
+ success: false,
851
+ error: error instanceof Error ? error.message : "Failed to create folder"
852
+ };
853
+ }
854
+ }
855
+ /**
856
+ * Copy media between storage locations
857
+ */
858
+ async function copyMediaAction(sourceKey, destinationKey, options) {
859
+ "use server";
860
+ try {
861
+ const blob = await getStorage().download(sourceKey);
862
+ return {
863
+ success: true,
864
+ data: await getStorage().upload(destinationKey, blob, {
865
+ contentType: options?.contentType,
866
+ ...options
867
+ })
868
+ };
869
+ } catch (error) {
870
+ return {
871
+ success: false,
872
+ error: error instanceof Error ? error.message : "Failed to copy media"
873
+ };
874
+ }
875
+ }
876
+ /**
877
+ * Get presigned upload URL for direct client uploads
878
+ */
879
+ async function getPresignedUploadUrlAction(key, options) {
880
+ "use server";
881
+ try {
882
+ const provider = getStorage();
883
+ if (!provider.getPresignedUploadUrl) return {
884
+ success: false,
885
+ error: "Provider does not support presigned URLs"
886
+ };
887
+ return {
888
+ success: true,
889
+ data: await provider.getPresignedUploadUrl(key, options)
890
+ };
891
+ } catch (error) {
892
+ return {
893
+ success: false,
894
+ error: error instanceof Error ? error.message : "Failed to get presigned upload URL"
895
+ };
896
+ }
897
+ }
898
+ /**
899
+ * Get storage provider capabilities
900
+ */
901
+ async function getStorageCapabilitiesAction() {
902
+ "use server";
903
+ try {
904
+ return {
905
+ success: true,
906
+ data: getStorage().getCapabilities?.() ?? {
907
+ multipart: false,
908
+ presignedUrls: false,
909
+ progressTracking: false,
910
+ abortSignal: false,
911
+ metadata: false,
912
+ customDomains: false,
913
+ edgeCompatible: false
914
+ }
915
+ };
916
+ } catch (error) {
917
+ return {
918
+ success: false,
919
+ error: error instanceof Error ? error.message : "Failed to get storage capabilities"
920
+ };
921
+ }
922
+ }
923
+ /**
924
+ * Validate a file's size, MIME type, and extension against provided constraints.
925
+ *
926
+ * Checks the file's byte size against `maxFileSize`, verifies the MIME type against
927
+ * `allowedMimeTypes` (supports wildcard patterns like `image/*`), and verifies the
928
+ * filename extension against `allowedExtensions`.
929
+ *
930
+ * @param file - The file to validate; must include `size`, `type`, and `name`.
931
+ * @param options.maxFileSize - Maximum allowed file size in bytes.
932
+ * @param options.allowedMimeTypes - Allowed MIME types; supports exact types and wildcards (e.g., `image/*`).
933
+ * @param options.allowedExtensions - Allowed file extensions including the leading dot (e.g., `.jpg`, `.png`).
934
+ * @returns An object with `valid` set to `true` when no validation errors were found, and `errors` listing any validation messages.
935
+ */
936
+ async function validateFileAction(file, options) {
937
+ "use server";
938
+ try {
939
+ const errors = [];
940
+ if (options?.maxFileSize && file.size > options.maxFileSize) errors.push(`File size ${file.size} bytes exceeds maximum ${options.maxFileSize} bytes`);
941
+ if (options?.allowedMimeTypes && options.allowedMimeTypes.length > 0) {
942
+ const normalizedMimeType = file.type.toLowerCase().trim();
943
+ const normalizedAllowed = options.allowedMimeTypes.map((t) => t.toLowerCase().trim());
944
+ if (!normalizedAllowed.includes(normalizedMimeType)) {
945
+ if (!normalizedAllowed.some((allowed) => {
946
+ if (allowed.endsWith("/*")) {
947
+ const prefix = allowed.slice(0, -2);
948
+ return normalizedMimeType.startsWith(prefix);
949
+ }
950
+ return false;
951
+ })) errors.push(`MIME type '${file.type}' is not allowed. Allowed types: ${options.allowedMimeTypes.join(", ")}`);
952
+ }
953
+ }
954
+ if (options?.allowedExtensions && options.allowedExtensions.length > 0) {
955
+ const extension = file.name.match(/\.[^/.]+$/)?.[0]?.toLowerCase();
956
+ if (extension && !options.allowedExtensions.includes(extension)) errors.push(`File extension ${extension} is not allowed. Allowed: ${options.allowedExtensions.join(", ")}`);
957
+ }
958
+ return {
959
+ success: true,
960
+ data: {
961
+ valid: errors.length === 0,
962
+ errors
963
+ }
964
+ };
965
+ } catch (error) {
966
+ return {
967
+ success: false,
968
+ error: error instanceof Error ? error.message : "Failed to validate file"
969
+ };
970
+ }
971
+ }
972
+
973
+ //#endregion
974
+ //#region src/actions/productMediaActions.ts
975
+ /**
976
+ * @fileoverview Product media business logic server actions
977
+ *
978
+ * Provides Next.js server actions specifically for product media operations.
979
+ * Includes business logic for product image management with vendor and admin contexts.
980
+ *
981
+ * Features:
982
+ * - Product-specific media uploads
983
+ * - Vendor and admin role support
984
+ * - Product media metadata management
985
+ * - Authorization checks
986
+ *
987
+ * @module @od-oneapp/storage/actions/productMediaActions
988
+ */
989
+ /**
990
+ * Uploads multiple media files for a product while enforcing optional authentication, authorization, and rate limits.
991
+ *
992
+ * @param productId - The ID of the product to attach media to.
993
+ * @param files - Files to upload; each object must include `filename`, `contentType`, and binary `data`.
994
+ * @param options - Optional settings: `context` is the uploader role (`admin` | `vendor`); `altText`, `description`, and `tags` supply metadata.
995
+ * @returns The action response. On success, `data` is an array of uploaded items containing `key`, `url`, and a temporary `mediaId`; on failure, `error` describes the problem.
996
+ */
997
+ async function uploadProductMediaAction(productId, files, options) {
998
+ "use server";
999
+ try {
1000
+ const featureEnv = safeEnv();
1001
+ const session = await getSession();
1002
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) return {
1003
+ success: false,
1004
+ error: "Unauthorized: Authentication required"
1005
+ };
1006
+ const hasPermission = session?.user ? await canUserManageProduct(session.user.id, productId) : false;
1007
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) return {
1008
+ success: false,
1009
+ error: "Forbidden: Insufficient permissions"
1010
+ };
1011
+ const env = safeEnv();
1012
+ const rateLimitResult = await checkRateLimit(session?.user?.id ?? "anonymous", "storage:upload", env.STORAGE_RATE_LIMIT_REQUESTS, env.STORAGE_RATE_LIMIT_WINDOW_MS);
1013
+ if (env.STORAGE_ENABLE_RATE_LIMIT && !rateLimitResult.allowed) return {
1014
+ success: false,
1015
+ error: "Rate limit exceeded. Please try again later."
1016
+ };
1017
+ const storage = getStorage();
1018
+ const results = [];
1019
+ for (const [index, file] of files.entries()) {
1020
+ const sanitizedFilename = sanitizeStorageKey(file.filename);
1021
+ const timestamp = Date.now();
1022
+ const key = `products/${productId}/images/${timestamp}-${index}-${sanitizedFilename}`;
1023
+ const keyValidation = validateStorageKey(key, {
1024
+ maxLength: 1024,
1025
+ forbiddenPatterns: [/\.\./, /\/\//]
1026
+ });
1027
+ if (!keyValidation.valid) return {
1028
+ success: false,
1029
+ error: `Invalid storage key: ${keyValidation.errors.join(", ")}`
1030
+ };
1031
+ const signedUrl = (await storage.upload(key, file.data, {
1032
+ contentType: file.contentType,
1033
+ metadata: {
1034
+ productId,
1035
+ uploadedBy: options?.context ?? "admin",
1036
+ altText: options?.altText ?? ""
1037
+ }
1038
+ })).url || await storage.getUrl(key, { expiresIn: STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS });
1039
+ results.push({
1040
+ key,
1041
+ url: signedUrl,
1042
+ mediaId: `temp-${timestamp}-${index}`
1043
+ });
1044
+ }
1045
+ return {
1046
+ success: true,
1047
+ data: results
1048
+ };
1049
+ } catch (error) {
1050
+ return {
1051
+ success: false,
1052
+ error: error instanceof Error ? error.message : "Failed to upload product media"
1053
+ };
1054
+ }
1055
+ }
1056
+ /**
1057
+ * Retrieve a product's media entries and attach signed URLs for client access.
1058
+ *
1059
+ * The returned media items include metadata (id, key, altText, sortOrder, contentType, size)
1060
+ * and a signed `url` valid for the configured expiry. If `options.variant` is provided and is
1061
+ * not `'public'`, the storage key is adjusted to include the variant before generating the URL.
1062
+ *
1063
+ * @param productId - The identifier of the product whose media should be fetched
1064
+ * @param options.context - Request context; affects visibility (e.g., `'admin'` may include deleted items)
1065
+ * @param options.variant - Optional variant key (`'thumbnail' | 'gallery' | 'hero' | 'public'`); when set and not `'public'` the storage key is suffixed with the variant for URL generation
1066
+ * @param options.expiresIn - Signed URL lifetime in seconds (defaults to 3600)
1067
+ * @returns On success, a `MediaActionResponse` containing an array of media items each with `id`, `key`, `url`, optional `altText`, `sortOrder`, `contentType`, and `size`; on failure, an error message describing the problem
1068
+ */
1069
+ async function getProductMediaAction(productId, options) {
1070
+ "use server";
1071
+ try {
1072
+ const productMedia = [{
1073
+ id: "media-1",
1074
+ key: `products/${productId}/images/hero.jpg`,
1075
+ altText: "Product hero image",
1076
+ sortOrder: 0,
1077
+ contentType: "image/jpeg",
1078
+ size: 1024e3
1079
+ }, {
1080
+ id: "media-2",
1081
+ key: `products/${productId}/images/gallery-1.jpg`,
1082
+ altText: "Product gallery image 1",
1083
+ sortOrder: 1,
1084
+ contentType: "image/jpeg",
1085
+ size: 856e3
1086
+ }];
1087
+ const storage = getStorage();
1088
+ const expiresIn = options?.expiresIn ?? 3600;
1089
+ return {
1090
+ success: true,
1091
+ data: await Promise.all(productMedia.map(async (media) => {
1092
+ let { key } = media;
1093
+ if (options?.variant && options.variant !== "public") key = `${media.key}/${options.variant}`;
1094
+ const signedUrl = await storage.getUrl(key, { expiresIn });
1095
+ return {
1096
+ ...media,
1097
+ url: signedUrl
1098
+ };
1099
+ }))
1100
+ };
1101
+ } catch (error) {
1102
+ return {
1103
+ success: false,
1104
+ error: error instanceof Error ? error.message : "Failed to get product media"
1105
+ };
1106
+ }
1107
+ }
1108
+ /**
1109
+ * Delete a product's media entry and remove its stored object when requested.
1110
+ *
1111
+ * Performs authentication and permission checks when enforcement is enabled. By default performs a soft delete of the media record; when `options.hardDelete` is true, also deletes the object from storage.
1112
+ *
1113
+ * @param options - Additional options controlling deletion behavior
1114
+ * @param options.context - Invocation context, either `'admin'` or `'vendor'`
1115
+ * @param options.hardDelete - If `true`, delete the storage object and perform a hard delete; if omitted or `false`, perform a soft delete
1116
+ * @returns A MediaActionResponse<void> indicating success. On failure `success` is `false` and `error` contains a descriptive message.
1117
+ */
1118
+ async function deleteProductMediaAction(productId, mediaId, options) {
1119
+ "use server";
1120
+ try {
1121
+ const featureEnv = safeEnv();
1122
+ const session = await getSession();
1123
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) return {
1124
+ success: false,
1125
+ error: "Unauthorized: Authentication required"
1126
+ };
1127
+ const hasPermission = session?.user ? await canUserManageProduct(session.user.id, productId) : false;
1128
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) return {
1129
+ success: false,
1130
+ error: "Forbidden: Insufficient permissions"
1131
+ };
1132
+ const mediaRecord = {
1133
+ id: mediaId,
1134
+ key: `products/${productId}/images/example.jpg`,
1135
+ productId
1136
+ };
1137
+ if (options?.hardDelete) await getStorage().delete(mediaRecord.key);
1138
+ return { success: true };
1139
+ } catch (error) {
1140
+ return {
1141
+ success: false,
1142
+ error: error instanceof Error ? error.message : "Failed to delete product media"
1143
+ };
1144
+ }
1145
+ }
1146
+ /**
1147
+ * Reorders media items for a product.
1148
+ *
1149
+ * When storage authentication is enforced, this action requires an authenticated user
1150
+ * who has permission to manage the specified product; otherwise it returns an authorization error.
1151
+ *
1152
+ * @param productId - The ID of the product whose media order will be updated
1153
+ * @param mediaOrder - Array of objects mapping `mediaId` to the desired `sortOrder`
1154
+ * @param options.context - Optional caller context, e.g. 'admin' or 'vendor'
1155
+ * @returns An object with `success: true` when the reorder operation completes; otherwise `success: false` and an `error` message describing the failure
1156
+ */
1157
+ async function reorderProductMediaAction(productId, _mediaOrder, _options) {
1158
+ "use server";
1159
+ try {
1160
+ const featureEnv = safeEnv();
1161
+ const session = await getSession();
1162
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) return {
1163
+ success: false,
1164
+ error: "Unauthorized: Authentication required"
1165
+ };
1166
+ const hasPermission = session?.user ? await canUserManageProduct(session.user.id, productId) : false;
1167
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) return {
1168
+ success: false,
1169
+ error: "Forbidden: Insufficient permissions"
1170
+ };
1171
+ return { success: true };
1172
+ } catch (error) {
1173
+ return {
1174
+ success: false,
1175
+ error: error instanceof Error ? error.message : "Failed to reorder product media"
1176
+ };
1177
+ }
1178
+ }
1179
+ /**
1180
+ * Generate presigned upload URLs for client-side direct uploads scoped to a product.
1181
+ *
1182
+ * @param productId - Product identifier used to scope generated storage keys
1183
+ * @param filenames - Desired filenames to include in generated storage keys
1184
+ * @param options - Optional settings for URL generation
1185
+ * @param options.context - The request context, e.g. 'admin' or 'vendor'
1186
+ * @param options.expiresIn - Signed URL lifetime in seconds; defaults to STORAGE_CONSTANTS.UPLOAD_URL_EXPIRY_SECONDS
1187
+ * @param options.maxSizeBytes - Optional maximum allowed upload size in bytes (informational; enforcement depends on storage provider)
1188
+ * @returns A MediaActionResponse containing an array of upload descriptors; each descriptor includes `filename`, `uploadUrl`, `key`, and optional form `fields`
1189
+ */
1190
+ async function getProductUploadPresignedUrlsAction(productId, filenames, options) {
1191
+ "use server";
1192
+ try {
1193
+ const featureEnv = safeEnv();
1194
+ const session = await getSession();
1195
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) return {
1196
+ success: false,
1197
+ error: "Unauthorized: Authentication required"
1198
+ };
1199
+ const hasPermission = session?.user ? await canUserManageProduct(session.user.id, productId) : false;
1200
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) return {
1201
+ success: false,
1202
+ error: "Forbidden: Insufficient permissions"
1203
+ };
1204
+ const storage = getStorage();
1205
+ const expiresIn = options?.expiresIn ?? STORAGE_CONSTANTS.UPLOAD_URL_EXPIRY_SECONDS;
1206
+ return {
1207
+ success: true,
1208
+ data: await Promise.all(filenames.map(async (filename, index) => {
1209
+ const sanitizedFilename = sanitizeStorageKey(filename);
1210
+ const key = `products/${productId}/images/${Date.now()}-${index}-${sanitizedFilename}`;
1211
+ const keyValidation = validateStorageKey(key, {
1212
+ maxLength: 1024,
1213
+ forbiddenPatterns: [/\.\./, /\/\//]
1214
+ });
1215
+ if (!keyValidation.valid) throw new Error(`Invalid storage key: ${keyValidation.errors.join(", ")}`);
1216
+ return {
1217
+ filename,
1218
+ uploadUrl: await storage.getUrl(key, { expiresIn }),
1219
+ key
1220
+ };
1221
+ }))
1222
+ };
1223
+ } catch (error) {
1224
+ return {
1225
+ success: false,
1226
+ error: error instanceof Error ? error.message : "Failed to generate upload URLs"
1227
+ };
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Performs bulk metadata updates for a product's media items.
1232
+ *
1233
+ * @param productId - ID of the product whose media items will be updated
1234
+ * @param updates - List of update objects, each with a `mediaId` and optional `altText`, `description`, and `tags`
1235
+ * @param options - Optional operation context; expected values are `'admin'` or `'vendor'`
1236
+ * @returns A response object indicating success; on failure `error` contains a descriptive message (for example, `Unauthorized: Authentication required` or `Forbidden: Insufficient permissions`)
1237
+ */
1238
+ async function bulkUpdateProductMediaAction(productId, _updates, _options) {
1239
+ "use server";
1240
+ try {
1241
+ const featureEnv = safeEnv();
1242
+ const session = await getSession();
1243
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) return {
1244
+ success: false,
1245
+ error: "Unauthorized: Authentication required"
1246
+ };
1247
+ const hasPermission = session?.user ? await canUserManageProduct(session.user.id, productId) : false;
1248
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) return {
1249
+ success: false,
1250
+ error: "Forbidden: Insufficient permissions"
1251
+ };
1252
+ return { success: true };
1253
+ } catch (error) {
1254
+ return {
1255
+ success: false,
1256
+ error: error instanceof Error ? error.message : "Failed to bulk update product media"
1257
+ };
1258
+ }
1259
+ }
1260
+
1261
+ //#endregion
1262
+ //#region src/actions/blob-upload.ts
1263
+ /**
1264
+ * @fileoverview Blob upload server action
1265
+ *
1266
+ * Handles Vercel Blob uploads with authentication, validation, and callbacks.
1267
+ * Provides secure upload handling for Next.js server actions.
1268
+ *
1269
+ * @module @od-oneapp/storage/actions/blob-upload
1270
+ */
1271
+ /**
1272
+ * Process a multipart/form-data upload via Vercel Blob, enforcing CSRF and environment token checks and invoking optional pre-token and post-upload hooks.
1273
+ *
1274
+ * @param request - Incoming HTTP Request expected to contain multipart/form-data for the upload.
1275
+ * @param config - Optional handlers:
1276
+ * - onBeforeGenerateToken(pathname, clientPayload): validate/configure a scoped client token and return `{ allowed, token?, allowedContentTypes?, maximumSizeInBytes?, tokenPayload? }`.
1277
+ * - onUploadCompleted(blob, clientPayload): receive completed upload metadata `{ url, pathname, contentType?, contentDisposition?, size }` and the original clientPayload.
1278
+ * @returns A Response with a JSON body. On success the body is the value returned by Vercel's `handleUpload`; on error the body is `{ error: string }` and the response status will be 403 for invalid CSRF or 500 for configuration/processing errors.
1279
+ */
1280
+ async function handleBlobUpload(request, config) {
1281
+ try {
1282
+ const blobEnv = safeEnv();
1283
+ if (blobEnv.STORAGE_ENFORCE_CSRF) {
1284
+ const csrfToken = request.headers.get("x-csrf-token");
1285
+ if (!csrfToken || !validateCSRFToken(csrfToken, request)) return new Response(JSON.stringify({ error: "Invalid CSRF token" }), {
1286
+ status: 403,
1287
+ headers: { "Content-Type": "application/json" }
1288
+ });
1289
+ }
1290
+ const token = blobEnv.VERCEL_BLOB_READ_WRITE_TOKEN;
1291
+ if (!token) return new Response(JSON.stringify({ error: "VERCEL_BLOB_READ_WRITE_TOKEN not configured" }), {
1292
+ status: 500,
1293
+ headers: { "Content-Type": "application/json" }
1294
+ });
1295
+ const result = await handleUpload({
1296
+ body: await request.json(),
1297
+ token,
1298
+ request,
1299
+ onBeforeGenerateToken: async (pathname, clientPayload, _multipart) => {
1300
+ if (config?.onBeforeGenerateToken) try {
1301
+ const result = await config.onBeforeGenerateToken(pathname, clientPayload ?? void 0);
1302
+ if (!result.allowed) throw new Error("Upload not allowed");
1303
+ return {
1304
+ allowedContentTypes: result.allowedContentTypes,
1305
+ maximumSizeInBytes: result.maximumSizeInBytes,
1306
+ tokenPayload: result.tokenPayload
1307
+ };
1308
+ } catch (error) {
1309
+ throw new Error(`Upload validation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1310
+ }
1311
+ return {};
1312
+ },
1313
+ onUploadCompleted: async (payload) => {
1314
+ if (config?.onUploadCompleted) try {
1315
+ const { blob, tokenPayload } = payload;
1316
+ await config.onUploadCompleted({
1317
+ url: blob.url,
1318
+ pathname: blob.pathname,
1319
+ contentType: blob.contentType,
1320
+ contentDisposition: blob.contentDisposition,
1321
+ size: blob.size ?? 0
1322
+ }, tokenPayload ?? void 0);
1323
+ } catch (error) {
1324
+ logError("onUploadCompleted callback failed", {
1325
+ error: error instanceof Error ? error.message : String(error),
1326
+ blob: {
1327
+ url: payload.blob.url,
1328
+ pathname: payload.blob.pathname,
1329
+ size: payload.blob.size,
1330
+ contentType: payload.blob.contentType
1331
+ },
1332
+ clientPayload: payload.tokenPayload,
1333
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1334
+ });
1335
+ }
1336
+ }
1337
+ });
1338
+ return new Response(JSON.stringify(result), {
1339
+ status: 200,
1340
+ headers: { "Content-Type": "application/json" }
1341
+ });
1342
+ } catch (error) {
1343
+ return new Response(JSON.stringify({ error: error instanceof Error ? error.message : "Upload failed" }), {
1344
+ status: 500,
1345
+ headers: { "Content-Type": "application/json" }
1346
+ });
1347
+ }
1348
+ }
1349
+
1350
+ //#endregion
1351
+ export { CloudflareImagesProvider, CloudflareR2Provider, ConfigError, DownloadError, MultiStorageManager, MultipartUploadManager, NetworkError, ProviderError, StorageError, StorageErrorCode, UploadError, ValidationError, VercelBlobProvider, bulkDeleteMediaAction, bulkImportFromUrlsAction, bulkMoveMediaAction, bulkUpdateProductMediaAction, checkProviderHealth, checkProviderSuitability, copyBetweenProvidersAction, copyMediaAction, createFolderAction, createMultipartUploadManager, createStorageError, createStorageProvider, deleteMediaAction, deleteProductMediaAction, describeProviderCapabilities, downloadMediaAction, env, existsMediaAction, formatFileSize, getBestProvider, getCapabilityMatrix, getErrorCode, getMediaAction, getMediaUrlAction, getMultiStorage, getOptimalPartSize, getPresignedUploadUrlAction, getProductMediaAction, getProductMediaUrlsAction, getProductUploadPresignedUrlsAction, getProductUploadUrlAction, getProviderCapabilities, getProviderCapabilitiesFromConfig, getQuotaInfo, getStorage, getStorageCapabilitiesAction, handleBlobUpload, hasAllCapabilities, hasAnyCapability, hasCapability, hasMultipartSupport, importFromUrlAction, initializeMultiStorage, initializeStorage, isQuotaExceeded, isRetryableError, listMediaAction, listProvidersAction, multiStorage, parseFileSize, reorderProductMediaAction, resetStorageState, safeEnv, storage, storageHealthCheck, uploadMediaAction, uploadProductMediaAction, uploadToProviderAction, validateFileAction, validateFileSize, validateMimeType, validateProviderCapabilities, validateStorageConfig, validateStorageKey, validateUploadOptions };
1352
+ //# sourceMappingURL=server-next.mjs.map