@gilhrpenner/convex-files-control 0.1.1 → 0.2.0
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 +243 -207
- package/dist/client/http.d.ts +4 -4
- package/dist/client/http.d.ts.map +1 -1
- package/dist/client/http.js +39 -10
- package/dist/client/http.js.map +1 -1
- package/dist/client/index.d.ts +66 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +136 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +2 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/download.d.ts +2 -0
- package/dist/component/download.d.ts.map +1 -1
- package/dist/component/download.js +33 -12
- package/dist/component/download.js.map +1 -1
- package/dist/component/schema.d.ts +3 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +1 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +10 -6
- package/dist/react/index.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/client-extra.test.ts +157 -4
- package/src/__tests__/client.test.ts +572 -46
- package/src/__tests__/download-core.test.ts +70 -0
- package/src/__tests__/entrypoints.test.ts +13 -0
- package/src/__tests__/http.test.ts +34 -0
- package/src/__tests__/react.test.ts +10 -16
- package/src/__tests__/shared.test.ts +0 -5
- package/src/__tests__/transfer.test.ts +103 -0
- package/src/client/http.ts +51 -10
- package/src/client/index.ts +242 -51
- package/src/component/_generated/component.ts +2 -0
- package/src/component/download.ts +35 -14
- package/src/component/schema.ts +1 -0
- package/src/react/index.ts +11 -10
package/src/client/index.ts
CHANGED
|
@@ -31,12 +31,11 @@ import {
|
|
|
31
31
|
normalizePathPrefix,
|
|
32
32
|
uploadFormFields,
|
|
33
33
|
} from "../shared";
|
|
34
|
-
import type { R2Config, StorageProvider } from "../shared/types";
|
|
34
|
+
import type { R2Config, StorageProvider, UploadResult } from "../shared/types";
|
|
35
35
|
import {
|
|
36
36
|
corsResponse,
|
|
37
37
|
jsonError,
|
|
38
38
|
jsonSuccess,
|
|
39
|
-
parseJsonStringArray,
|
|
40
39
|
parseOptionalTimestamp,
|
|
41
40
|
sanitizeFilename,
|
|
42
41
|
statusCodeForDownloadError,
|
|
@@ -101,13 +100,66 @@ const requireR2Config = (input?: R2ConfigInput, context?: string): R2Config => {
|
|
|
101
100
|
);
|
|
102
101
|
};
|
|
103
102
|
|
|
103
|
+
const normalizeOptionalHeaderName = (value?: string) => {
|
|
104
|
+
const trimmed = value?.trim();
|
|
105
|
+
return trimmed ? trimmed : undefined;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const getOrigin = (request: Request) =>
|
|
109
|
+
request.headers.get("Origin") ?? undefined;
|
|
110
|
+
|
|
111
|
+
const withCors = (
|
|
112
|
+
response: Response,
|
|
113
|
+
origin?: string,
|
|
114
|
+
allowHeaders?: string[],
|
|
115
|
+
) => {
|
|
116
|
+
const headers = new Headers(response.headers);
|
|
117
|
+
const cors = corsHeaders(origin, allowHeaders);
|
|
118
|
+
for (const [key, value] of cors.entries()) {
|
|
119
|
+
headers.set(key, value);
|
|
120
|
+
}
|
|
121
|
+
return new Response(response.body, {
|
|
122
|
+
status: response.status,
|
|
123
|
+
statusText: response.statusText,
|
|
124
|
+
headers,
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const resolveUploadProvider = (
|
|
129
|
+
value: FormDataEntryValue | null,
|
|
130
|
+
fallback: StorageProvider,
|
|
131
|
+
): StorageProvider => {
|
|
132
|
+
const providerValue = typeof value === "string" ? value : "";
|
|
133
|
+
return isStorageProvider(providerValue) ? providerValue : fallback;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
137
|
+
if (typeof Buffer !== "undefined") {
|
|
138
|
+
return Buffer.from(bytes).toString("base64");
|
|
139
|
+
}
|
|
140
|
+
if (typeof btoa === "function") {
|
|
141
|
+
if (typeof TextDecoder === "function") {
|
|
142
|
+
try {
|
|
143
|
+
return btoa(new TextDecoder("latin1").decode(bytes));
|
|
144
|
+
} catch {
|
|
145
|
+
// Fallback below for environments without latin1 support.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let binary = "";
|
|
149
|
+
const chunkSize = 0x8000;
|
|
150
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
151
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
152
|
+
}
|
|
153
|
+
return btoa(binary);
|
|
154
|
+
}
|
|
155
|
+
throw new Error("Base64 encoding is not available in this environment.");
|
|
156
|
+
}
|
|
157
|
+
|
|
104
158
|
export interface RegisterRoutesOptions {
|
|
105
159
|
/** Prefix for HTTP routes, defaults to "/files". */
|
|
106
160
|
pathPrefix?: string;
|
|
107
|
-
/** Require accessKey
|
|
161
|
+
/** Require accessKey for downloads (via checkDownloadRequest hook). */
|
|
108
162
|
requireAccessKey?: boolean;
|
|
109
|
-
/** Query parameter name for accessKey. */
|
|
110
|
-
accessKeyQueryParam?: string;
|
|
111
163
|
/**
|
|
112
164
|
* Query parameter name for password. Note: query params can leak into logs or
|
|
113
165
|
* caches; prefer headers or POST flows when possible.
|
|
@@ -122,13 +174,45 @@ export interface RegisterRoutesOptions {
|
|
|
122
174
|
/** R2 credentials for server-side upload/download/cleanup. */
|
|
123
175
|
r2?: R2ConfigInput;
|
|
124
176
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
177
|
+
* Required hook for upload authentication when enableUploadRoute is true.
|
|
178
|
+
* Return { accessKeys } to proceed with the upload, or a Response to reject.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```ts
|
|
182
|
+
* checkUploadRequest: async (ctx) => {
|
|
183
|
+
* const userId = await getAuthUserId(ctx);
|
|
184
|
+
* if (!userId) {
|
|
185
|
+
* return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
186
|
+
* status: 401,
|
|
187
|
+
* headers: { "Content-Type": "application/json" },
|
|
188
|
+
* });
|
|
189
|
+
* }
|
|
190
|
+
* return { accessKeys: [userId] };
|
|
191
|
+
* }
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
checkUploadRequest?: (
|
|
195
|
+
ctx: RunHttpActionCtx,
|
|
196
|
+
args: UploadRequestArgs,
|
|
197
|
+
) => UploadRequestResult | Promise<UploadRequestResult>;
|
|
198
|
+
/**
|
|
199
|
+
* Optional hook called after a successful upload + finalizeUpload.
|
|
200
|
+
* Return a Response to override the default JSON response.
|
|
201
|
+
*/
|
|
202
|
+
onUploadComplete?: (
|
|
203
|
+
ctx: RunHttpActionCtx,
|
|
204
|
+
args: UploadCompleteArgs,
|
|
205
|
+
) => UploadCompleteResult | Promise<UploadCompleteResult>;
|
|
206
|
+
/**
|
|
207
|
+
* Optional hook for rate limiting, request validation, or authentication.
|
|
208
|
+
* Return a Response to short-circuit the request (e.g. 401, 429).
|
|
209
|
+
* Return { accessKey: string } to set the accessKey for non-public links.
|
|
210
|
+
* Return void to proceed without setting an accessKey.
|
|
127
211
|
*/
|
|
128
212
|
checkDownloadRequest?: (
|
|
129
|
-
ctx:
|
|
213
|
+
ctx: RunHttpActionCtx,
|
|
130
214
|
args: DownloadRequestArgs,
|
|
131
|
-
) => void | Response | Promise<void | Response>;
|
|
215
|
+
) => void | Response | { accessKey: string } | Promise<void | Response | { accessKey: string }>;
|
|
132
216
|
}
|
|
133
217
|
|
|
134
218
|
/**
|
|
@@ -165,65 +249,92 @@ export function registerRoutes(
|
|
|
165
249
|
const {
|
|
166
250
|
pathPrefix = DEFAULT_PATH_PREFIX,
|
|
167
251
|
requireAccessKey = false,
|
|
168
|
-
accessKeyQueryParam = "accessKey",
|
|
169
252
|
passwordQueryParam = "password",
|
|
170
253
|
passwordHeader = "x-download-password",
|
|
171
254
|
enableUploadRoute = false,
|
|
172
255
|
enableDownloadRoute = true,
|
|
173
256
|
defaultUploadProvider = "convex",
|
|
174
257
|
r2,
|
|
258
|
+
checkUploadRequest,
|
|
259
|
+
onUploadComplete,
|
|
175
260
|
checkDownloadRequest,
|
|
176
261
|
} = options;
|
|
177
262
|
|
|
178
263
|
const normalizedPrefix = normalizePathPrefix(pathPrefix);
|
|
264
|
+
const passwordHeaderName = normalizeOptionalHeaderName(passwordHeader);
|
|
265
|
+
const passwordQueryKey = normalizeOptionalHeaderName(passwordQueryParam);
|
|
266
|
+
const downloadCorsAllowHeaders = passwordHeaderName
|
|
267
|
+
? [passwordHeaderName]
|
|
268
|
+
: undefined;
|
|
179
269
|
const uploadPath = `${normalizedPrefix}/upload`;
|
|
180
270
|
const downloadPath = `${normalizedPrefix}/download`;
|
|
181
271
|
|
|
182
272
|
if (enableUploadRoute) {
|
|
273
|
+
if (!checkUploadRequest) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"checkUploadRequest is required when enableUploadRoute is true. " +
|
|
276
|
+
"This hook must authenticate the request and return { accessKeys }.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
183
280
|
http.route({
|
|
184
281
|
path: uploadPath,
|
|
185
282
|
method: "OPTIONS",
|
|
186
|
-
handler: httpActionGeneric(async () =>
|
|
283
|
+
handler: httpActionGeneric(async (_ctx, request) => {
|
|
284
|
+
const origin = getOrigin(request);
|
|
285
|
+
return corsResponse(origin);
|
|
286
|
+
}),
|
|
187
287
|
});
|
|
188
288
|
|
|
189
289
|
http.route({
|
|
190
290
|
path: uploadPath,
|
|
191
291
|
method: "POST",
|
|
192
292
|
handler: httpActionGeneric(async (ctx, request) => {
|
|
293
|
+
const origin = getOrigin(request);
|
|
193
294
|
const contentType = request.headers.get("Content-Type") ?? "";
|
|
194
295
|
if (!contentType.includes("multipart/form-data")) {
|
|
195
|
-
return jsonError("Content-Type must be multipart/form-data", 415);
|
|
296
|
+
return jsonError("Content-Type must be multipart/form-data", 415, origin);
|
|
196
297
|
}
|
|
197
298
|
|
|
198
299
|
const formData = await request.formData();
|
|
199
300
|
const file = formData.get(uploadFormFields.file);
|
|
200
301
|
if (!(file instanceof Blob)) {
|
|
201
|
-
return jsonError("Missing or invalid 'file' field", 400);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const accessKeysRaw = formData.get(uploadFormFields.accessKeys);
|
|
205
|
-
if (typeof accessKeysRaw !== "string") {
|
|
206
|
-
return jsonError("Missing 'accessKeys' field", 400);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const accessKeys = parseJsonStringArray(accessKeysRaw);
|
|
210
|
-
if (!accessKeys) {
|
|
211
|
-
return jsonError("'accessKeys' must be a JSON array of strings", 400);
|
|
302
|
+
return jsonError("Missing or invalid 'file' field", 400, origin);
|
|
212
303
|
}
|
|
213
304
|
|
|
214
305
|
const expiresAt = parseOptionalTimestamp(
|
|
215
306
|
formData.get(uploadFormFields.expiresAt),
|
|
216
307
|
);
|
|
217
308
|
if (expiresAt === "invalid") {
|
|
218
|
-
return jsonError("'expiresAt' must be a number or null", 400);
|
|
309
|
+
return jsonError("'expiresAt' must be a number or null", 400, origin);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const provider = resolveUploadProvider(
|
|
313
|
+
formData.get(uploadFormFields.provider),
|
|
314
|
+
defaultUploadProvider,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Call the auth hook to get access keys
|
|
318
|
+
const hookResult = await checkUploadRequest(ctx, {
|
|
319
|
+
file,
|
|
320
|
+
expiresAt: expiresAt ?? undefined,
|
|
321
|
+
provider,
|
|
322
|
+
request,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// If hook returns a Response, wrap it with CORS headers
|
|
326
|
+
if (hookResult instanceof Response) {
|
|
327
|
+
return withCors(hookResult, origin);
|
|
219
328
|
}
|
|
220
329
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
330
|
+
if (!hookResult || typeof hookResult !== "object") {
|
|
331
|
+
return jsonError("checkUploadRequest must return accessKeys", 500, origin);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const { accessKeys } = hookResult;
|
|
335
|
+
if (!accessKeys || accessKeys.length === 0) {
|
|
336
|
+
return jsonError("checkUploadRequest must return accessKeys", 500, origin);
|
|
337
|
+
}
|
|
227
338
|
|
|
228
339
|
let r2Config: R2Config | undefined = undefined;
|
|
229
340
|
if (provider === "r2") {
|
|
@@ -233,6 +344,7 @@ export function registerRoutes(
|
|
|
233
344
|
return jsonError(
|
|
234
345
|
error instanceof Error ? error.message : "R2 configuration missing.",
|
|
235
346
|
500,
|
|
347
|
+
origin,
|
|
236
348
|
);
|
|
237
349
|
}
|
|
238
350
|
}
|
|
@@ -250,7 +362,7 @@ export function registerRoutes(
|
|
|
250
362
|
});
|
|
251
363
|
|
|
252
364
|
if (!uploadResponse.ok) {
|
|
253
|
-
return jsonError("File upload failed", 502);
|
|
365
|
+
return jsonError("File upload failed", 502, origin);
|
|
254
366
|
}
|
|
255
367
|
|
|
256
368
|
let storageId = presetStorageId ?? null;
|
|
@@ -262,17 +374,46 @@ export function registerRoutes(
|
|
|
262
374
|
}
|
|
263
375
|
|
|
264
376
|
if (!storageId) {
|
|
265
|
-
return jsonError("Upload did not return storageId", 502);
|
|
377
|
+
return jsonError("Upload did not return storageId", 502, origin);
|
|
266
378
|
}
|
|
267
379
|
|
|
380
|
+
// Compute file metadata from the blob for HTTP action uploads.
|
|
381
|
+
// This is necessary because R2 uploads don't have metadata in Convex's
|
|
382
|
+
// system database, and even for Convex uploads we already have the blob.
|
|
383
|
+
const fileBuffer = await file.arrayBuffer();
|
|
384
|
+
const fileBytes = new Uint8Array(fileBuffer);
|
|
385
|
+
const digest = await crypto.subtle.digest("SHA-256", fileBytes);
|
|
386
|
+
const sha256 = bytesToBase64(new Uint8Array(digest));
|
|
387
|
+
const metadata = {
|
|
388
|
+
size: fileBytes.byteLength,
|
|
389
|
+
sha256,
|
|
390
|
+
contentType: file.type || null,
|
|
391
|
+
};
|
|
392
|
+
|
|
268
393
|
const result = await ctx.runMutation(component.upload.finalizeUpload, {
|
|
269
394
|
uploadToken,
|
|
270
395
|
storageId,
|
|
271
396
|
accessKeys,
|
|
272
397
|
expiresAt: expiresAt ?? undefined,
|
|
398
|
+
metadata,
|
|
273
399
|
});
|
|
274
400
|
|
|
275
|
-
|
|
401
|
+
if (onUploadComplete) {
|
|
402
|
+
const hookResult = await onUploadComplete(ctx, {
|
|
403
|
+
file,
|
|
404
|
+
provider,
|
|
405
|
+
accessKeys,
|
|
406
|
+
expiresAt: expiresAt ?? null,
|
|
407
|
+
request,
|
|
408
|
+
result,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
if (hookResult instanceof Response) {
|
|
412
|
+
return withCors(hookResult, origin);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return jsonSuccess(result, origin);
|
|
276
417
|
}),
|
|
277
418
|
});
|
|
278
419
|
}
|
|
@@ -281,29 +422,36 @@ export function registerRoutes(
|
|
|
281
422
|
http.route({
|
|
282
423
|
path: downloadPath,
|
|
283
424
|
method: "OPTIONS",
|
|
284
|
-
handler: httpActionGeneric(async () =>
|
|
425
|
+
handler: httpActionGeneric(async (_ctx, request) => {
|
|
426
|
+
const origin = getOrigin(request);
|
|
427
|
+
return corsResponse(origin, downloadCorsAllowHeaders);
|
|
428
|
+
}),
|
|
285
429
|
});
|
|
286
430
|
|
|
287
431
|
http.route({
|
|
288
432
|
path: downloadPath,
|
|
289
433
|
method: "GET",
|
|
290
434
|
handler: httpActionGeneric(async (ctx, request) => {
|
|
435
|
+
const origin = getOrigin(request);
|
|
291
436
|
const url = new URL(request.url);
|
|
292
437
|
const downloadToken = url.searchParams.get("token");
|
|
293
438
|
if (!downloadToken) {
|
|
294
|
-
return jsonError(
|
|
439
|
+
return jsonError(
|
|
440
|
+
"Missing 'token' query parameter",
|
|
441
|
+
400,
|
|
442
|
+
origin,
|
|
443
|
+
downloadCorsAllowHeaders,
|
|
444
|
+
);
|
|
295
445
|
}
|
|
296
446
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return jsonError("Missing required accessKey", 401);
|
|
300
|
-
}
|
|
447
|
+
// For downloads, accessKey should come from checkDownloadRequest hook
|
|
448
|
+
let accessKey: string | undefined;
|
|
301
449
|
|
|
302
|
-
const passwordFromHeader =
|
|
303
|
-
? request.headers.get(
|
|
450
|
+
const passwordFromHeader = passwordHeaderName
|
|
451
|
+
? request.headers.get(passwordHeaderName)
|
|
304
452
|
: null;
|
|
305
|
-
const passwordFromQuery =
|
|
306
|
-
? url.searchParams.get(
|
|
453
|
+
const passwordFromQuery = passwordQueryKey
|
|
454
|
+
? url.searchParams.get(passwordQueryKey)
|
|
307
455
|
: null;
|
|
308
456
|
const password = passwordFromHeader ?? passwordFromQuery ?? undefined;
|
|
309
457
|
|
|
@@ -315,10 +463,23 @@ export function registerRoutes(
|
|
|
315
463
|
request,
|
|
316
464
|
});
|
|
317
465
|
if (result instanceof Response) {
|
|
318
|
-
return result;
|
|
466
|
+
return withCors(result, origin, downloadCorsAllowHeaders);
|
|
467
|
+
}
|
|
468
|
+
// If hook returns { accessKey }, use it
|
|
469
|
+
if (result && typeof result === "object" && "accessKey" in result) {
|
|
470
|
+
accessKey = result.accessKey;
|
|
319
471
|
}
|
|
320
472
|
}
|
|
321
473
|
|
|
474
|
+
if (requireAccessKey && !accessKey) {
|
|
475
|
+
return jsonError(
|
|
476
|
+
"Missing required accessKey. Provide it via checkDownloadRequest hook.",
|
|
477
|
+
401,
|
|
478
|
+
origin,
|
|
479
|
+
downloadCorsAllowHeaders,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
322
483
|
const result = await ctx.runMutation(
|
|
323
484
|
component.download.consumeDownloadGrantForUrl,
|
|
324
485
|
{
|
|
@@ -333,16 +494,18 @@ export function registerRoutes(
|
|
|
333
494
|
return jsonError(
|
|
334
495
|
"Download unavailable",
|
|
335
496
|
statusCodeForDownloadError(result.status),
|
|
497
|
+
origin,
|
|
498
|
+
downloadCorsAllowHeaders,
|
|
336
499
|
);
|
|
337
500
|
}
|
|
338
501
|
|
|
339
502
|
const fileResponse = await fetch(result.downloadUrl);
|
|
340
503
|
if (!fileResponse.ok || !fileResponse.body) {
|
|
341
|
-
return jsonError("File not available", 404);
|
|
504
|
+
return jsonError("File not available", 404, origin, downloadCorsAllowHeaders);
|
|
342
505
|
}
|
|
343
506
|
|
|
344
507
|
const filename = sanitizeFilename(url.searchParams.get("filename"));
|
|
345
|
-
const headers = corsHeaders();
|
|
508
|
+
const headers = corsHeaders(origin, downloadCorsAllowHeaders);
|
|
346
509
|
headers.set("Cache-Control", "no-store");
|
|
347
510
|
headers.set("Content-Disposition", `attachment; filename="${filename}"`);
|
|
348
511
|
|
|
@@ -366,7 +529,6 @@ export interface BuildDownloadUrlOptions {
|
|
|
366
529
|
baseUrl: string;
|
|
367
530
|
downloadToken: string;
|
|
368
531
|
pathPrefix?: string;
|
|
369
|
-
accessKey?: string;
|
|
370
532
|
filename?: string;
|
|
371
533
|
}
|
|
372
534
|
|
|
@@ -378,13 +540,13 @@ export interface BuildDownloadUrlOptions {
|
|
|
378
540
|
*
|
|
379
541
|
* Note: Avoid placing passwords in query params; they can leak into logs or
|
|
380
542
|
* caches. Prefer headers or POST flows when possible.
|
|
543
|
+
* Access keys are not included in the URL; enforce them via checkDownloadRequest.
|
|
381
544
|
*
|
|
382
545
|
* @example
|
|
383
546
|
* ```ts
|
|
384
547
|
* const url = buildDownloadUrl({
|
|
385
548
|
* baseUrl: "https://your-app.convex.site",
|
|
386
549
|
* downloadToken,
|
|
387
|
-
* accessKey: "user_123",
|
|
388
550
|
* filename: "report.pdf",
|
|
389
551
|
* });
|
|
390
552
|
* ```
|
|
@@ -393,7 +555,6 @@ export function buildDownloadUrl({
|
|
|
393
555
|
baseUrl,
|
|
394
556
|
downloadToken,
|
|
395
557
|
pathPrefix = DEFAULT_PATH_PREFIX,
|
|
396
|
-
accessKey,
|
|
397
558
|
filename,
|
|
398
559
|
}: BuildDownloadUrlOptions): string {
|
|
399
560
|
const normalizedBase = normalizeBaseUrl(baseUrl);
|
|
@@ -403,7 +564,6 @@ export function buildDownloadUrl({
|
|
|
403
564
|
"download",
|
|
404
565
|
);
|
|
405
566
|
const params = new URLSearchParams({ token: downloadToken });
|
|
406
|
-
if (accessKey) params.set("accessKey", accessKey);
|
|
407
567
|
if (filename) params.set("filename", filename);
|
|
408
568
|
return `${endpoint}?${params.toString()}`;
|
|
409
569
|
}
|
|
@@ -456,6 +616,15 @@ type RunActionCtx = {
|
|
|
456
616
|
runAction: GenericActionCtx<GenericDataModel>["runAction"];
|
|
457
617
|
};
|
|
458
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Context type for HTTP action hooks. Includes auth for authentication
|
|
621
|
+
* and runMutation for calling component mutations.
|
|
622
|
+
*/
|
|
623
|
+
export type RunHttpActionCtx = {
|
|
624
|
+
auth: GenericActionCtx<GenericDataModel>["auth"];
|
|
625
|
+
runMutation: GenericActionCtx<GenericDataModel>["runMutation"];
|
|
626
|
+
};
|
|
627
|
+
|
|
459
628
|
type FinalizeUploadArgs = {
|
|
460
629
|
uploadToken: Id<"pendingUploads">;
|
|
461
630
|
storageId: string;
|
|
@@ -494,7 +663,29 @@ type DownloadConsumeArgs = {
|
|
|
494
663
|
r2Config?: R2Config;
|
|
495
664
|
};
|
|
496
665
|
|
|
497
|
-
type
|
|
666
|
+
export type UploadRequestArgs = {
|
|
667
|
+
file: Blob;
|
|
668
|
+
expiresAt?: number;
|
|
669
|
+
provider: StorageProvider;
|
|
670
|
+
request: Request;
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
export type UploadRequestResult =
|
|
674
|
+
| { accessKeys: string[] }
|
|
675
|
+
| Response;
|
|
676
|
+
|
|
677
|
+
export type UploadCompleteArgs = {
|
|
678
|
+
file: Blob;
|
|
679
|
+
provider: StorageProvider;
|
|
680
|
+
accessKeys: string[];
|
|
681
|
+
expiresAt: number | null;
|
|
682
|
+
request: Request;
|
|
683
|
+
result: UploadResult;
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
export type UploadCompleteResult = void | Response;
|
|
687
|
+
|
|
688
|
+
export type DownloadRequestArgs = {
|
|
498
689
|
downloadToken: string;
|
|
499
690
|
accessKey?: string;
|
|
500
691
|
password?: string;
|
|
@@ -131,12 +131,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
131
131
|
expiresAt?: null | number;
|
|
132
132
|
maxUses?: null | number;
|
|
133
133
|
password?: string;
|
|
134
|
+
shareableLink?: boolean;
|
|
134
135
|
storageId: string;
|
|
135
136
|
},
|
|
136
137
|
{
|
|
137
138
|
downloadToken: string;
|
|
138
139
|
expiresAt: null | number;
|
|
139
140
|
maxUses: null | number;
|
|
141
|
+
shareableLink: boolean;
|
|
140
142
|
storageId: string;
|
|
141
143
|
},
|
|
142
144
|
Name
|
|
@@ -47,12 +47,14 @@ export const createDownloadGrant = mutation({
|
|
|
47
47
|
maxUses: v.optional(v.union(v.null(), v.number())),
|
|
48
48
|
expiresAt: v.optional(v.union(v.null(), v.number())),
|
|
49
49
|
password: v.optional(v.string()),
|
|
50
|
+
shareableLink: v.optional(v.boolean()),
|
|
50
51
|
},
|
|
51
52
|
returns: v.object({
|
|
52
53
|
downloadToken: v.id("downloadGrants"),
|
|
53
54
|
storageId: v.string(),
|
|
54
55
|
expiresAt: v.union(v.null(), v.number()),
|
|
55
56
|
maxUses: v.union(v.null(), v.number()),
|
|
57
|
+
shareableLink: v.boolean(),
|
|
56
58
|
}),
|
|
57
59
|
handler: async (ctx, args) => {
|
|
58
60
|
const now = Date.now();
|
|
@@ -92,11 +94,13 @@ export const createDownloadGrant = mutation({
|
|
|
92
94
|
passwordAlgorithm: passwordRecord.algorithm,
|
|
93
95
|
}
|
|
94
96
|
: {};
|
|
97
|
+
const shareableLink = args.shareableLink ?? false;
|
|
95
98
|
const downloadToken = await ctx.db.insert("downloadGrants", {
|
|
96
99
|
storageId: args.storageId,
|
|
97
100
|
expiresAt: expiresAt ?? undefined,
|
|
98
101
|
maxUses: maxUses ?? null,
|
|
99
102
|
useCount: 0,
|
|
103
|
+
shareableLink,
|
|
100
104
|
...passwordFields,
|
|
101
105
|
});
|
|
102
106
|
|
|
@@ -105,6 +109,7 @@ export const createDownloadGrant = mutation({
|
|
|
105
109
|
storageId: args.storageId,
|
|
106
110
|
expiresAt,
|
|
107
111
|
maxUses: maxUses ?? null,
|
|
112
|
+
shareableLink,
|
|
108
113
|
};
|
|
109
114
|
},
|
|
110
115
|
});
|
|
@@ -212,32 +217,48 @@ async function consumeDownloadGrantCore(
|
|
|
212
217
|
const filePromise = findFileByStorageId(ctx, grant.storageId);
|
|
213
218
|
const accessKey = normalizeAccessKey(args.accessKey);
|
|
214
219
|
|
|
215
|
-
|
|
220
|
+
// Shareable links bypass access key validation
|
|
221
|
+
if (grant.shareableLink) {
|
|
216
222
|
const file = await filePromise;
|
|
217
223
|
if (!file) {
|
|
218
224
|
await ctx.db.delete(grant._id);
|
|
219
225
|
return { status: "file_missing" };
|
|
220
226
|
}
|
|
221
|
-
|
|
222
|
-
|
|
227
|
+
} else {
|
|
228
|
+
// Regular grants require a valid access key
|
|
229
|
+
if (!accessKey) {
|
|
230
|
+
const file = await filePromise;
|
|
231
|
+
if (!file) {
|
|
232
|
+
await ctx.db.delete(grant._id);
|
|
233
|
+
return { status: "file_missing" };
|
|
234
|
+
}
|
|
235
|
+
return { status: "access_denied" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const [file, hasAccess] = await Promise.all([
|
|
239
|
+
filePromise,
|
|
240
|
+
hasAccessKey(ctx, {
|
|
241
|
+
accessKey,
|
|
242
|
+
storageId: grant.storageId,
|
|
243
|
+
}),
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
if (!file) {
|
|
247
|
+
await ctx.db.delete(grant._id);
|
|
248
|
+
return { status: "file_missing" };
|
|
249
|
+
}
|
|
223
250
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
storageId: grant.storageId,
|
|
229
|
-
}),
|
|
230
|
-
]);
|
|
251
|
+
if (!hasAccess) {
|
|
252
|
+
return { status: "access_denied" };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
231
255
|
|
|
256
|
+
const file = await filePromise;
|
|
232
257
|
if (!file) {
|
|
233
258
|
await ctx.db.delete(grant._id);
|
|
234
259
|
return { status: "file_missing" };
|
|
235
260
|
}
|
|
236
261
|
|
|
237
|
-
if (!hasAccess) {
|
|
238
|
-
return { status: "access_denied" };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
262
|
if (grant.passwordHash) {
|
|
242
263
|
const password = args.password;
|
|
243
264
|
if (!password || password.trim() === "") {
|
package/src/component/schema.ts
CHANGED
|
@@ -25,6 +25,7 @@ export default defineSchema({
|
|
|
25
25
|
expiresAt: v.optional(v.number()),
|
|
26
26
|
maxUses: v.union(v.null(), v.number()),
|
|
27
27
|
useCount: v.number(),
|
|
28
|
+
shareableLink: v.optional(v.boolean()),
|
|
28
29
|
passwordHash: v.optional(v.string()),
|
|
29
30
|
passwordSalt: v.optional(v.string()),
|
|
30
31
|
passwordIterations: v.optional(v.number()),
|
package/src/react/index.ts
CHANGED
|
@@ -17,11 +17,11 @@ export type HttpUploadOptions = {
|
|
|
17
17
|
uploadUrl?: string;
|
|
18
18
|
baseUrl?: string;
|
|
19
19
|
pathPrefix?: string;
|
|
20
|
+
authToken?: string;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export type UploadFileArgs = {
|
|
23
24
|
file: File;
|
|
24
|
-
accessKeys: string[];
|
|
25
25
|
expiresAt?: number | null;
|
|
26
26
|
method?: UploadMethod;
|
|
27
27
|
http?: HttpUploadOptions;
|
|
@@ -69,7 +69,7 @@ function resolveUploadUrl(http?: HttpUploadOptions) {
|
|
|
69
69
|
* import { useUploadFile } from "@gilhrpenner/convex-files-control/react";
|
|
70
70
|
*
|
|
71
71
|
* const { uploadFile } = useUploadFile(api.filesControl, { method: "presigned" });
|
|
72
|
-
* await uploadFile({ file
|
|
72
|
+
* await uploadFile({ file });
|
|
73
73
|
* ```
|
|
74
74
|
*/
|
|
75
75
|
export function useUploadFile<Api extends UploadApi>(
|
|
@@ -81,7 +81,7 @@ export function useUploadFile<Api extends UploadApi>(
|
|
|
81
81
|
|
|
82
82
|
const uploadViaPresignedUrl = useCallback(
|
|
83
83
|
async (args: UploadFileArgs): Promise<UploadResult> => {
|
|
84
|
-
const { file,
|
|
84
|
+
const { file, expiresAt } = args;
|
|
85
85
|
const provider = args.provider ?? options.provider ?? "convex";
|
|
86
86
|
const { uploadUrl, uploadToken, storageId: presetStorageId } =
|
|
87
87
|
await generateUploadUrl({ provider });
|
|
@@ -112,7 +112,7 @@ export function useUploadFile<Api extends UploadApi>(
|
|
|
112
112
|
return await finalizeUpload({
|
|
113
113
|
uploadToken,
|
|
114
114
|
storageId,
|
|
115
|
-
|
|
115
|
+
fileName: file.name,
|
|
116
116
|
expiresAt,
|
|
117
117
|
});
|
|
118
118
|
},
|
|
@@ -121,9 +121,10 @@ export function useUploadFile<Api extends UploadApi>(
|
|
|
121
121
|
|
|
122
122
|
const uploadViaHttpAction = useCallback(
|
|
123
123
|
async (args: UploadFileArgs): Promise<UploadResult> => {
|
|
124
|
-
const { file,
|
|
124
|
+
const { file, expiresAt } = args;
|
|
125
125
|
const provider = args.provider ?? options.provider ?? "convex";
|
|
126
|
-
const
|
|
126
|
+
const httpConfig = args.http ?? options.http;
|
|
127
|
+
const uploadUrl = resolveUploadUrl(httpConfig);
|
|
127
128
|
if (!uploadUrl) {
|
|
128
129
|
throw new Error(
|
|
129
130
|
"Missing HTTP upload URL. Provide http.uploadUrl or http.baseUrl.",
|
|
@@ -132,10 +133,6 @@ export function useUploadFile<Api extends UploadApi>(
|
|
|
132
133
|
|
|
133
134
|
const formData = new FormData();
|
|
134
135
|
formData.append(uploadFormFields.file, file);
|
|
135
|
-
formData.append(
|
|
136
|
-
uploadFormFields.accessKeys,
|
|
137
|
-
JSON.stringify(accessKeys),
|
|
138
|
-
);
|
|
139
136
|
formData.append(uploadFormFields.provider, provider);
|
|
140
137
|
if (expiresAt !== undefined) {
|
|
141
138
|
formData.append(
|
|
@@ -147,6 +144,10 @@ export function useUploadFile<Api extends UploadApi>(
|
|
|
147
144
|
const uploadResponse = await fetch(uploadUrl, {
|
|
148
145
|
method: "POST",
|
|
149
146
|
body: formData,
|
|
147
|
+
credentials: "include",
|
|
148
|
+
headers: httpConfig?.authToken
|
|
149
|
+
? { Authorization: `Bearer ${httpConfig.authToken}` }
|
|
150
|
+
: undefined,
|
|
150
151
|
});
|
|
151
152
|
|
|
152
153
|
let payload: unknown = null;
|