@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.
- package/README.md +854 -0
- package/dist/client-next.d.mts +61 -0
- package/dist/client-next.d.mts.map +1 -0
- package/dist/client-next.mjs +111 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client-utils-Dx6W25iz.d.mts +43 -0
- package/dist/client-utils-Dx6W25iz.d.mts.map +1 -0
- package/dist/client.d.mts +28 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +183 -0
- package/dist/client.mjs.map +1 -0
- package/dist/env-BVHLmQdh.mjs +128 -0
- package/dist/env-BVHLmQdh.mjs.map +1 -0
- package/dist/env.mjs +3 -0
- package/dist/health-check-D7LnnDec.mjs +746 -0
- package/dist/health-check-D7LnnDec.mjs.map +1 -0
- package/dist/health-check-im_huJ59.d.mts +116 -0
- package/dist/health-check-im_huJ59.d.mts.map +1 -0
- package/dist/index.d.mts +60 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/keys.d.mts +37 -0
- package/dist/keys.d.mts.map +1 -0
- package/dist/keys.mjs +253 -0
- package/dist/keys.mjs.map +1 -0
- package/dist/server-edge.d.mts +28 -0
- package/dist/server-edge.d.mts.map +1 -0
- package/dist/server-edge.mjs +88 -0
- package/dist/server-edge.mjs.map +1 -0
- package/dist/server-next.d.mts +183 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +1353 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +70 -0
- package/dist/server.d.mts.map +1 -0
- package/dist/server.mjs +384 -0
- package/dist/server.mjs.map +1 -0
- package/dist/types.d.mts +321 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +3 -0
- package/dist/validation.d.mts +101 -0
- package/dist/validation.d.mts.map +1 -0
- package/dist/validation.mjs +590 -0
- package/dist/validation.mjs.map +1 -0
- package/dist/vercel-blob-07Sx0Akn.d.mts +31 -0
- package/dist/vercel-blob-07Sx0Akn.d.mts.map +1 -0
- package/dist/vercel-blob-DA8HaYuw.mjs +158 -0
- package/dist/vercel-blob-DA8HaYuw.mjs.map +1 -0
- package/package.json +111 -0
- package/src/actions/blob-upload.ts +171 -0
- package/src/actions/index.ts +23 -0
- package/src/actions/mediaActions.ts +1071 -0
- package/src/actions/productMediaActions.ts +538 -0
- package/src/auth-helpers.ts +386 -0
- package/src/capabilities.ts +225 -0
- package/src/client-next.ts +184 -0
- package/src/client-utils.ts +292 -0
- package/src/client.ts +102 -0
- package/src/constants.ts +88 -0
- package/src/health-check.ts +81 -0
- package/src/multi-storage.ts +230 -0
- package/src/multipart.ts +497 -0
- package/src/retry-utils.test.ts +118 -0
- package/src/retry-utils.ts +59 -0
- package/src/server-edge.ts +129 -0
- package/src/server-next.ts +14 -0
- package/src/server.ts +666 -0
- package/src/validation.test.ts +312 -0
- 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
|
+
}
|