@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.
Files changed (38) hide show
  1. package/README.md +243 -207
  2. package/dist/client/http.d.ts +4 -4
  3. package/dist/client/http.d.ts.map +1 -1
  4. package/dist/client/http.js +39 -10
  5. package/dist/client/http.js.map +1 -1
  6. package/dist/client/index.d.ts +66 -11
  7. package/dist/client/index.d.ts.map +1 -1
  8. package/dist/client/index.js +136 -41
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/component/_generated/component.d.ts +2 -0
  11. package/dist/component/_generated/component.d.ts.map +1 -1
  12. package/dist/component/download.d.ts +2 -0
  13. package/dist/component/download.d.ts.map +1 -1
  14. package/dist/component/download.js +33 -12
  15. package/dist/component/download.js.map +1 -1
  16. package/dist/component/schema.d.ts +3 -1
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/schema.js +1 -0
  19. package/dist/component/schema.js.map +1 -1
  20. package/dist/react/index.d.ts +2 -2
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +10 -6
  23. package/dist/react/index.js.map +1 -1
  24. package/package.json +4 -3
  25. package/src/__tests__/client-extra.test.ts +157 -4
  26. package/src/__tests__/client.test.ts +572 -46
  27. package/src/__tests__/download-core.test.ts +70 -0
  28. package/src/__tests__/entrypoints.test.ts +13 -0
  29. package/src/__tests__/http.test.ts +34 -0
  30. package/src/__tests__/react.test.ts +10 -16
  31. package/src/__tests__/shared.test.ts +0 -5
  32. package/src/__tests__/transfer.test.ts +103 -0
  33. package/src/client/http.ts +51 -10
  34. package/src/client/index.ts +242 -51
  35. package/src/component/_generated/component.ts +2 -0
  36. package/src/component/download.ts +35 -14
  37. package/src/component/schema.ts +1 -0
  38. package/src/react/index.ts +11 -10
@@ -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 query param for downloads. */
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
- * Optional hook for rate limiting or request validation. Return a Response to
126
- * short-circuit the request (e.g. 429).
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: RunMutationCtx,
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 () => corsResponse()),
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
- const providerRaw = formData.get(uploadFormFields.provider);
222
- const providerValue =
223
- typeof providerRaw === "string" ? providerRaw : "";
224
- const provider = isStorageProvider(providerValue)
225
- ? providerValue
226
- : defaultUploadProvider;
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
- return jsonSuccess(result);
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 () => corsResponse()),
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("Missing 'token' query parameter", 400);
439
+ return jsonError(
440
+ "Missing 'token' query parameter",
441
+ 400,
442
+ origin,
443
+ downloadCorsAllowHeaders,
444
+ );
295
445
  }
296
446
 
297
- const accessKey = url.searchParams.get(accessKeyQueryParam) ?? undefined;
298
- if (requireAccessKey && !accessKey) {
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 = passwordHeader
303
- ? request.headers.get(passwordHeader)
450
+ const passwordFromHeader = passwordHeaderName
451
+ ? request.headers.get(passwordHeaderName)
304
452
  : null;
305
- const passwordFromQuery = passwordQueryParam
306
- ? url.searchParams.get(passwordQueryParam)
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 DownloadRequestArgs = {
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
- if (!accessKey) {
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
- return { status: "access_denied" };
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
- const [file, hasAccess] = await Promise.all([
225
- filePromise,
226
- hasAccessKey(ctx, {
227
- accessKey,
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() === "") {
@@ -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()),
@@ -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, accessKeys: ["user_123"] });
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, accessKeys, expiresAt } = args;
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
- accessKeys,
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, accessKeys, expiresAt } = args;
124
+ const { file, expiresAt } = args;
125
125
  const provider = args.provider ?? options.provider ?? "convex";
126
- const uploadUrl = resolveUploadUrl(args.http ?? options.http);
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;