@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,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Authentication and Authorization Utilities for Storage Actions
|
|
3
|
+
*
|
|
4
|
+
* This module provides authentication and authorization helpers for storage server actions.
|
|
5
|
+
* These functions should be implemented by the consuming application to integrate with
|
|
6
|
+
* the authentication system.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: These are placeholder functions that should be replaced with actual implementations
|
|
9
|
+
* from your authentication package (e.g., @od-oneapp/auth, NextAuth, etc.)
|
|
10
|
+
*
|
|
11
|
+
* @module @od-oneapp/storage/auth-helpers
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { logError, logWarn } from '@repo/shared/logs';
|
|
15
|
+
|
|
16
|
+
import { safeEnv } from '../env';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Retrieves the current authenticated user session
|
|
20
|
+
*
|
|
21
|
+
* This is a placeholder implementation that should be replaced with your actual
|
|
22
|
+
* authentication system integration (NextAuth, @od-oneapp/auth, Clerk, etc.).
|
|
23
|
+
*
|
|
24
|
+
* **Implementation Pattern:**
|
|
25
|
+
* ```typescript
|
|
26
|
+
* // Example with @od-oneapp/auth:
|
|
27
|
+
* const { auth } = await import('@od-oneapp/auth/server');
|
|
28
|
+
* return await auth();
|
|
29
|
+
*
|
|
30
|
+
* // Example with NextAuth:
|
|
31
|
+
* const { getServerSession } = await import('next-auth');
|
|
32
|
+
* return await getServerSession(authOptions);
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @returns Session object containing user info, or `null` if unauthenticated
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* export async function uploadMediaAction(key: string, data: Blob) {
|
|
40
|
+
* 'use server';
|
|
41
|
+
*
|
|
42
|
+
* const session = await getSession();
|
|
43
|
+
* if (!session?.user) {
|
|
44
|
+
* return { success: false, error: 'Unauthorized' };
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* // Proceed with authenticated upload
|
|
48
|
+
* const storage = getStorage();
|
|
49
|
+
* const result = await storage.upload(key, data);
|
|
50
|
+
*
|
|
51
|
+
* return { success: true, data: result };
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export async function getSession(): Promise<{ user: { id: string; email?: string } } | null> {
|
|
56
|
+
// Permissive-by-default: return null (unauthenticated)
|
|
57
|
+
// Consuming actions should enforce auth based on env flags
|
|
58
|
+
try {
|
|
59
|
+
// If a real auth implementation is available, prefer it (optional dynamic import pattern)
|
|
60
|
+
// const { auth } = await import('@od-oneapp/auth/server');
|
|
61
|
+
// return await auth();
|
|
62
|
+
return null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Checks if a user has permission to manage a specific product
|
|
70
|
+
*
|
|
71
|
+
* This is a placeholder that returns `false` by default (deny-by-default security).
|
|
72
|
+
* Replace with your actual authorization logic that checks:
|
|
73
|
+
* - Product ownership
|
|
74
|
+
* - Role-based permissions (admin, editor, viewer)
|
|
75
|
+
* - Team/organization membership
|
|
76
|
+
*
|
|
77
|
+
* **Implementation Pattern:**
|
|
78
|
+
* ```typescript
|
|
79
|
+
* // Example with database check:
|
|
80
|
+
* const product = await db.product.findUnique({
|
|
81
|
+
* where: { id: productId },
|
|
82
|
+
* include: { team: { members: true } }
|
|
83
|
+
* });
|
|
84
|
+
*
|
|
85
|
+
* return product?.ownerId === userId ||
|
|
86
|
+
* product?.team?.members.some(m => m.userId === userId && m.role === 'admin');
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* @param userId - User ID to authorize
|
|
90
|
+
* @param productId - Product ID to check access for
|
|
91
|
+
* @returns `true` if user can manage the product, `false` otherwise
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* export async function deleteProductMediaAction(mediaId: string) {
|
|
96
|
+
* 'use server';
|
|
97
|
+
*
|
|
98
|
+
* const session = await getSession();
|
|
99
|
+
* if (!session?.user) {
|
|
100
|
+
* return { success: false, error: 'Unauthorized' };
|
|
101
|
+
* }
|
|
102
|
+
*
|
|
103
|
+
* const media = await db.productMedia.findUnique({ where: { id: mediaId } });
|
|
104
|
+
* if (!media) {
|
|
105
|
+
* return { success: false, error: 'Not found' };
|
|
106
|
+
* }
|
|
107
|
+
*
|
|
108
|
+
* const canManage = await canUserManageProduct(session.user.id, media.productId);
|
|
109
|
+
* if (!canManage) {
|
|
110
|
+
* return { success: false, error: 'Forbidden' };
|
|
111
|
+
* }
|
|
112
|
+
*
|
|
113
|
+
* await getStorage().delete(media.storageKey);
|
|
114
|
+
* return { success: true };
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export async function canUserManageProduct(_userId: string, _productId: string): Promise<boolean> {
|
|
119
|
+
// Deny-by-default (restrictive): returns false until replaced with actual authorization logic
|
|
120
|
+
// Replace with actual authorization logic in the consuming app
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extracts client IP address or identifier for rate limiting
|
|
126
|
+
*
|
|
127
|
+
* This placeholder returns `'unknown'`. Implement proper IP extraction based on:
|
|
128
|
+
* - Proxy headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP)
|
|
129
|
+
* - Direct connection IP
|
|
130
|
+
* - User ID for authenticated rate limiting
|
|
131
|
+
*
|
|
132
|
+
* **Implementation Pattern:**
|
|
133
|
+
* ```typescript
|
|
134
|
+
* export function getClientIP(_request?: Request): string {
|
|
135
|
+
* if (!request) return 'unknown';
|
|
136
|
+
*
|
|
137
|
+
* // Cloudflare
|
|
138
|
+
* const cfIP = request.headers.get('cf-connecting-ip');
|
|
139
|
+
* if (cfIP) return cfIP;
|
|
140
|
+
*
|
|
141
|
+
* // Behind proxy
|
|
142
|
+
* const forwarded = request.headers.get('x-forwarded-for');
|
|
143
|
+
* if (forwarded) return forwarded.split(',')[0]?.trim() || 'unknown';
|
|
144
|
+
*
|
|
145
|
+
* // Direct connection
|
|
146
|
+
* const realIP = request.headers.get('x-real-ip');
|
|
147
|
+
* return realIP || 'unknown';
|
|
148
|
+
* }
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* @param request - Optional Request object to extract IP from
|
|
152
|
+
* @returns Client IP address or `'unknown'` if not determinable
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* export async function uploadAction(request: Request, data: FormData) {
|
|
157
|
+
* 'use server';
|
|
158
|
+
*
|
|
159
|
+
* const clientIP = getClientIP(request);
|
|
160
|
+
* const rateLimit = await checkRateLimit(
|
|
161
|
+
* clientIP,
|
|
162
|
+
* 'storage:upload',
|
|
163
|
+
* 100, // max requests
|
|
164
|
+
* 60000 // per minute
|
|
165
|
+
* );
|
|
166
|
+
*
|
|
167
|
+
* if (!rateLimit.allowed) {
|
|
168
|
+
* return {
|
|
169
|
+
* success: false,
|
|
170
|
+
* error: 'Rate limit exceeded',
|
|
171
|
+
* resetAt: rateLimit.resetAt
|
|
172
|
+
* };
|
|
173
|
+
* }
|
|
174
|
+
*
|
|
175
|
+
* // Process upload...
|
|
176
|
+
* }
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
export function getClientIP(_request?: Request): string {
|
|
180
|
+
// TODO: Implement IP extraction from request headers
|
|
181
|
+
// Example:
|
|
182
|
+
// if (request) {
|
|
183
|
+
// return request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
|
184
|
+
// request.headers.get('x-real-ip') ||
|
|
185
|
+
// 'unknown';
|
|
186
|
+
// }
|
|
187
|
+
|
|
188
|
+
// Fallback for server actions (may need to pass request through)
|
|
189
|
+
return 'unknown';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Checks if an identifier has exceeded rate limits for a storage action
|
|
194
|
+
*
|
|
195
|
+
* This placeholder always allows requests when `STORAGE_ENABLE_RATE_LIMIT=false`.
|
|
196
|
+
* When enabled, implement with Redis, Upstash, or in-memory store.
|
|
197
|
+
*
|
|
198
|
+
* **Implementation Pattern (Redis):**
|
|
199
|
+
* ```typescript
|
|
200
|
+
* import { Redis } from '@upstash/redis';
|
|
201
|
+
*
|
|
202
|
+
* export async function checkRateLimit(
|
|
203
|
+
* identifier: string,
|
|
204
|
+
* action: string,
|
|
205
|
+
* maxRequests: number,
|
|
206
|
+
* windowMs: number
|
|
207
|
+
* ) {
|
|
208
|
+
* const redis = Redis.fromEnv();
|
|
209
|
+
* const key = `ratelimit:${action}:${identifier}`;
|
|
210
|
+
*
|
|
211
|
+
* const count = await redis.incr(key);
|
|
212
|
+
* if (count === 1) {
|
|
213
|
+
* await redis.pexpire(key, windowMs);
|
|
214
|
+
* }
|
|
215
|
+
*
|
|
216
|
+
* const ttl = await redis.pttl(key);
|
|
217
|
+
* const resetAt = new Date(Date.now() + ttl);
|
|
218
|
+
*
|
|
219
|
+
* return {
|
|
220
|
+
* allowed: count <= maxRequests,
|
|
221
|
+
* remaining: Math.max(0, maxRequests - count),
|
|
222
|
+
* resetAt
|
|
223
|
+
* };
|
|
224
|
+
* }
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* @param identifier - Unique identifier (IP address, user ID, API key)
|
|
228
|
+
* @param action - Action key for rate limiting (e.g., `'storage:upload'`, `'storage:delete'`)
|
|
229
|
+
* @param maxRequests - Maximum requests allowed in the time window
|
|
230
|
+
* @param windowMs - Time window in milliseconds (e.g., 60000 for 1 minute)
|
|
231
|
+
* @returns Rate limit status with allowed flag, remaining count, and reset time
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```typescript
|
|
235
|
+
* export async function uploadMediaAction(key: string, data: Blob) {
|
|
236
|
+
* 'use server';
|
|
237
|
+
*
|
|
238
|
+
* const session = await getSession();
|
|
239
|
+
* const identifier = session?.user.id || getClientIP();
|
|
240
|
+
*
|
|
241
|
+
* const limit = await checkRateLimit(identifier, 'storage:upload', 100, 60000);
|
|
242
|
+
* if (!limit.allowed) {
|
|
243
|
+
* return {
|
|
244
|
+
* success: false,
|
|
245
|
+
* error: `Rate limit exceeded. ${limit.remaining} requests remaining. Resets at ${limit.resetAt.toISOString()}`,
|
|
246
|
+
* };
|
|
247
|
+
* }
|
|
248
|
+
*
|
|
249
|
+
* const storage = getStorage();
|
|
250
|
+
* const result = await storage.upload(key, data);
|
|
251
|
+
* return { success: true, data: result };
|
|
252
|
+
* }
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
export async function checkRateLimit(
|
|
256
|
+
identifier: string,
|
|
257
|
+
action: string,
|
|
258
|
+
maxRequests: number,
|
|
259
|
+
windowMs: number,
|
|
260
|
+
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
|
|
261
|
+
// Check feature flag to decide behavior
|
|
262
|
+
const { safeEnv } = await import('../env');
|
|
263
|
+
const env = safeEnv();
|
|
264
|
+
|
|
265
|
+
if (!env.STORAGE_ENABLE_RATE_LIMIT) {
|
|
266
|
+
// Permissive: allow
|
|
267
|
+
return {
|
|
268
|
+
allowed: true,
|
|
269
|
+
remaining: maxRequests,
|
|
270
|
+
resetAt: new Date(Date.now() + windowMs),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// If enabled but no implementation, allow but with zero remaining to encourage integration
|
|
275
|
+
return {
|
|
276
|
+
allowed: true,
|
|
277
|
+
remaining: Math.max(0, maxRequests - 1),
|
|
278
|
+
resetAt: new Date(Date.now() + windowMs),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Validate a CSRF token according to runtime enforcement settings.
|
|
284
|
+
*
|
|
285
|
+
* 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.
|
|
286
|
+
*
|
|
287
|
+
* @param token - CSRF token to validate
|
|
288
|
+
* @param request - Optional request object that consuming applications may inspect when implementing real validation
|
|
289
|
+
* @returns `true` if the token is considered valid (or enforcement is disabled / env unavailable), `false` otherwise
|
|
290
|
+
* @deprecated Use validateCSRFOrigin() for better origin-based validation
|
|
291
|
+
*/
|
|
292
|
+
export function validateCSRFToken(token: string, _request?: Request): boolean {
|
|
293
|
+
// Enforce behavior based on env flag
|
|
294
|
+
try {
|
|
295
|
+
const env = safeEnv();
|
|
296
|
+
if (!env.STORAGE_ENFORCE_CSRF) {
|
|
297
|
+
// Permissive-by-default: skip CSRF enforcement
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// If env not available, be permissive in package context
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// If enforcement is enabled but no real validation is available, fail closed
|
|
306
|
+
return typeof token === 'string' && token.length > 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Validates CSRF protection by checking request origin matches host
|
|
311
|
+
*
|
|
312
|
+
* Next.js has built-in origin checking for Server Actions, but this adds
|
|
313
|
+
* explicit validation for defense-in-depth security.
|
|
314
|
+
*
|
|
315
|
+
* This function validates:
|
|
316
|
+
* - The request origin header matches the host header
|
|
317
|
+
* - Optional x-csrf-token header if provided
|
|
318
|
+
*
|
|
319
|
+
* @returns `true` if CSRF validation passes or is disabled, `false` if validation fails
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* ```typescript
|
|
323
|
+
* export async function uploadMediaAction(...) {
|
|
324
|
+
* 'use server';
|
|
325
|
+
*
|
|
326
|
+
* const csrfValid = await validateCSRFOrigin();
|
|
327
|
+
* if (!csrfValid) {
|
|
328
|
+
* return { success: false, error: 'Invalid request origin' };
|
|
329
|
+
* }
|
|
330
|
+
* // ... proceed with action
|
|
331
|
+
* }
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
export async function validateCSRFOrigin(): Promise<boolean> {
|
|
335
|
+
const env = safeEnv();
|
|
336
|
+
|
|
337
|
+
if (!env.STORAGE_ENFORCE_CSRF) {
|
|
338
|
+
return true; // CSRF validation disabled
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const { headers } = await import('next/headers');
|
|
343
|
+
const headersList = await headers();
|
|
344
|
+
const origin = headersList.get('origin');
|
|
345
|
+
const host = headersList.get('host');
|
|
346
|
+
|
|
347
|
+
// Validate origin matches host
|
|
348
|
+
if (origin && host) {
|
|
349
|
+
try {
|
|
350
|
+
const originUrl = new URL(origin);
|
|
351
|
+
const expectedOrigin = `${originUrl.protocol}//${host}`;
|
|
352
|
+
|
|
353
|
+
if (origin !== expectedOrigin) {
|
|
354
|
+
logWarn('CSRF validation failed: origin mismatch', {
|
|
355
|
+
origin,
|
|
356
|
+
expectedOrigin,
|
|
357
|
+
host,
|
|
358
|
+
});
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logError('CSRF validation error: invalid origin URL', {
|
|
363
|
+
origin,
|
|
364
|
+
error,
|
|
365
|
+
});
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Additional: Validate custom CSRF token if provided
|
|
371
|
+
const csrfToken = headersList.get('x-csrf-token');
|
|
372
|
+
if (csrfToken) {
|
|
373
|
+
// Basic validation - consuming apps should enhance this with session validation
|
|
374
|
+
// For now, just check it's a non-empty string
|
|
375
|
+
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return true;
|
|
381
|
+
} catch (error) {
|
|
382
|
+
// If headers() fails (not in Next.js context), be permissive for package usage
|
|
383
|
+
logWarn('CSRF validation skipped: not in Next.js context', { error });
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Storage Provider Capabilities Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for checking storage provider capabilities and feature support.
|
|
5
|
+
* Helps determine which features are available for each provider.
|
|
6
|
+
*
|
|
7
|
+
* @module @repo/storage/capabilities
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_STORAGE_CAPABILITIES, MULTIPART_THRESHOLDS } from './constants';
|
|
11
|
+
|
|
12
|
+
import type { StorageCapabilities, StorageProvider } from '../types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a storage provider has a specific capability
|
|
16
|
+
* @param provider - The storage provider to check
|
|
17
|
+
* @param capability - The capability to check for
|
|
18
|
+
* @returns True if the provider supports the capability
|
|
19
|
+
*/
|
|
20
|
+
export function hasCapability(
|
|
21
|
+
provider: StorageProvider,
|
|
22
|
+
capability: keyof StorageCapabilities,
|
|
23
|
+
): boolean {
|
|
24
|
+
const capabilities = provider.getCapabilities?.();
|
|
25
|
+
return capabilities?.[capability] ?? false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a storage provider supports multiple capabilities
|
|
30
|
+
* @param provider - The storage provider to check
|
|
31
|
+
* @param capabilities - Array of capabilities to check for
|
|
32
|
+
* @returns True if the provider supports all capabilities
|
|
33
|
+
*/
|
|
34
|
+
export function hasAllCapabilities(
|
|
35
|
+
provider: StorageProvider,
|
|
36
|
+
capabilities: Array<keyof StorageCapabilities>,
|
|
37
|
+
): boolean {
|
|
38
|
+
return capabilities.every(capability => hasCapability(provider, capability));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if a storage provider supports any of the specified capabilities
|
|
43
|
+
* @param provider - The storage provider to check
|
|
44
|
+
* @param capabilities - Array of capabilities to check for
|
|
45
|
+
* @returns True if the provider supports at least one capability
|
|
46
|
+
*/
|
|
47
|
+
export function hasAnyCapability(
|
|
48
|
+
provider: StorageProvider,
|
|
49
|
+
capabilities: Array<keyof StorageCapabilities>,
|
|
50
|
+
): boolean {
|
|
51
|
+
return capabilities.some(capability => hasCapability(provider, capability));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get all capabilities supported by a storage provider
|
|
56
|
+
* @param provider - The storage provider to check
|
|
57
|
+
* @returns Object with all capabilities and their support status
|
|
58
|
+
*/
|
|
59
|
+
export function getProviderCapabilities(provider: StorageProvider): StorageCapabilities {
|
|
60
|
+
return provider.getCapabilities?.() ?? { ...DEFAULT_STORAGE_CAPABILITIES };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get a human-readable description of provider capabilities
|
|
65
|
+
* @param provider - The storage provider to describe
|
|
66
|
+
* @returns String describing the provider's capabilities
|
|
67
|
+
*/
|
|
68
|
+
export function describeProviderCapabilities(provider: StorageProvider): string {
|
|
69
|
+
const capabilities = getProviderCapabilities(provider);
|
|
70
|
+
const supportedFeatures: string[] = [];
|
|
71
|
+
const unsupportedFeatures: string[] = [];
|
|
72
|
+
|
|
73
|
+
if (capabilities.multipart) supportedFeatures.push('multipart uploads');
|
|
74
|
+
else unsupportedFeatures.push('multipart uploads');
|
|
75
|
+
|
|
76
|
+
if (capabilities.presignedUrls) supportedFeatures.push('presigned URLs');
|
|
77
|
+
else unsupportedFeatures.push('presigned URLs');
|
|
78
|
+
|
|
79
|
+
if (capabilities.progressTracking) supportedFeatures.push('progress tracking');
|
|
80
|
+
else unsupportedFeatures.push('progress tracking');
|
|
81
|
+
|
|
82
|
+
if (capabilities.abortSignal) supportedFeatures.push('abort signals');
|
|
83
|
+
else unsupportedFeatures.push('abort signals');
|
|
84
|
+
|
|
85
|
+
if (capabilities.metadata) supportedFeatures.push('metadata');
|
|
86
|
+
else unsupportedFeatures.push('metadata');
|
|
87
|
+
|
|
88
|
+
if (capabilities.customDomains) supportedFeatures.push('custom domains');
|
|
89
|
+
else unsupportedFeatures.push('custom domains');
|
|
90
|
+
|
|
91
|
+
if (capabilities.edgeCompatible) supportedFeatures.push('edge runtime');
|
|
92
|
+
else unsupportedFeatures.push('edge runtime');
|
|
93
|
+
|
|
94
|
+
let description = `Provider supports: ${supportedFeatures.join(', ')}`;
|
|
95
|
+
|
|
96
|
+
if (unsupportedFeatures.length > 0) {
|
|
97
|
+
description += `\nProvider does not support: ${unsupportedFeatures.join(', ')}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return description;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate that a provider supports the required capabilities for an operation
|
|
105
|
+
* @param provider - The storage provider to validate
|
|
106
|
+
* @param requiredCapabilities - Capabilities required for the operation
|
|
107
|
+
* @throws Error if provider doesn't support required capabilities
|
|
108
|
+
*/
|
|
109
|
+
export function validateProviderCapabilities(
|
|
110
|
+
provider: StorageProvider,
|
|
111
|
+
requiredCapabilities: Array<keyof StorageCapabilities>,
|
|
112
|
+
): void {
|
|
113
|
+
const missingCapabilities = requiredCapabilities.filter(
|
|
114
|
+
capability => !hasCapability(provider, capability),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (missingCapabilities.length > 0) {
|
|
118
|
+
const providerName = provider.constructor.name;
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Provider ${providerName} does not support required capabilities: ${missingCapabilities.join(', ')}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the best provider for a specific use case based on capabilities
|
|
127
|
+
* @param providers - Array of storage providers to choose from
|
|
128
|
+
* @param requiredCapabilities - Capabilities required for the use case
|
|
129
|
+
* @returns The best provider or null if none meet the requirements
|
|
130
|
+
*/
|
|
131
|
+
export function getBestProvider(
|
|
132
|
+
providers: StorageProvider[],
|
|
133
|
+
requiredCapabilities: Array<keyof StorageCapabilities>,
|
|
134
|
+
): StorageProvider | null {
|
|
135
|
+
const suitableProviders = providers.filter(provider =>
|
|
136
|
+
hasAllCapabilities(provider, requiredCapabilities),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (suitableProviders.length === 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If multiple providers are suitable, prefer edge-compatible ones
|
|
144
|
+
const edgeCompatible = suitableProviders.filter(provider =>
|
|
145
|
+
hasCapability(provider, 'edgeCompatible'),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return edgeCompatible.length > 0 ? (edgeCompatible[0] ?? null) : (suitableProviders[0] ?? null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Builds a map from provider names to their reported storage capabilities.
|
|
153
|
+
*
|
|
154
|
+
* @param providers - Array of entries each containing a `name` (used as the map key) and a `provider` instance
|
|
155
|
+
* @returns A mapping from each provider name to its `StorageCapabilities`
|
|
156
|
+
*/
|
|
157
|
+
export function getCapabilityMatrix(
|
|
158
|
+
providers: Array<{ name: string; provider: StorageProvider }>,
|
|
159
|
+
): Record<string, StorageCapabilities> {
|
|
160
|
+
const matrix: Record<string, StorageCapabilities> = {};
|
|
161
|
+
|
|
162
|
+
for (const { name, provider } of providers) {
|
|
163
|
+
matrix[name] = getProviderCapabilities(provider);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return matrix;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Evaluate a storage provider's suitability for a specific file and produce actionable recommendations and warnings.
|
|
171
|
+
*
|
|
172
|
+
* @param provider - The storage provider to evaluate
|
|
173
|
+
* @param fileSize - File size in bytes
|
|
174
|
+
* @param fileType - MIME type of the file
|
|
175
|
+
* @returns An object with `suitable` (`true` if there are no warnings, `false` otherwise), `recommendations` (suggested actions to improve handling), and `warnings` (issues that reduce suitability)
|
|
176
|
+
*/
|
|
177
|
+
export function checkProviderSuitability(
|
|
178
|
+
provider: StorageProvider,
|
|
179
|
+
fileSize: number,
|
|
180
|
+
fileType: string,
|
|
181
|
+
): {
|
|
182
|
+
suitable: boolean;
|
|
183
|
+
recommendations: string[];
|
|
184
|
+
warnings: string[];
|
|
185
|
+
} {
|
|
186
|
+
const capabilities = getProviderCapabilities(provider);
|
|
187
|
+
const recommendations: string[] = [];
|
|
188
|
+
const warnings: string[] = [];
|
|
189
|
+
|
|
190
|
+
// Check file size suitability
|
|
191
|
+
if (fileSize > MULTIPART_THRESHOLDS.SMALL_FILE) {
|
|
192
|
+
// > 100MB
|
|
193
|
+
if (!capabilities.multipart) {
|
|
194
|
+
warnings.push('Large file detected but provider does not support multipart uploads');
|
|
195
|
+
} else {
|
|
196
|
+
recommendations.push('Use multipart upload for this large file');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check file type suitability
|
|
201
|
+
if (fileType.startsWith('image/')) {
|
|
202
|
+
if (capabilities.metadata) {
|
|
203
|
+
recommendations.push('Consider storing image metadata for better organization');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (fileType.startsWith('video/')) {
|
|
208
|
+
if (!capabilities.multipart) {
|
|
209
|
+
warnings.push('Video files are typically large and benefit from multipart uploads');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check edge compatibility
|
|
214
|
+
if (fileType.startsWith('text/') && !capabilities.edgeCompatible) {
|
|
215
|
+
recommendations.push('Text files could be processed in edge runtime for better performance');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const suitable = warnings.length === 0;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
suitable,
|
|
222
|
+
recommendations,
|
|
223
|
+
warnings,
|
|
224
|
+
};
|
|
225
|
+
}
|