@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,1071 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Media storage server actions
|
|
3
|
+
*
|
|
4
|
+
* Provides Next.js server actions for media file operations including upload,
|
|
5
|
+
* download, delete, move, and list operations with authentication and validation.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Authentication and authorization
|
|
9
|
+
* - Rate limiting
|
|
10
|
+
* - File validation (size, MIME type)
|
|
11
|
+
* - SSRF protection
|
|
12
|
+
* - Bulk operations
|
|
13
|
+
*
|
|
14
|
+
* @module @repo/storage/actions/mediaActions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use server';
|
|
18
|
+
|
|
19
|
+
import { safeEnv } from '../../env';
|
|
20
|
+
import { validateStorageKey } from '../../keys';
|
|
21
|
+
import { checkRateLimit, getClientIP, getSession } from '../auth-helpers';
|
|
22
|
+
import { STORAGE_CONSTANTS } from '../constants';
|
|
23
|
+
import { getMultiStorage, getStorage } from '../server';
|
|
24
|
+
import { validateMimeType, validateUrlForSSRF } from '../validation';
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
BulkDeleteResponse,
|
|
28
|
+
BulkMoveResponse,
|
|
29
|
+
ListOptions,
|
|
30
|
+
MediaActionResponse,
|
|
31
|
+
StorageObject,
|
|
32
|
+
UploadOptions,
|
|
33
|
+
} from '../../types';
|
|
34
|
+
|
|
35
|
+
//==============================================================================
|
|
36
|
+
// MEDIA STORAGE SERVER ACTIONS
|
|
37
|
+
//==============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Upload binary data to the configured storage provider with optional validations.
|
|
41
|
+
*
|
|
42
|
+
* Validations performed before upload include storage key safety checks, optional
|
|
43
|
+
* authentication and rate limiting, maximum file size enforcement, and MIME type
|
|
44
|
+
* validation when `options.allowedMimeTypes` and `options.contentType` are provided.
|
|
45
|
+
*
|
|
46
|
+
* @param key - Destination storage key (path) where the file will be stored
|
|
47
|
+
* @param data - File contents to upload (Buffer, ArrayBuffer, Blob, File, or ReadableStream)
|
|
48
|
+
* @param options - Upload options and validation overrides. Notable fields:
|
|
49
|
+
* - `maxFileSize` — override maximum allowed file size in bytes for this upload
|
|
50
|
+
* - `allowedMimeTypes` — list of permitted MIME types; validated against `options.contentType` if present
|
|
51
|
+
* - other provider-specific upload options (e.g., `contentType`, `metadata`) are passed through to the storage provider
|
|
52
|
+
* @returns An object with `success: true` and the uploaded `StorageObject` on success, or `success: false` and an `error` message on failure
|
|
53
|
+
*/
|
|
54
|
+
export async function uploadMediaAction(
|
|
55
|
+
key: string,
|
|
56
|
+
data: ArrayBuffer | Blob | Buffer | File | ReadableStream,
|
|
57
|
+
options?: UploadOptions & {
|
|
58
|
+
maxFileSize?: number;
|
|
59
|
+
allowedMimeTypes?: string[];
|
|
60
|
+
},
|
|
61
|
+
): Promise<MediaActionResponse<StorageObject>> {
|
|
62
|
+
'use server';
|
|
63
|
+
|
|
64
|
+
// Get environment configuration once
|
|
65
|
+
const storageEnv = safeEnv();
|
|
66
|
+
|
|
67
|
+
// Authentication check (configurable)
|
|
68
|
+
const session = await getSession();
|
|
69
|
+
if (storageEnv.STORAGE_ENFORCE_AUTH && !session?.user) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
error: 'Unauthorized: Authentication required',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Rate limiting
|
|
77
|
+
const identifier = session?.user?.id ?? getClientIP();
|
|
78
|
+
const rateLimitResult = await checkRateLimit(
|
|
79
|
+
identifier,
|
|
80
|
+
'storage:upload',
|
|
81
|
+
storageEnv.STORAGE_RATE_LIMIT_REQUESTS,
|
|
82
|
+
storageEnv.STORAGE_RATE_LIMIT_WINDOW_MS,
|
|
83
|
+
);
|
|
84
|
+
if (storageEnv.STORAGE_ENABLE_RATE_LIMIT && !rateLimitResult.allowed) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: 'Rate limit exceeded. Please try again later.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Validate storage key to prevent path traversal
|
|
93
|
+
const keyValidation = validateStorageKey(key, {
|
|
94
|
+
maxLength: 1024,
|
|
95
|
+
forbiddenPatterns: [/\.\./, /\/\//],
|
|
96
|
+
});
|
|
97
|
+
if (!keyValidation.valid) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
error: `Invalid storage key: ${keyValidation.errors.join(', ')}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate file size
|
|
105
|
+
const maxSize = options?.maxFileSize ?? storageEnv.STORAGE_MAX_FILE_SIZE;
|
|
106
|
+
let fileSize = 0;
|
|
107
|
+
|
|
108
|
+
if (data instanceof File || data instanceof Blob) {
|
|
109
|
+
fileSize = data.size;
|
|
110
|
+
} else if (data instanceof ArrayBuffer) {
|
|
111
|
+
fileSize = data.byteLength;
|
|
112
|
+
} else if (data instanceof Buffer) {
|
|
113
|
+
fileSize = data.length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (fileSize > maxSize) {
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
error: `File size ${fileSize} bytes exceeds maximum ${maxSize} bytes`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Validate MIME type if restrictions provided
|
|
124
|
+
if (options?.allowedMimeTypes && options.contentType) {
|
|
125
|
+
const mimeValidation = validateMimeType(options.contentType, options.allowedMimeTypes);
|
|
126
|
+
if (!mimeValidation.valid) {
|
|
127
|
+
return {
|
|
128
|
+
success: false,
|
|
129
|
+
error: mimeValidation.error ?? 'Invalid file type',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const result = await getStorage().upload(key, data, options);
|
|
135
|
+
return { success: true, data: result };
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
error: error instanceof Error ? error.message : 'Failed to upload media',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get media file metadata
|
|
146
|
+
*/
|
|
147
|
+
export async function getMediaAction(key: string): Promise<MediaActionResponse<StorageObject>> {
|
|
148
|
+
'use server';
|
|
149
|
+
try {
|
|
150
|
+
const metadata = await getStorage().getMetadata(key);
|
|
151
|
+
return { success: true, data: metadata };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
success: false,
|
|
155
|
+
error: error instanceof Error ? error.message : 'Failed to get media metadata',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* List media files
|
|
162
|
+
*/
|
|
163
|
+
export async function listMediaAction(
|
|
164
|
+
options?: ListOptions,
|
|
165
|
+
): Promise<MediaActionResponse<StorageObject[]>> {
|
|
166
|
+
'use server';
|
|
167
|
+
try {
|
|
168
|
+
const items = await getStorage().list(options);
|
|
169
|
+
return { success: true, data: items };
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
error: error instanceof Error ? error.message : 'Failed to list media',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Delete a media file
|
|
180
|
+
*/
|
|
181
|
+
export async function deleteMediaAction(key: string): Promise<MediaActionResponse<void>> {
|
|
182
|
+
'use server';
|
|
183
|
+
try {
|
|
184
|
+
await getStorage().delete(key);
|
|
185
|
+
return { success: true };
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
error: error instanceof Error ? error.message : 'Failed to delete media',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Determine whether a media object exists at the given storage key.
|
|
196
|
+
*
|
|
197
|
+
* @param key - The storage key (path) of the media object to check
|
|
198
|
+
* @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
|
|
199
|
+
*/
|
|
200
|
+
export async function existsMediaAction(key: string): Promise<MediaActionResponse<boolean>> {
|
|
201
|
+
'use server';
|
|
202
|
+
try {
|
|
203
|
+
const exists = await getStorage().exists(key);
|
|
204
|
+
return { success: true, data: exists };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
success: false,
|
|
208
|
+
error: error instanceof Error ? error.message : 'Failed to check media existence',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Retrieve a public or signed URL for a media object.
|
|
215
|
+
*
|
|
216
|
+
* When the context is `product`, when `forceSign` is true, or when `expiresIn` is provided,
|
|
217
|
+
* a signed URL is returned using the specified or default expiry; otherwise a direct URL is returned.
|
|
218
|
+
*
|
|
219
|
+
* @param key - The storage key of the media object
|
|
220
|
+
* @param options.expiresIn - Expiration time in seconds for a signed URL
|
|
221
|
+
* @param options.context - Context of the media; `product` forces signed URLs for product photos
|
|
222
|
+
* @param options.forceSign - When true, force generation of a signed URL regardless of context
|
|
223
|
+
* @returns The URL for the storage key (signed if signing rules apply)
|
|
224
|
+
*/
|
|
225
|
+
export async function getMediaUrlAction(
|
|
226
|
+
key: string,
|
|
227
|
+
options?: {
|
|
228
|
+
expiresIn?: number;
|
|
229
|
+
context?: 'product' | 'user' | 'admin' | 'public';
|
|
230
|
+
forceSign?: boolean;
|
|
231
|
+
},
|
|
232
|
+
): Promise<MediaActionResponse<string>> {
|
|
233
|
+
'use server';
|
|
234
|
+
try {
|
|
235
|
+
const storage = getStorage();
|
|
236
|
+
|
|
237
|
+
// Product photos always need signed URLs for protection
|
|
238
|
+
const isProductPhoto = options?.context === 'product' || key.includes('/products/');
|
|
239
|
+
const shouldSign = (isProductPhoto || options?.forceSign) ?? Boolean(options?.expiresIn);
|
|
240
|
+
|
|
241
|
+
if (shouldSign) {
|
|
242
|
+
const expiresIn =
|
|
243
|
+
options?.expiresIn ??
|
|
244
|
+
(isProductPhoto
|
|
245
|
+
? STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS
|
|
246
|
+
: STORAGE_CONSTANTS.UPLOAD_URL_EXPIRY_SECONDS);
|
|
247
|
+
const signedUrl = await storage.getUrl(key, { expiresIn });
|
|
248
|
+
return { success: true, data: signedUrl };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// For public content, return direct URL
|
|
252
|
+
const url = await storage.getUrl(key);
|
|
253
|
+
return { success: true, data: url };
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
success: false,
|
|
257
|
+
error: error instanceof Error ? error.message : 'Failed to get media URL',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Generate signed URLs for multiple product media keys.
|
|
264
|
+
*
|
|
265
|
+
* Appends the provided `variant` to keys that reference Cloudflare Images and uses
|
|
266
|
+
* `options.expiresIn` or the product URL expiry constant as the signing duration.
|
|
267
|
+
*
|
|
268
|
+
* @param keys - Array of storage keys identifying product media items
|
|
269
|
+
* @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
|
|
270
|
+
* @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
|
|
271
|
+
*/
|
|
272
|
+
export async function getProductMediaUrlsAction(
|
|
273
|
+
keys: string[],
|
|
274
|
+
options?: { expiresIn?: number; variant?: string },
|
|
275
|
+
): Promise<MediaActionResponse<Array<{ key: string; url: string }>>> {
|
|
276
|
+
'use server';
|
|
277
|
+
try {
|
|
278
|
+
const storage = getStorage();
|
|
279
|
+
const expiresIn = options?.expiresIn ?? STORAGE_CONSTANTS.PRODUCT_URL_EXPIRY_SECONDS;
|
|
280
|
+
|
|
281
|
+
const mediaWithSignedUrls = await Promise.all(
|
|
282
|
+
keys.map(async key => {
|
|
283
|
+
let finalKey = key;
|
|
284
|
+
|
|
285
|
+
// For Cloudflare Images, append variant if specified
|
|
286
|
+
if (options?.variant && key.includes('cloudflare-images')) {
|
|
287
|
+
finalKey = `${key}/${options.variant}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const signedUrl = await storage.getUrl(finalKey, { expiresIn });
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
key,
|
|
294
|
+
url: signedUrl,
|
|
295
|
+
};
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
success: true,
|
|
301
|
+
data: mediaWithSignedUrls,
|
|
302
|
+
};
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
error: error instanceof Error ? error.message : 'Failed to get product media URLs',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get presigned upload URL for product photos (admin only)
|
|
313
|
+
*/
|
|
314
|
+
export async function getProductUploadUrlAction(
|
|
315
|
+
filename: string,
|
|
316
|
+
productId: string,
|
|
317
|
+
options?: {
|
|
318
|
+
expiresIn?: number;
|
|
319
|
+
contentType?: string;
|
|
320
|
+
maxSizeBytes?: number;
|
|
321
|
+
},
|
|
322
|
+
): Promise<MediaActionResponse<{ uploadUrl: string; key: string }>> {
|
|
323
|
+
'use server';
|
|
324
|
+
try {
|
|
325
|
+
const storage = getStorage();
|
|
326
|
+
const key = `products/${productId}/${Date.now()}-${filename}`;
|
|
327
|
+
|
|
328
|
+
// Get presigned upload URL (this would depend on the storage provider)
|
|
329
|
+
// For now, return a structure that indicates what should be implemented
|
|
330
|
+
const uploadUrl = await storage.getUrl(key, {
|
|
331
|
+
expiresIn: options?.expiresIn ?? 1800, // 30 minutes for uploads
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
success: true,
|
|
336
|
+
data: {
|
|
337
|
+
uploadUrl,
|
|
338
|
+
key,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return {
|
|
343
|
+
success: false,
|
|
344
|
+
error: error instanceof Error ? error.message : 'Failed to get product upload URL',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Download a media file
|
|
351
|
+
*/
|
|
352
|
+
export async function downloadMediaAction(key: string): Promise<MediaActionResponse<Blob>> {
|
|
353
|
+
'use server';
|
|
354
|
+
try {
|
|
355
|
+
const blob = await getStorage().download(key);
|
|
356
|
+
return { success: true, data: blob };
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: error instanceof Error ? error.message : 'Failed to download media',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
//==============================================================================
|
|
366
|
+
// BULK OPERATIONS
|
|
367
|
+
//==============================================================================
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Delete multiple media files
|
|
371
|
+
*/
|
|
372
|
+
export async function bulkDeleteMediaAction(
|
|
373
|
+
keys: string[],
|
|
374
|
+
): Promise<MediaActionResponse<BulkDeleteResponse>> {
|
|
375
|
+
'use server';
|
|
376
|
+
const results = {
|
|
377
|
+
succeeded: [] as string[],
|
|
378
|
+
failed: [] as { key: string; error: string }[],
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
// Process deletions in parallel with error handling for each
|
|
383
|
+
await Promise.all(
|
|
384
|
+
keys.map(async key => {
|
|
385
|
+
try {
|
|
386
|
+
await getStorage().delete(key);
|
|
387
|
+
results.succeeded.push(key);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
results.failed.push({
|
|
390
|
+
key,
|
|
391
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
success: results.failed.length === 0,
|
|
399
|
+
data: results,
|
|
400
|
+
};
|
|
401
|
+
} catch (error) {
|
|
402
|
+
return {
|
|
403
|
+
success: false,
|
|
404
|
+
error: error instanceof Error ? error.message : 'Bulk delete operation failed',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Move/rename multiple media files
|
|
411
|
+
*/
|
|
412
|
+
export async function bulkMoveMediaAction(
|
|
413
|
+
operations: Array<{ sourceKey: string; destinationKey: string }>,
|
|
414
|
+
): Promise<MediaActionResponse<BulkMoveResponse>> {
|
|
415
|
+
'use server';
|
|
416
|
+
const results = {
|
|
417
|
+
succeeded: [] as { sourceKey: string; destinationKey: string }[],
|
|
418
|
+
failed: [] as { sourceKey: string; destinationKey: string; error: string }[],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
// Process moves in parallel
|
|
423
|
+
await Promise.all(
|
|
424
|
+
operations.map(async ({ sourceKey, destinationKey }) => {
|
|
425
|
+
try {
|
|
426
|
+
// Download the file
|
|
427
|
+
const blob = await getStorage().download(sourceKey);
|
|
428
|
+
|
|
429
|
+
// Get metadata from source
|
|
430
|
+
const metadata = await getStorage().getMetadata(sourceKey);
|
|
431
|
+
|
|
432
|
+
// Upload to new location
|
|
433
|
+
await getStorage().upload(destinationKey, blob, {
|
|
434
|
+
contentType: metadata.contentType,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Delete the source
|
|
438
|
+
await getStorage().delete(sourceKey);
|
|
439
|
+
|
|
440
|
+
results.succeeded.push({ sourceKey, destinationKey });
|
|
441
|
+
} catch (error) {
|
|
442
|
+
results.failed.push({
|
|
443
|
+
sourceKey,
|
|
444
|
+
destinationKey,
|
|
445
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
success: results.failed.length === 0,
|
|
453
|
+
data: results,
|
|
454
|
+
};
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return {
|
|
457
|
+
success: false,
|
|
458
|
+
error: error instanceof Error ? error.message : 'Bulk move operation failed',
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
//==============================================================================
|
|
464
|
+
// BULK IMPORT OPERATIONS
|
|
465
|
+
//==============================================================================
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Imports multiple external URLs into storage, processing them in configurable batches.
|
|
469
|
+
*
|
|
470
|
+
* Each source URL is validated for safety, fetched with a per-request timeout, and streamed
|
|
471
|
+
* into the chosen storage provider (or routed to Cloudflare Images for images when available).
|
|
472
|
+
* Successful imports are recorded with their destination key and resulting StorageObject;
|
|
473
|
+
* failures record the source URL and an error message. Processing continues after individual failures.
|
|
474
|
+
*
|
|
475
|
+
* @param imports - Array of import entries, each with a required `sourceUrl`, optional `destinationKey`
|
|
476
|
+
* (if omitted a key is generated), and optional `metadata` (altText, productId, userId, type).
|
|
477
|
+
* @param options - Optional settings:
|
|
478
|
+
* - `batchSize`: number of concurrent imports per batch (defaults to STORAGE_CONSTANTS.DEFAULT_BATCH_SIZE).
|
|
479
|
+
* - `provider`: name of the storage provider to use (if omitted uses default provider or Cloudflare Images for images).
|
|
480
|
+
* - `timeout`: per-request fetch timeout in milliseconds (defaults to STORAGE_CONSTANTS.DEFAULT_REQUEST_TIMEOUT_MS).
|
|
481
|
+
* @returns An object with:
|
|
482
|
+
* - `succeeded`: array of { sourceUrl, destinationKey, storageObject } for successful imports;
|
|
483
|
+
* - `failed`: array of { sourceUrl, error } for failed imports;
|
|
484
|
+
* - `totalProcessed`: total number of import attempts processed.
|
|
485
|
+
*/
|
|
486
|
+
export async function bulkImportFromUrlsAction(
|
|
487
|
+
imports: Array<{
|
|
488
|
+
sourceUrl: string;
|
|
489
|
+
destinationKey?: string;
|
|
490
|
+
metadata?: {
|
|
491
|
+
altText?: string;
|
|
492
|
+
productId?: string;
|
|
493
|
+
userId?: string;
|
|
494
|
+
type?: 'IMAGE' | 'VIDEO' | 'DOCUMENT';
|
|
495
|
+
};
|
|
496
|
+
}>,
|
|
497
|
+
options?: {
|
|
498
|
+
batchSize?: number;
|
|
499
|
+
provider?: string;
|
|
500
|
+
timeout?: number;
|
|
501
|
+
},
|
|
502
|
+
): Promise<
|
|
503
|
+
MediaActionResponse<{
|
|
504
|
+
succeeded: Array<{
|
|
505
|
+
sourceUrl: string;
|
|
506
|
+
destinationKey: string;
|
|
507
|
+
storageObject: StorageObject;
|
|
508
|
+
}>;
|
|
509
|
+
failed: Array<{
|
|
510
|
+
sourceUrl: string;
|
|
511
|
+
error: string;
|
|
512
|
+
}>;
|
|
513
|
+
totalProcessed: number;
|
|
514
|
+
}>
|
|
515
|
+
> {
|
|
516
|
+
'use server';
|
|
517
|
+
|
|
518
|
+
const results = {
|
|
519
|
+
succeeded: [] as Array<{
|
|
520
|
+
sourceUrl: string;
|
|
521
|
+
destinationKey: string;
|
|
522
|
+
storageObject: StorageObject;
|
|
523
|
+
}>,
|
|
524
|
+
failed: [] as Array<{
|
|
525
|
+
sourceUrl: string;
|
|
526
|
+
error: string;
|
|
527
|
+
}>,
|
|
528
|
+
totalProcessed: 0,
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const batchSize = options?.batchSize ?? STORAGE_CONSTANTS.DEFAULT_BATCH_SIZE;
|
|
532
|
+
const timeout = options?.timeout ?? STORAGE_CONSTANTS.DEFAULT_REQUEST_TIMEOUT_MS;
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
// Process imports in batches to avoid overwhelming the system
|
|
536
|
+
for (let i = 0; i < imports.length; i += batchSize) {
|
|
537
|
+
const batch = imports.slice(i, i + batchSize);
|
|
538
|
+
|
|
539
|
+
await Promise.all(
|
|
540
|
+
batch.map(async importItem => {
|
|
541
|
+
try {
|
|
542
|
+
// Validate URL to prevent SSRF attacks
|
|
543
|
+
const urlValidation = validateUrlForSSRF(importItem.sourceUrl);
|
|
544
|
+
if (!urlValidation.valid) {
|
|
545
|
+
throw new Error(urlValidation.error ?? 'Invalid or unsafe URL');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Fetch with timeout
|
|
549
|
+
const controller = new AbortController();
|
|
550
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
551
|
+
|
|
552
|
+
const response = await fetch(importItem.sourceUrl, {
|
|
553
|
+
signal: controller.signal,
|
|
554
|
+
// Add additional security headers
|
|
555
|
+
headers: {
|
|
556
|
+
'User-Agent': 'Storage-Service/1.0',
|
|
557
|
+
},
|
|
558
|
+
// Prevent redirects to internal URLs
|
|
559
|
+
redirect: 'error',
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
clearTimeout(timeoutId);
|
|
563
|
+
|
|
564
|
+
if (!response.ok) {
|
|
565
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Get content type and determine storage provider
|
|
569
|
+
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
|
|
570
|
+
const isImage = contentType.startsWith('image/');
|
|
571
|
+
|
|
572
|
+
// Generate destination key if not provided
|
|
573
|
+
const destinationKey =
|
|
574
|
+
importItem.destinationKey ??
|
|
575
|
+
generateStorageKey(importItem.sourceUrl, importItem.metadata);
|
|
576
|
+
|
|
577
|
+
// Stream the content to storage
|
|
578
|
+
const storage = options?.provider
|
|
579
|
+
? getMultiStorage().getProvider(options.provider)
|
|
580
|
+
: getStorage();
|
|
581
|
+
|
|
582
|
+
if (!storage) {
|
|
583
|
+
throw new Error('Storage provider not available');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// For images, use Cloudflare Images if configured
|
|
587
|
+
if (isImage && !options?.provider) {
|
|
588
|
+
const multiStorage = getMultiStorage();
|
|
589
|
+
const imageProvider = multiStorage.getProvider('cloudflare-images');
|
|
590
|
+
if (imageProvider) {
|
|
591
|
+
// Stream to Cloudflare Images
|
|
592
|
+
const blob = await response.blob();
|
|
593
|
+
const result = await imageProvider.upload(destinationKey, blob, {
|
|
594
|
+
contentType,
|
|
595
|
+
metadata: importItem.metadata,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
results.succeeded.push({
|
|
599
|
+
sourceUrl: importItem.sourceUrl,
|
|
600
|
+
destinationKey,
|
|
601
|
+
storageObject: result,
|
|
602
|
+
});
|
|
603
|
+
results.totalProcessed++;
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Stream to default storage (R2 or local)
|
|
609
|
+
const stream = response.body;
|
|
610
|
+
if (!stream) {
|
|
611
|
+
throw new Error('No response body available');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const result = await storage.upload(destinationKey, stream, {
|
|
615
|
+
contentType,
|
|
616
|
+
metadata: importItem.metadata,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
results.succeeded.push({
|
|
620
|
+
sourceUrl: importItem.sourceUrl,
|
|
621
|
+
destinationKey,
|
|
622
|
+
storageObject: result,
|
|
623
|
+
});
|
|
624
|
+
results.totalProcessed++;
|
|
625
|
+
} catch (error) {
|
|
626
|
+
results.failed.push({
|
|
627
|
+
sourceUrl: importItem.sourceUrl,
|
|
628
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
629
|
+
});
|
|
630
|
+
results.totalProcessed++;
|
|
631
|
+
}
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
success: results.failed.length === 0,
|
|
638
|
+
data: results,
|
|
639
|
+
};
|
|
640
|
+
} catch (error) {
|
|
641
|
+
return {
|
|
642
|
+
success: false,
|
|
643
|
+
error: error instanceof Error ? error.message : 'Bulk import operation failed',
|
|
644
|
+
data: results,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Import a single media item from a remote HTTPS URL into storage.
|
|
651
|
+
*
|
|
652
|
+
* Validates the source URL for safety, fetches the resource, and uploads it to storage.
|
|
653
|
+
* If `options.onProgress` is provided and the response exposes a `content-length`, progress
|
|
654
|
+
* updates are emitted as a fraction between 0 and 1 during download.
|
|
655
|
+
*
|
|
656
|
+
* @param sourceUrl - The HTTPS URL of the resource to import; must pass SSRF-safe validation.
|
|
657
|
+
* @param destinationKey - Optional destination storage key; generated automatically when omitted.
|
|
658
|
+
* @param options.metadata - Optional metadata to attach to the uploaded object.
|
|
659
|
+
* @param options.onProgress - Optional callback invoked with a number in [0, 1] representing download progress.
|
|
660
|
+
* @returns On success, a response containing the uploaded `StorageObject`; on failure, a response containing an error message.
|
|
661
|
+
*/
|
|
662
|
+
export async function importFromUrlAction(
|
|
663
|
+
sourceUrl: string,
|
|
664
|
+
destinationKey?: string,
|
|
665
|
+
options?: {
|
|
666
|
+
metadata?: Record<string, any>;
|
|
667
|
+
onProgress?: (progress: number) => void;
|
|
668
|
+
},
|
|
669
|
+
): Promise<MediaActionResponse<StorageObject>> {
|
|
670
|
+
'use server';
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
// Validate URL to prevent SSRF attacks
|
|
674
|
+
const urlValidation = validateUrlForSSRF(sourceUrl);
|
|
675
|
+
if (!urlValidation.valid) {
|
|
676
|
+
throw new Error(urlValidation.error ?? 'Invalid or unsafe URL');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const response = await fetch(sourceUrl, {
|
|
680
|
+
// Add additional security headers
|
|
681
|
+
headers: {
|
|
682
|
+
'User-Agent': 'Storage-Service/1.0',
|
|
683
|
+
},
|
|
684
|
+
// Prevent redirects to internal URLs
|
|
685
|
+
redirect: 'error',
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
if (!response.ok) {
|
|
689
|
+
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
|
|
693
|
+
const contentLength = response.headers.get('content-length');
|
|
694
|
+
|
|
695
|
+
// Generate key if not provided
|
|
696
|
+
const key = destinationKey ?? generateStorageKey(sourceUrl);
|
|
697
|
+
|
|
698
|
+
// For progress tracking, we need to read the stream manually
|
|
699
|
+
if (options?.onProgress && contentLength && response.body) {
|
|
700
|
+
const total = parseInt(contentLength);
|
|
701
|
+
let loaded = 0;
|
|
702
|
+
|
|
703
|
+
const reader = response.body.getReader();
|
|
704
|
+
const chunks: Uint8Array[] = [];
|
|
705
|
+
|
|
706
|
+
while (true) {
|
|
707
|
+
const { done, value } = await reader.read();
|
|
708
|
+
if (done) break;
|
|
709
|
+
|
|
710
|
+
chunks.push(value);
|
|
711
|
+
loaded += value.length;
|
|
712
|
+
options.onProgress(loaded / total);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Combine chunks into a single blob
|
|
716
|
+
const blob = new Blob(
|
|
717
|
+
chunks.map(
|
|
718
|
+
chunk =>
|
|
719
|
+
chunk.buffer.slice(
|
|
720
|
+
chunk.byteOffset,
|
|
721
|
+
chunk.byteOffset + chunk.byteLength,
|
|
722
|
+
) as ArrayBuffer,
|
|
723
|
+
),
|
|
724
|
+
{ type: contentType },
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const storage = getStorage();
|
|
728
|
+
const result = await storage.upload(key, blob, {
|
|
729
|
+
contentType,
|
|
730
|
+
metadata: options.metadata,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
return { success: true, data: result };
|
|
734
|
+
} else {
|
|
735
|
+
// Simple stream without progress
|
|
736
|
+
const storage = getStorage();
|
|
737
|
+
const stream = response.body;
|
|
738
|
+
|
|
739
|
+
if (!stream) {
|
|
740
|
+
throw new Error('No response body available');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const result = await storage.upload(key, stream, {
|
|
744
|
+
contentType,
|
|
745
|
+
metadata: options?.metadata,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
return { success: true, data: result };
|
|
749
|
+
}
|
|
750
|
+
} catch (error) {
|
|
751
|
+
return {
|
|
752
|
+
success: false,
|
|
753
|
+
error: error instanceof Error ? error.message : 'Failed to import from URL',
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Helper function to generate storage key from URL
|
|
760
|
+
*/
|
|
761
|
+
function generateStorageKey(sourceUrl: string, metadata?: Record<string, any>): string {
|
|
762
|
+
try {
|
|
763
|
+
const url = new URL(sourceUrl);
|
|
764
|
+
const filename = url.pathname.split('/').pop() ?? 'imported-file';
|
|
765
|
+
const timestamp = Date.now();
|
|
766
|
+
|
|
767
|
+
// Determine prefix based on metadata
|
|
768
|
+
let prefix = 'imports';
|
|
769
|
+
if (metadata?.productId) {
|
|
770
|
+
prefix = `products/${metadata.productId}`;
|
|
771
|
+
} else if (metadata?.userId) {
|
|
772
|
+
prefix = `users/${metadata.userId}`;
|
|
773
|
+
} else if (metadata?.type === 'IMAGE') {
|
|
774
|
+
prefix = 'images';
|
|
775
|
+
} else if (metadata?.type === 'VIDEO') {
|
|
776
|
+
prefix = 'videos';
|
|
777
|
+
} else if (metadata?.type === 'DOCUMENT') {
|
|
778
|
+
prefix = 'documents';
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return `${prefix}/${timestamp}-${filename}`;
|
|
782
|
+
} catch {
|
|
783
|
+
// Fallback for invalid URLs
|
|
784
|
+
return `imports/${Date.now()}-imported-file`;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
//==============================================================================
|
|
789
|
+
// MULTI-STORAGE OPERATIONS
|
|
790
|
+
//==============================================================================
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Upload to a specific storage provider
|
|
794
|
+
*/
|
|
795
|
+
export async function uploadToProviderAction(
|
|
796
|
+
providerName: string,
|
|
797
|
+
key: string,
|
|
798
|
+
data: ArrayBuffer | Blob | Buffer | File | ReadableStream,
|
|
799
|
+
options?: UploadOptions,
|
|
800
|
+
): Promise<MediaActionResponse<StorageObject>> {
|
|
801
|
+
'use server';
|
|
802
|
+
try {
|
|
803
|
+
const provider = getMultiStorage().getProvider(providerName);
|
|
804
|
+
if (!provider) {
|
|
805
|
+
throw new Error(`Provider '${providerName}' not found`);
|
|
806
|
+
}
|
|
807
|
+
const result = await provider.upload(key, data, options);
|
|
808
|
+
return { success: true, data: result };
|
|
809
|
+
} catch (error) {
|
|
810
|
+
return {
|
|
811
|
+
success: false,
|
|
812
|
+
error: error instanceof Error ? error.message : 'Failed to upload to provider',
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* List available storage providers
|
|
819
|
+
*/
|
|
820
|
+
export async function listProvidersAction(): Promise<MediaActionResponse<string[]>> {
|
|
821
|
+
'use server';
|
|
822
|
+
try {
|
|
823
|
+
const providers = getMultiStorage().getProviderNames();
|
|
824
|
+
return { success: true, data: providers };
|
|
825
|
+
} catch (error) {
|
|
826
|
+
return {
|
|
827
|
+
success: false,
|
|
828
|
+
error: error instanceof Error ? error.message : 'Failed to list providers',
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Copy media between providers
|
|
835
|
+
*/
|
|
836
|
+
export async function copyBetweenProvidersAction(
|
|
837
|
+
sourceProvider: string,
|
|
838
|
+
destinationProvider: string,
|
|
839
|
+
key: string,
|
|
840
|
+
options?: UploadOptions,
|
|
841
|
+
): Promise<MediaActionResponse<StorageObject>> {
|
|
842
|
+
'use server';
|
|
843
|
+
try {
|
|
844
|
+
const multiStorage = getMultiStorage();
|
|
845
|
+
const source = multiStorage.getProvider(sourceProvider);
|
|
846
|
+
const destination = multiStorage.getProvider(destinationProvider);
|
|
847
|
+
|
|
848
|
+
if (!source) {
|
|
849
|
+
throw new Error(`Source provider '${sourceProvider}' not found`);
|
|
850
|
+
}
|
|
851
|
+
if (!destination) {
|
|
852
|
+
throw new Error(`Destination provider '${destinationProvider}' not found`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Download from source
|
|
856
|
+
const blob = await source.download(key);
|
|
857
|
+
const metadata = await source.getMetadata(key);
|
|
858
|
+
|
|
859
|
+
// Upload to destination
|
|
860
|
+
const result = await destination.upload(key, blob, {
|
|
861
|
+
contentType: metadata.contentType,
|
|
862
|
+
...options,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
return { success: true, data: result };
|
|
866
|
+
} catch (error) {
|
|
867
|
+
return {
|
|
868
|
+
success: false,
|
|
869
|
+
error: error instanceof Error ? error.message : 'Failed to copy between providers',
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Create an empty folder (placeholder with trailing slash)
|
|
876
|
+
*/
|
|
877
|
+
export async function createFolderAction(
|
|
878
|
+
key: string,
|
|
879
|
+
options?: UploadOptions,
|
|
880
|
+
): Promise<MediaActionResponse<StorageObject>> {
|
|
881
|
+
'use server';
|
|
882
|
+
try {
|
|
883
|
+
// Ensure key ends with slash for folder
|
|
884
|
+
const folderKey = key.endsWith('/') ? key : `${key}/`;
|
|
885
|
+
|
|
886
|
+
// Create empty folder by uploading empty content
|
|
887
|
+
const emptyContent = Buffer.from(new Uint8Array(0));
|
|
888
|
+
const result = await getStorage().upload(folderKey, emptyContent, {
|
|
889
|
+
contentType: 'application/x-directory',
|
|
890
|
+
...options,
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
return { success: true, data: result };
|
|
894
|
+
} catch (error) {
|
|
895
|
+
return {
|
|
896
|
+
success: false,
|
|
897
|
+
error: error instanceof Error ? error.message : 'Failed to create folder',
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Copy media between storage locations
|
|
904
|
+
*/
|
|
905
|
+
export async function copyMediaAction(
|
|
906
|
+
sourceKey: string,
|
|
907
|
+
destinationKey: string,
|
|
908
|
+
options?: UploadOptions,
|
|
909
|
+
): Promise<MediaActionResponse<StorageObject>> {
|
|
910
|
+
'use server';
|
|
911
|
+
try {
|
|
912
|
+
// Download from source
|
|
913
|
+
const blob = await getStorage().download(sourceKey);
|
|
914
|
+
|
|
915
|
+
// Upload to destination
|
|
916
|
+
const result = await getStorage().upload(destinationKey, blob, {
|
|
917
|
+
contentType: options?.contentType,
|
|
918
|
+
...options,
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
return { success: true, data: result };
|
|
922
|
+
} catch (error) {
|
|
923
|
+
return {
|
|
924
|
+
success: false,
|
|
925
|
+
error: error instanceof Error ? error.message : 'Failed to copy media',
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Get presigned upload URL for direct client uploads
|
|
932
|
+
*/
|
|
933
|
+
export async function getPresignedUploadUrlAction(
|
|
934
|
+
key: string,
|
|
935
|
+
options?: { expiresIn?: number; contentType?: string },
|
|
936
|
+
): Promise<MediaActionResponse<{ url: string; fields: Record<string, string>; expiresAt: Date }>> {
|
|
937
|
+
'use server';
|
|
938
|
+
try {
|
|
939
|
+
const provider = getStorage();
|
|
940
|
+
|
|
941
|
+
if (!provider.getPresignedUploadUrl) {
|
|
942
|
+
return {
|
|
943
|
+
success: false,
|
|
944
|
+
error: 'Provider does not support presigned URLs',
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const result = await provider.getPresignedUploadUrl(key, options);
|
|
949
|
+
|
|
950
|
+
return { success: true, data: result };
|
|
951
|
+
} catch (error) {
|
|
952
|
+
return {
|
|
953
|
+
success: false,
|
|
954
|
+
error: error instanceof Error ? error.message : 'Failed to get presigned upload URL',
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Get storage provider capabilities
|
|
961
|
+
*/
|
|
962
|
+
export async function getStorageCapabilitiesAction(): Promise<
|
|
963
|
+
MediaActionResponse<{
|
|
964
|
+
multipart: boolean;
|
|
965
|
+
presignedUrls: boolean;
|
|
966
|
+
progressTracking: boolean;
|
|
967
|
+
abortSignal: boolean;
|
|
968
|
+
metadata: boolean;
|
|
969
|
+
customDomains: boolean;
|
|
970
|
+
edgeCompatible: boolean;
|
|
971
|
+
}>
|
|
972
|
+
> {
|
|
973
|
+
'use server';
|
|
974
|
+
try {
|
|
975
|
+
const provider = getStorage();
|
|
976
|
+
const capabilities = provider.getCapabilities?.() ?? {
|
|
977
|
+
multipart: false,
|
|
978
|
+
presignedUrls: false,
|
|
979
|
+
progressTracking: false,
|
|
980
|
+
abortSignal: false,
|
|
981
|
+
metadata: false,
|
|
982
|
+
customDomains: false,
|
|
983
|
+
edgeCompatible: false,
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
return { success: true, data: capabilities };
|
|
987
|
+
} catch (error) {
|
|
988
|
+
return {
|
|
989
|
+
success: false,
|
|
990
|
+
error: error instanceof Error ? error.message : 'Failed to get storage capabilities',
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Validate a file's size, MIME type, and extension against provided constraints.
|
|
997
|
+
*
|
|
998
|
+
* Checks the file's byte size against `maxFileSize`, verifies the MIME type against
|
|
999
|
+
* `allowedMimeTypes` (supports wildcard patterns like `image/*`), and verifies the
|
|
1000
|
+
* filename extension against `allowedExtensions`.
|
|
1001
|
+
*
|
|
1002
|
+
* @param file - The file to validate; must include `size`, `type`, and `name`.
|
|
1003
|
+
* @param options.maxFileSize - Maximum allowed file size in bytes.
|
|
1004
|
+
* @param options.allowedMimeTypes - Allowed MIME types; supports exact types and wildcards (e.g., `image/*`).
|
|
1005
|
+
* @param options.allowedExtensions - Allowed file extensions including the leading dot (e.g., `.jpg`, `.png`).
|
|
1006
|
+
* @returns An object with `valid` set to `true` when no validation errors were found, and `errors` listing any validation messages.
|
|
1007
|
+
*/
|
|
1008
|
+
export async function validateFileAction(
|
|
1009
|
+
file: { size: number; type: string; name: string },
|
|
1010
|
+
options?: {
|
|
1011
|
+
maxFileSize?: number;
|
|
1012
|
+
allowedMimeTypes?: string[];
|
|
1013
|
+
allowedExtensions?: string[];
|
|
1014
|
+
},
|
|
1015
|
+
): Promise<MediaActionResponse<{ valid: boolean; errors: string[] }>> {
|
|
1016
|
+
'use server';
|
|
1017
|
+
try {
|
|
1018
|
+
const errors: string[] = [];
|
|
1019
|
+
|
|
1020
|
+
// Validate file size
|
|
1021
|
+
if (options?.maxFileSize && file.size > options.maxFileSize) {
|
|
1022
|
+
errors.push(`File size ${file.size} bytes exceeds maximum ${options.maxFileSize} bytes`);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Validate MIME type
|
|
1026
|
+
if (options?.allowedMimeTypes && options.allowedMimeTypes.length > 0) {
|
|
1027
|
+
const normalizedMimeType = file.type.toLowerCase().trim();
|
|
1028
|
+
const normalizedAllowed = options.allowedMimeTypes.map(t => t.toLowerCase().trim());
|
|
1029
|
+
|
|
1030
|
+
if (!normalizedAllowed.includes(normalizedMimeType)) {
|
|
1031
|
+
// Check wildcard patterns
|
|
1032
|
+
const wildcardMatch = normalizedAllowed.some(allowed => {
|
|
1033
|
+
if (allowed.endsWith('/*')) {
|
|
1034
|
+
const prefix = allowed.slice(0, -2);
|
|
1035
|
+
return normalizedMimeType.startsWith(prefix);
|
|
1036
|
+
}
|
|
1037
|
+
return false;
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
if (!wildcardMatch) {
|
|
1041
|
+
errors.push(
|
|
1042
|
+
`MIME type '${file.type}' is not allowed. Allowed types: ${options.allowedMimeTypes.join(', ')}`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Validate file extension
|
|
1049
|
+
if (options?.allowedExtensions && options.allowedExtensions.length > 0) {
|
|
1050
|
+
const extension = file.name.match(/\.[^/.]+$/)?.[0]?.toLowerCase();
|
|
1051
|
+
if (extension && !options.allowedExtensions.includes(extension)) {
|
|
1052
|
+
errors.push(
|
|
1053
|
+
`File extension ${extension} is not allowed. Allowed: ${options.allowedExtensions.join(', ')}`,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
success: true,
|
|
1060
|
+
data: {
|
|
1061
|
+
valid: errors.length === 0,
|
|
1062
|
+
errors,
|
|
1063
|
+
},
|
|
1064
|
+
};
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
return {
|
|
1067
|
+
success: false,
|
|
1068
|
+
error: error instanceof Error ? error.message : 'Failed to validate file',
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
}
|