fix: iOS image share intent uploads actual images instead of file paths#113
fix: iOS image share intent uploads actual images instead of file paths#113
Conversation
… paths - Updated mobile API client to support FormData uploads alongside JSON - Modified share handler to detect images and read file data from local path - Enhanced /api/v1/bookmarks endpoint to accept image files with validation - Added S3 upload integration following existing avatar upload patterns - Images now trigger proper Inngest processing pipeline for AI analysis Closes #111
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Use /api/bookmarks instead of /api/v1/bookmarks for authenticated users - Enhanced main bookmarks API to support both JSON and FormData requests - Fixed TypeScript typing for metadata parameter - Mobile app now sends images via FormData to correct endpoint - Maintains backward compatibility for existing bookmark creation Addresses feedback on PR #113
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting
| export const POST = userRoute.body(z.any()).handler(async (req, { ctx }) => { | ||
| try { | ||
| const contentType = req.headers.get("content-type") || ""; | ||
| let url: string; | ||
| let transcript: string | undefined; | ||
| let metadata: unknown; | ||
|
|
||
| if (contentType.includes("multipart/form-data")) { | ||
| const formData = await req.formData(); | ||
| url = formData.get("url") as string; | ||
| transcript = (formData.get("transcript") as string) || undefined; | ||
| const metadataString = formData.get("metadata") as string; | ||
| const imageFile = formData.get("image") as File | null; | ||
|
|
||
| if (!url) { | ||
| return NextResponse.json({ error: "URL is required" }, { status: 400 }); | ||
| } | ||
|
|
||
| if (metadataString) { | ||
| try { | ||
| metadata = JSON.parse(metadataString); | ||
| } catch { | ||
| metadata = {}; | ||
| } | ||
| } | ||
|
|
||
| if (imageFile) { | ||
| if (imageFile.size > MAX_FILE_SIZE) { | ||
| return NextResponse.json( | ||
| { error: "File size must be less than 2MB" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| if (!ALLOWED_IMAGE_TYPES.includes(imageFile.type)) { | ||
| return NextResponse.json( | ||
| { error: "Only image files (JPEG, PNG, WebP, GIF) are allowed" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| const s3Url = await uploadFileToS3({ | ||
| file: imageFile, | ||
| prefix: `users/${ctx.user.id}/bookmarks`, | ||
| fileName: `${Date.now()}-${imageFile.name}`, | ||
| contentType: imageFile.type, | ||
| }); | ||
|
|
||
| if (!s3Url) { | ||
| return NextResponse.json( | ||
| { error: "Failed to upload image" }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
|
|
||
| url = s3Url; | ||
| metadata = { | ||
| ...(metadata as Record<string, unknown>), | ||
| originalFileName: imageFile.name, | ||
| uploadedFromMobile: true, | ||
| }; | ||
| } | ||
| } else { | ||
| const body = await req.json(); | ||
| url = body.url; |
There was a problem hiding this comment.
Avoid double-reading request body in bookmark POST
The route is still wrapped with userRoute.body(z.any()), which causes next-zod-route to consume the request body before the handler executes. The new implementation then calls req.formData()/req.json() again, which will throw TypeError: body stream already read and prevent both JSON and multipart submissions from ever reaching createBookmark. Drop the .body() middleware and parse the request only once (or reuse the parsed body) so image uploads can succeed.
Useful? React with 👍 / 👎.
| export const POST = apiRoute.body(z.any()).handler(async (req, { ctx }) => { | ||
| try { | ||
| const formData = await req.formData(); | ||
| const url = formData.get("url") as string; | ||
| const metadataString = formData.get("metadata") as string; | ||
| const imageFile = formData.get("image") as File | null; | ||
|
|
||
| if (!url) { | ||
| return NextResponse.json( | ||
| { error: "URL is required", success: false }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| let metadata: Record<string, unknown> = {}; | ||
| if (metadataString) { | ||
| try { | ||
| metadata = JSON.parse(metadataString); | ||
| } catch { | ||
| metadata = {}; | ||
| } | ||
| } | ||
|
|
||
| let actualUrl = url; | ||
|
|
||
| return { | ||
| success: true, | ||
| bookmark: { | ||
| id: bookmark.id, | ||
| url: bookmark.url, | ||
| title: bookmark.title, | ||
| summary: bookmark.summary, | ||
| type: bookmark.type, | ||
| status: bookmark.status, | ||
| starred: bookmark.starred, | ||
| read: bookmark.read, | ||
| createdAt: bookmark.createdAt, | ||
| updatedAt: bookmark.updatedAt, | ||
| }, | ||
| }; | ||
| } catch (error: unknown) { | ||
| if (error instanceof BookmarkValidationError) { | ||
| if (imageFile) { | ||
| if (imageFile.size > MAX_FILE_SIZE) { | ||
| return NextResponse.json( | ||
| { error: error.message, success: false }, | ||
| { error: "File size must be less than 2MB", success: false }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| throw error; | ||
| if (!ALLOWED_IMAGE_TYPES.includes(imageFile.type)) { | ||
| return NextResponse.json( | ||
| { | ||
| error: "Only image files (JPEG, PNG, WebP, GIF) are allowed", | ||
| success: false, | ||
| }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| const s3Url = await uploadFileToS3({ | ||
| file: imageFile, | ||
| prefix: `users/${ctx.user.id}/bookmarks`, | ||
| fileName: `${Date.now()}-${imageFile.name}`, | ||
| contentType: imageFile.type, | ||
| }); | ||
|
|
||
| if (!s3Url) { | ||
| return NextResponse.json( | ||
| { error: "Failed to upload image", success: false }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
|
|
||
| actualUrl = s3Url; | ||
| metadata.originalFileName = imageFile.name; | ||
| metadata.uploadedFromMobile = true; | ||
| } | ||
| }); | ||
|
|
||
| const bookmark = await createBookmark({ | ||
| url: actualUrl, | ||
| userId: ctx.user.id, | ||
| metadata, | ||
| }); |
There was a problem hiding this comment.
Keep JSON and transcript support in v1 bookmark POST
The v1 endpoint now unconditionally calls req.formData() and never forwards a transcript to createBookmark. Existing clients that still POST JSON (as the previous version required) will fail when formData() is invoked on an application/json request, and any transcripts sent by those clients will be discarded. To maintain backward compatibility, detect content-type and handle JSON bodies while preserving the transcript field.
Useful? React with 👍 / 👎.
Summary
Fixes iOS share intent to upload actual image blobs instead of treating local file paths as URLs.
Changes Made
/api/v1/bookmarksto accept image files with proper validationTechnical Details
Test Plan
Before/After
Before: iOS shared images failed - local file paths sent as URLs
After: iOS shared images upload properly and get AI processing
Closes #111