Skip to content

feat: pass file metadata to checkUpload and bind ContentLength in presigned URLs#35

Open
ginzlabs wants to merge 3 commits into
get-convex:mainfrom
ginzlabs:feat/file-metadata-in-checkupload
Open

feat: pass file metadata to checkUpload and bind ContentLength in presigned URLs#35
ginzlabs wants to merge 3 commits into
get-convex:mainfrom
ginzlabs:feat/file-metadata-in-checkupload

Conversation

@ginzlabs
Copy link
Copy Markdown

@ginzlabs ginzlabs commented Feb 12, 2026

Summary

  • Extend checkUpload callback with an optional fileInfo parameter ({ size?, type? }), enabling users to implement custom upload validation (file size limits, type restrictions, per-user quotas, etc.)
  • Bind ContentLength and ContentType in presigned PUT URLs via PutObjectCommand, so R2 cryptographically enforces exact file size at the S3 protocol level
  • All three framework hooks (React, Svelte, Vue) automatically pass file.size and file.type to the generateUploadUrl mutation

Motivation

Currently there is no way to enforce upload size limits through the R2 component. The checkUpload callback only receives (ctx, bucket) with no information about the file being uploaded.

This is a common need in practice — I've had to manually implement file size validation on top of the R2 component across multiple projects. Passing file metadata through the existing checkUpload callback is a natural extension that saves every user from reimplementing this themselves, while the ContentLength binding in the presigned URL provides S3-level cryptographic enforcement for free.

Changes

Client library (src/client/index.ts):

  • R2.generateUploadUrl() accepts optional { contentLength, contentType } — included in PutObjectCommand so the presigned URL signature covers the exact file size
  • checkUpload type extended with optional third parameter fileInfo?: { size?, type? }
  • generateUploadUrl mutation accepts optional fileSize/contentType args, forwarded to both the callback and the presigned URL

Hooks (src/react/, src/svelte/, src/vue/):

  • All useUploadFile hooks pass file.size and file.type automatically

Example app:

  • checkUpload demonstrates a 1MB limit using ConvexError
  • App.tsx adds client-side validation for instant feedback and error display

Backward compatibility

Fully backward compatible — all new parameters are optional. Existing checkUpload callbacks that only take (ctx, bucket) continue to work unchanged.

Test plan

  • Updated unit tests for React, Svelte, and Vue hooks to verify fileSize/contentType are forwarded
  • All 13 tests pass (npm run test)
  • TypeScript builds and typechecks cleanly (npm run build, npm run typecheck)
  • Manually verified R2 rejects uploads when Content-Length in the presigned URL doesn't match the actual file (tampered +1 byte upload returns 403)

Summary by CodeRabbit

  • New Features

    • Uploads now send file metadata (size and type) so the server can validate content.
    • Client-side file size validation with a 1 MB limit provides immediate feedback and prevents oversized uploads.
  • Bug Fixes

    • Clearer user-facing error messages and an error banner for upload failures; upload progress is reliably cleared after attempts.

…signed URLs

Extend the upload flow to forward file size and content type information
through the entire chain, enabling server-side validation and S3-level
enforcement of upload constraints.

Changes:
- R2.generateUploadUrl() accepts optional contentLength/contentType, bound
  into the PutObjectCommand so the presigned URL signature enforces exact
  file size
- checkUpload callback receives optional fileInfo as a third parameter,
  allowing users to implement custom validation (size limits, file type
  checks, per-user quotas, etc.)
- generateUploadUrl mutation accepts optional fileSize/contentType args
- React, Svelte, and Vue hooks automatically pass file.size and file.type
- Updated tests for all three frameworks
- Example app demonstrates 1MB file size limit with client-side fast check,
  server-side ConvexError validation, and user-friendly error display

Fully backward compatible - all new parameters are optional.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 12, 2026

Warning

Rate limit exceeded

@ginzlabs has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 55 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

This PR propagates client-side file metadata (size and MIME type) through the upload flow: useUploadFile now sends { fileSize, contentType } to the generateUploadUrl mutation, generateUploadUrl accepts and validates those args and forwards a fileInfo object to the checkUpload hook, and example/react code adds size checks and error handling.

Changes

Cohort / File(s) Summary
Client API Core
src/client/index.ts
Expanded generateUploadUrl to accept { contentLength?, contentType? }. Mutation handler now accepts (ctx, args) with { fileSize?, contentType? }, validates fileSize, passes { size, type } to checkUpload, and forwards content info to the underlying URL generation (ContentLength/ContentType).
Framework integrations
src/react/index.ts, src/svelte/index.ts, src/vue/index.ts
Calls to generateUploadUrl updated to pass { fileSize: file.size, contentType: file.type } (was no-arg / {}). Upload flow otherwise unchanged.
Framework tests
src/react/index.test.ts, src/svelte/index.test.ts, src/vue/index.test.ts
Test assertions updated to expect generateUploadUrl to be invoked with { fileSize, contentType } payload.
Example implementation
example/convex/example.ts, example/src/App.tsx
checkUpload callback signature updated to (ctx, bucket, fileInfo?). Added server-side size check (1 MB) throwing ConvexError. React example adds client-side size pre-validation, uploadError state, and try/catch/finally around upload flow.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client UI
    participant Lib as Frontend lib (useUploadFile)
    participant Server as Convex Mutation (generateUploadUrl)
    participant R2 as R2 Upload Service

    Client->>Lib: select file (file.size, file.type)
    Lib->>Server: generateUploadUrl({ fileSize, contentType })
    Server->>Server: validate args (fileSize non-negative)
    Server->>Server: call checkUpload(ctx, bucket, { size, type })
    Server-->>Lib: return upload URL (includes ContentLength/Type metadata)
    Lib->>R2: upload file to URL with Content-Length/Type
    R2-->>Lib: upload result (key)
    Lib->>Server: syncMetadata(key, ...)
    Server-->>Client: final success / error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰 I nibbled bytes and counted size with care,
I passed their whispers through server air,
A hop, a check, a gentle ConvexError squeak —
Now uploads know their bounds before they leap! 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: extending checkUpload to receive file metadata and adding ContentLength binding to presigned URLs, which are the primary objectives of this PR.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@example/src/App.tsx`:
- Line 59: The line creating const file from event.target.files![0] can throw if
the file picker was cancelled; update the change handler in App.tsx to guard
against null/empty file lists by checking event.target.files (or using optional
chaining and length check) and return early or handle the no-file case before
accessing [0]; ensure the rest of the handler (the logic that uses file) only
runs when a valid File object is present.
🧹 Nitpick comments (3)
src/client/index.ts (2)

396-416: Consider validating fileSize is a positive integer before passing it as ContentLength.

v.optional(v.number()) permits negative values, zero, fractional numbers, NaN, and Infinity. Passing any of these as ContentLength in the PutObjectCommand will either produce an invalid presigned URL or be rejected by S3/R2 at signing time with an opaque error. A simple guard in the handler (or a more restrictive validator) would surface a clearer error to the caller.

Proposed validation in the handler
       handler: async (ctx, args) => {
+        if (
+          args.fileSize !== undefined &&
+          (!Number.isInteger(args.fileSize) || args.fileSize < 0)
+        ) {
+          throw new Error("fileSize must be a non-negative integer");
+        }
         if (opts?.checkUpload) {

426-429: Note: syncMetadata calls checkUpload without fileInfo.

This is correct since syncMetadata doesn't receive file metadata, and the parameter is optional. However, this means an upload flow still calls checkUpload twice: once with fileInfo (in generateUploadUrl) and once without (in syncMetadata). If a checkUpload implementation requires fileInfo.size to be present (e.g., throws when missing), it will block syncMetadata. This is worth documenting or noting in the checkUpload JSDoc so consumers write defensive checks (as the example does with fileInfo?.size).

example/src/App.tsx (1)

53-53: Move MAX_FILE_SIZE outside the component.

It's a constant and doesn't depend on props or state. Declaring it at module scope avoids re-creation on every render and makes it easier to share/import if needed.

Comment thread example/src/App.tsx Outdated
- Guard against null/empty file list when file picker is cancelled
- Validate fileSize is a non-negative integer before presigning
- Move MAX_FILE_SIZE constant to module scope
- Document that checkUpload is called without fileInfo from syncMetadata

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/client/index.ts`:
- Around line 417-422: The call to opts?.checkUpload currently always passes an
object { size: args.fileSize, type: args.contentType } even when both fields are
undefined, causing truthy false-positives for callers that test if (fileInfo);
modify the invocation in the block that calls opts?.checkUpload so it constructs
a fileInfo variable (e.g. const fileInfo = (args.fileSize !== undefined ||
args.contentType !== undefined) ? { size: args.fileSize, type: args.contentType
} : undefined) and then call await opts.checkUpload(ctx, this.config.bucket,
fileInfo) so fileInfo is only passed when at least one field is defined
(referencing opts?.checkUpload, ctx, this.config.bucket, args.fileSize,
args.contentType).
🧹 Nitpick comments (2)
example/src/App.tsx (2)

81-86: error.data as string will render poorly if the server throws a non-string payload.

If ConvexError is ever thrown with a structured data value (e.g., an object), this cast will display [object Object] in the error banner. A small guard would make this more resilient:

Suggested improvement
       const message =
         error instanceof ConvexError
-          ? (error.data as string)
+          ? String(error.data)
           : "Failed to upload file";

134-140: File input value is never reset — re-selecting the same file won't trigger onChange.

After an upload (successful or failed), the <input> retains its last value. If the user picks the identical file again, most browsers won't fire onChange. Resetting the input at the end of handleUpload avoids this:

One way to fix
   } finally {
     setUploadProgress(null);
+    event.target.value = "";
   }

(Move event into the closure or use a ref to the input element.)

Comment thread src/client/index.ts
- Only pass fileInfo to checkUpload when at least one field is defined
- Use String(error.data) instead of type cast for resilient error display
- Reset file input after upload attempt to allow re-selecting same file

Co-authored-by: Cursor <cursoragent@cursor.com>
@erquhart
Copy link
Copy Markdown
Member

Late review here - thanks for this!

Would need to think a bit more on how to approach. Two issues:

  • generateUploadUrl mutation now requires an empty object, which breaks all callers (need to at least pass an empty object)
  • server side file size enforcement is client driven - the generateUploadUrl mutation can be called directly without the new properties to bypass enforcement

Part of the problem is how checkUpload() is reused between syncMetadata and generateUploadUrl, we may need to provide separate checks for those, or differentiate between an auth check and input/file validation check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants