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