@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,538 @@
1
+ /**
2
+ * @fileoverview Product media business logic server actions
3
+ *
4
+ * Provides Next.js server actions specifically for product media operations.
5
+ * Includes business logic for product image management with vendor and admin contexts.
6
+ *
7
+ * Features:
8
+ * - Product-specific media uploads
9
+ * - Vendor and admin role support
10
+ * - Product media metadata management
11
+ * - Authorization checks
12
+ *
13
+ * @module @repo/storage/actions/productMediaActions
14
+ */
15
+
16
+ 'use server';
17
+
18
+ import { safeEnv } from '../../env';
19
+ import { sanitizeStorageKey, validateStorageKey } from '../../keys';
20
+ import { canUserManageProduct, checkRateLimit, getSession } from '../auth-helpers';
21
+ import { STORAGE_CONSTANTS } from '../constants';
22
+ import { getStorage } from '../server';
23
+
24
+ import type { MediaActionResponse } from '../../types';
25
+
26
+ //==============================================================================
27
+ // PRODUCT MEDIA BUSINESS LOGIC ACTIONS
28
+ //==============================================================================
29
+
30
+ /**
31
+ * Uploads multiple media files for a product while enforcing optional authentication, authorization, and rate limits.
32
+ *
33
+ * @param productId - The ID of the product to attach media to.
34
+ * @param files - Files to upload; each object must include `filename`, `contentType`, and binary `data`.
35
+ * @param options - Optional settings: `context` is the uploader role (`admin` | `vendor`); `altText`, `description`, and `tags` supply metadata.
36
+ * @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.
37
+ */
38
+ export async function uploadProductMediaAction(
39
+ productId: string,
40
+ files: Array<{
41
+ filename: string;
42
+ contentType: string;
43
+ data: ArrayBuffer | Blob | Buffer | File;
44
+ }>,
45
+ options?: {
46
+ context: 'admin' | 'vendor';
47
+ altText?: string;
48
+ description?: string;
49
+ tags?: string[];
50
+ },
51
+ ): Promise<MediaActionResponse<Array<{ key: string; url: string; mediaId: string }>>> {
52
+ 'use server';
53
+
54
+ try {
55
+ // Authentication check (configurable)
56
+ const featureEnv = safeEnv();
57
+ const session = await getSession();
58
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) {
59
+ return {
60
+ success: false,
61
+ error: 'Unauthorized: Authentication required',
62
+ };
63
+ }
64
+
65
+ // Permission check for product access (only when auth enforced)
66
+ const hasPermission = session?.user
67
+ ? await canUserManageProduct(session.user.id, productId)
68
+ : false;
69
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) {
70
+ return {
71
+ success: false,
72
+ error: 'Forbidden: Insufficient permissions',
73
+ };
74
+ }
75
+
76
+ // Rate limiting
77
+ const env = safeEnv();
78
+ const rateLimitResult = await checkRateLimit(
79
+ session?.user?.id ?? 'anonymous',
80
+ 'storage:upload',
81
+ env.STORAGE_RATE_LIMIT_REQUESTS,
82
+ env.STORAGE_RATE_LIMIT_WINDOW_MS,
83
+ );
84
+ if (env.STORAGE_ENABLE_RATE_LIMIT && !rateLimitResult.allowed) {
85
+ return {
86
+ success: false,
87
+ error: 'Rate limit exceeded. Please try again later.',
88
+ };
89
+ }
90
+
91
+ const storage = getStorage();
92
+ const results = [];
93
+
94
+ for (const [index, file] of files.entries()) {
95
+ // Sanitize filename to prevent path traversal attacks
96
+ const sanitizedFilename = sanitizeStorageKey(file.filename);
97
+
98
+ // Generate storage key with sanitized filename
99
+ const timestamp = Date.now();
100
+ const key = `products/${productId}/images/${timestamp}-${index}-${sanitizedFilename}`;
101
+
102
+ // Validate storage key to prevent path traversal
103
+ const keyValidation = validateStorageKey(key, {
104
+ maxLength: 1024,
105
+ forbiddenPatterns: [/\.\./, /\/\//],
106
+ });
107
+ if (!keyValidation.valid) {
108
+ return {
109
+ success: false,
110
+ error: `Invalid storage key: ${keyValidation.errors.join(', ')}`,
111
+ };
112
+ }
113
+
114
+ // Upload to storage
115
+ const uploadResult = await storage.upload(key, file.data, {
116
+ contentType: file.contentType,
117
+ metadata: {
118
+ productId,
119
+ uploadedBy: options?.context ?? 'admin',
120
+ altText: options?.altText ?? '',
121
+ },
122
+ });
123
+
124
+ // TODO: Create database record
125
+ // const mediaRecord = await createProductMediaRecord({
126
+ // productId,
127
+ // key,
128
+ // url: uploadResult.url,
129
+ // filename: file.filename,
130
+ // contentType: file.contentType,
131
+ // size: uploadResult.size,
132
+ // altText: options?.altText,
133
+ // description: options?.description,
134
+ // tags: options?.tags,
135
+ // sortOrder: index,
136
+ // });
137
+
138
+ // Generate signed URL for immediate use (or use uploadResult.url if available)
139
+ const signedUrl =
140
+ uploadResult.url ||
141
+ (await storage.getUrl(key, { expiresIn: STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS }));
142
+
143
+ results.push({
144
+ key,
145
+ url: signedUrl,
146
+ mediaId: `temp-${timestamp}-${index}`, // TODO: Replace with actual mediaRecord.id
147
+ });
148
+ }
149
+
150
+ return {
151
+ success: true,
152
+ data: results,
153
+ };
154
+ } catch (error) {
155
+ return {
156
+ success: false,
157
+ error: error instanceof Error ? error.message : 'Failed to upload product media',
158
+ };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Retrieve a product's media entries and attach signed URLs for client access.
164
+ *
165
+ * The returned media items include metadata (id, key, altText, sortOrder, contentType, size)
166
+ * and a signed `url` valid for the configured expiry. If `options.variant` is provided and is
167
+ * not `'public'`, the storage key is adjusted to include the variant before generating the URL.
168
+ *
169
+ * @param productId - The identifier of the product whose media should be fetched
170
+ * @param options.context - Request context; affects visibility (e.g., `'admin'` may include deleted items)
171
+ * @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
172
+ * @param options.expiresIn - Signed URL lifetime in seconds (defaults to 3600)
173
+ * @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
174
+ */
175
+ export async function getProductMediaAction(
176
+ productId: string,
177
+ options?: {
178
+ context: 'admin' | 'customer' | 'vendor';
179
+ variant?: 'thumbnail' | 'gallery' | 'hero' | 'public';
180
+ expiresIn?: number;
181
+ },
182
+ ): Promise<
183
+ MediaActionResponse<
184
+ Array<{
185
+ id: string;
186
+ key: string;
187
+ url: string;
188
+ altText?: string;
189
+ sortOrder: number;
190
+ contentType: string;
191
+ size: number;
192
+ }>
193
+ >
194
+ > {
195
+ 'use server';
196
+
197
+ try {
198
+ // TODO: Get media from database
199
+ // const productMedia = await getProductMediaFromDatabase(productId, {
200
+ // includeDeleted: options?.context === 'admin',
201
+ // });
202
+
203
+ // Mock data for now
204
+ const productMedia = [
205
+ {
206
+ id: 'media-1',
207
+ key: `products/${productId}/images/hero.jpg`,
208
+ altText: 'Product hero image',
209
+ sortOrder: 0,
210
+ contentType: 'image/jpeg',
211
+ size: 1024000,
212
+ },
213
+ {
214
+ id: 'media-2',
215
+ key: `products/${productId}/images/gallery-1.jpg`,
216
+ altText: 'Product gallery image 1',
217
+ sortOrder: 1,
218
+ contentType: 'image/jpeg',
219
+ size: 856000,
220
+ },
221
+ ];
222
+
223
+ const storage = getStorage();
224
+ const expiresIn = options?.expiresIn ?? 3600; // 1 hour default
225
+
226
+ // Generate signed URLs for all media
227
+ const mediaWithUrls = await Promise.all(
228
+ productMedia.map(async media => {
229
+ let { key } = media;
230
+
231
+ // For Cloudflare Images, append variant
232
+ if (options?.variant && options.variant !== 'public') {
233
+ key = `${media.key}/${options.variant}`;
234
+ }
235
+
236
+ const signedUrl = await storage.getUrl(key, { expiresIn });
237
+
238
+ return {
239
+ ...media,
240
+ url: signedUrl,
241
+ };
242
+ }),
243
+ );
244
+
245
+ return {
246
+ success: true,
247
+ data: mediaWithUrls,
248
+ };
249
+ } catch (error) {
250
+ return {
251
+ success: false,
252
+ error: error instanceof Error ? error.message : 'Failed to get product media',
253
+ };
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Delete a product's media entry and remove its stored object when requested.
259
+ *
260
+ * 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.
261
+ *
262
+ * @param options - Additional options controlling deletion behavior
263
+ * @param options.context - Invocation context, either `'admin'` or `'vendor'`
264
+ * @param options.hardDelete - If `true`, delete the storage object and perform a hard delete; if omitted or `false`, perform a soft delete
265
+ * @returns A MediaActionResponse<void> indicating success. On failure `success` is `false` and `error` contains a descriptive message.
266
+ */
267
+ export async function deleteProductMediaAction(
268
+ productId: string,
269
+ mediaId: string,
270
+ options?: {
271
+ context: 'admin' | 'vendor';
272
+ hardDelete?: boolean;
273
+ },
274
+ ): Promise<MediaActionResponse<void>> {
275
+ 'use server';
276
+
277
+ try {
278
+ // Authentication check (configurable)
279
+ const featureEnv = safeEnv();
280
+ const session = await getSession();
281
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) {
282
+ return {
283
+ success: false,
284
+ error: 'Unauthorized: Authentication required',
285
+ };
286
+ }
287
+
288
+ // Permission check (only when auth enforced)
289
+ const hasPermission = session?.user
290
+ ? await canUserManageProduct(session.user.id, productId)
291
+ : false;
292
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) {
293
+ return {
294
+ success: false,
295
+ error: 'Forbidden: Insufficient permissions',
296
+ };
297
+ }
298
+
299
+ // TODO: Get media record from database
300
+ // const mediaRecord = await getProductMediaById(mediaId);
301
+ // if (!mediaRecord || mediaRecord.productId !== productId) {
302
+ // throw new Error('Media not found');
303
+ // }
304
+
305
+ // Mock media record
306
+ const mediaRecord = {
307
+ id: mediaId,
308
+ key: `products/${productId}/images/example.jpg`,
309
+ productId,
310
+ };
311
+
312
+ if (options?.hardDelete) {
313
+ // Delete from storage
314
+ const storage = getStorage();
315
+ await storage.delete(mediaRecord.key);
316
+
317
+ // TODO: Hard delete from database
318
+ // await deleteProductMediaRecord(mediaId);
319
+ } else {
320
+ // TODO: Soft delete in database
321
+ // await softDeleteProductMediaRecord(mediaId, session.user.id);
322
+ }
323
+
324
+ return { success: true };
325
+ } catch (error) {
326
+ return {
327
+ success: false,
328
+ error: error instanceof Error ? error.message : 'Failed to delete product media',
329
+ };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Reorders media items for a product.
335
+ *
336
+ * When storage authentication is enforced, this action requires an authenticated user
337
+ * who has permission to manage the specified product; otherwise it returns an authorization error.
338
+ *
339
+ * @param productId - The ID of the product whose media order will be updated
340
+ * @param mediaOrder - Array of objects mapping `mediaId` to the desired `sortOrder`
341
+ * @param options.context - Optional caller context, e.g. 'admin' or 'vendor'
342
+ * @returns An object with `success: true` when the reorder operation completes; otherwise `success: false` and an `error` message describing the failure
343
+ */
344
+ export async function reorderProductMediaAction(
345
+ productId: string,
346
+ _mediaOrder: Array<{ mediaId: string; sortOrder: number }>,
347
+ _options?: {
348
+ context: 'admin' | 'vendor';
349
+ },
350
+ ): Promise<MediaActionResponse<void>> {
351
+ 'use server';
352
+
353
+ try {
354
+ // Authentication check (configurable)
355
+ const featureEnv = safeEnv();
356
+ const session = await getSession();
357
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) {
358
+ return {
359
+ success: false,
360
+ error: 'Unauthorized: Authentication required',
361
+ };
362
+ }
363
+
364
+ // Permission check (only when auth enforced)
365
+ const hasPermission = session?.user
366
+ ? await canUserManageProduct(session.user.id, productId)
367
+ : false;
368
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) {
369
+ return {
370
+ success: false,
371
+ error: 'Forbidden: Insufficient permissions',
372
+ };
373
+ }
374
+
375
+ // TODO: Update sort order in database
376
+ // await updateMediaSortOrder(productId, mediaOrder);
377
+
378
+ return { success: true };
379
+ } catch (error) {
380
+ return {
381
+ success: false,
382
+ error: error instanceof Error ? error.message : 'Failed to reorder product media',
383
+ };
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Generate presigned upload URLs for client-side direct uploads scoped to a product.
389
+ *
390
+ * @param productId - Product identifier used to scope generated storage keys
391
+ * @param filenames - Desired filenames to include in generated storage keys
392
+ * @param options - Optional settings for URL generation
393
+ * @param options.context - The request context, e.g. 'admin' or 'vendor'
394
+ * @param options.expiresIn - Signed URL lifetime in seconds; defaults to STORAGE_CONSTANTS.UPLOAD_URL_EXPIRY_SECONDS
395
+ * @param options.maxSizeBytes - Optional maximum allowed upload size in bytes (informational; enforcement depends on storage provider)
396
+ * @returns A MediaActionResponse containing an array of upload descriptors; each descriptor includes `filename`, `uploadUrl`, `key`, and optional form `fields`
397
+ */
398
+ export async function getProductUploadPresignedUrlsAction(
399
+ productId: string,
400
+ filenames: string[],
401
+ options?: {
402
+ context: 'admin' | 'vendor';
403
+ expiresIn?: number;
404
+ maxSizeBytes?: number;
405
+ },
406
+ ): Promise<
407
+ MediaActionResponse<
408
+ Array<{
409
+ filename: string;
410
+ uploadUrl: string;
411
+ key: string;
412
+ fields?: Record<string, string>;
413
+ }>
414
+ >
415
+ > {
416
+ 'use server';
417
+
418
+ try {
419
+ // Authentication check (configurable)
420
+ const featureEnv = safeEnv();
421
+ const session = await getSession();
422
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) {
423
+ return {
424
+ success: false,
425
+ error: 'Unauthorized: Authentication required',
426
+ };
427
+ }
428
+
429
+ // Permission check (only when auth enforced)
430
+ const hasPermission = session?.user
431
+ ? await canUserManageProduct(session.user.id, productId)
432
+ : false;
433
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) {
434
+ return {
435
+ success: false,
436
+ error: 'Forbidden: Insufficient permissions',
437
+ };
438
+ }
439
+
440
+ const storage = getStorage();
441
+ const expiresIn = options?.expiresIn ?? STORAGE_CONSTANTS.UPLOAD_URL_EXPIRY_SECONDS;
442
+
443
+ const uploadUrls = await Promise.all(
444
+ filenames.map(async (filename, index) => {
445
+ // Sanitize filename to prevent path traversal
446
+ const sanitizedFilename = sanitizeStorageKey(filename);
447
+ const timestamp = Date.now();
448
+ const key = `products/${productId}/images/${timestamp}-${index}-${sanitizedFilename}`;
449
+
450
+ // Validate storage key
451
+ const keyValidation = validateStorageKey(key, {
452
+ maxLength: 1024,
453
+ forbiddenPatterns: [/\.\./, /\/\//],
454
+ });
455
+ if (!keyValidation.valid) {
456
+ throw new Error(`Invalid storage key: ${keyValidation.errors.join(', ')}`);
457
+ }
458
+
459
+ // TODO: Use actual presigned POST URL when storage provider supports it
460
+ // For now, use signed GET URL as placeholder
461
+ const uploadUrl = await storage.getUrl(key, { expiresIn });
462
+
463
+ return {
464
+ filename,
465
+ uploadUrl,
466
+ key,
467
+ // fields: presignedPost.fields, // For S3-style presigned POST
468
+ };
469
+ }),
470
+ );
471
+
472
+ return {
473
+ success: true,
474
+ data: uploadUrls,
475
+ };
476
+ } catch (error) {
477
+ return {
478
+ success: false,
479
+ error: error instanceof Error ? error.message : 'Failed to generate upload URLs',
480
+ };
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Performs bulk metadata updates for a product's media items.
486
+ *
487
+ * @param productId - ID of the product whose media items will be updated
488
+ * @param updates - List of update objects, each with a `mediaId` and optional `altText`, `description`, and `tags`
489
+ * @param options - Optional operation context; expected values are `'admin'` or `'vendor'`
490
+ * @returns A response object indicating success; on failure `error` contains a descriptive message (for example, `Unauthorized: Authentication required` or `Forbidden: Insufficient permissions`)
491
+ */
492
+ export async function bulkUpdateProductMediaAction(
493
+ productId: string,
494
+ _updates: Array<{
495
+ mediaId: string;
496
+ altText?: string;
497
+ description?: string;
498
+ tags?: string[];
499
+ }>,
500
+ _options?: {
501
+ context: 'admin' | 'vendor';
502
+ },
503
+ ): Promise<MediaActionResponse<void>> {
504
+ 'use server';
505
+
506
+ try {
507
+ // Authentication check (configurable)
508
+ const featureEnv = safeEnv();
509
+ const session = await getSession();
510
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !session?.user) {
511
+ return {
512
+ success: false,
513
+ error: 'Unauthorized: Authentication required',
514
+ };
515
+ }
516
+
517
+ // Permission check (only when auth enforced)
518
+ const hasPermission = session?.user
519
+ ? await canUserManageProduct(session.user.id, productId)
520
+ : false;
521
+ if (featureEnv.STORAGE_ENFORCE_AUTH && !hasPermission) {
522
+ return {
523
+ success: false,
524
+ error: 'Forbidden: Insufficient permissions',
525
+ };
526
+ }
527
+
528
+ // TODO: Bulk update in database
529
+ // await bulkUpdateProductMedia(productId, updates);
530
+
531
+ return { success: true };
532
+ } catch (error) {
533
+ return {
534
+ success: false,
535
+ error: error instanceof Error ? error.message : 'Failed to bulk update product media',
536
+ };
537
+ }
538
+ }