@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,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
|