Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 64 additions & 8 deletions apps/mobile/app/share-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ export default function ShareHandler() {
mutationFn: ({
url,
metadata,
imageFile,
}: {
url: string;
metadata: Record<string, any>;
}) => apiClient.createBookmark({ url, metadata }),
imageFile?: { uri: string; name: string; type: string };
}) => apiClient.createBookmark({ url, metadata, imageFile }),
onSuccess: () => {
// Show success for 2 seconds, then close
setTimeout(() => {
Expand Down Expand Up @@ -94,7 +96,33 @@ export default function ShareHandler() {
} else if (shareIntent.files && shareIntent.files.length > 0) {
// File content (images, etc.)
const file = shareIntent.files[0];
if (file) {
if (file && file.mimeType?.startsWith("image/")) {
// For images, we'll upload the file - URL will be replaced by S3 URL
url = `placeholder-image-upload-${Date.now()}`;
metadata = {
type: "image",
fileName: file.fileName || "Shared image",
mimeType: file.mimeType,
fileSize: file.size,
};

// Create image file object for upload
const imageFile = {
uri: file.path,
name:
file.fileName ||
`image-${Date.now()}.${file.mimeType.split("/")[1]}`,
type: file.mimeType,
};

if (!url) {
return;
}

createBookmarkMutation.mutate({ url, metadata, imageFile });
return;
} else if (file) {
// For non-image files, keep existing behavior
url = file.path;
metadata = {
type: "file",
Expand All @@ -121,7 +149,13 @@ export default function ShareHandler() {
if (error) {
return (
<View flex={1} backgroundColor="$background">
<YStack flex={1} justifyContent="center" alignItems="center" padding="$4" gap="$4">
<YStack
flex={1}
justifyContent="center"
alignItems="center"
padding="$4"
gap="$4"
>
<Circle
size={80}
backgroundColor="$destructive"
Expand Down Expand Up @@ -150,7 +184,9 @@ export default function ShareHandler() {
fontWeight="600"
>
<X size={20} color="$destructiveForeground" />
<Text color="$destructiveForeground" fontWeight="600">Close</Text>
<Text color="$destructiveForeground" fontWeight="600">
Close
</Text>
</Button>
</YStack>
</View>
Expand All @@ -161,7 +197,13 @@ export default function ShareHandler() {
if (createBookmarkMutation.isError) {
return (
<View flex={1} backgroundColor="$background">
<YStack flex={1} justifyContent="center" alignItems="center" padding="$4" gap="$4">
<YStack
flex={1}
justifyContent="center"
alignItems="center"
padding="$4"
gap="$4"
>
<Circle
size={80}
backgroundColor="$primary"
Expand All @@ -188,7 +230,9 @@ export default function ShareHandler() {
fontWeight="600"
>
<X size={20} color="$primaryForeground" />
<Text color="$primaryForeground" fontWeight="600">Close</Text>
<Text color="$primaryForeground" fontWeight="600">
Close
</Text>
</Button>
</YStack>
</View>
Expand All @@ -199,7 +243,13 @@ export default function ShareHandler() {
if (createBookmarkMutation.isSuccess) {
return (
<View flex={1} backgroundColor="$background">
<YStack flex={1} justifyContent="center" alignItems="center" padding="$4" gap="$4">
<YStack
flex={1}
justifyContent="center"
alignItems="center"
padding="$4"
gap="$4"
>
<Circle
size={80}
backgroundColor="$primary"
Expand Down Expand Up @@ -233,7 +283,13 @@ export default function ShareHandler() {
if (createBookmarkMutation.isPending) {
return (
<View flex={1} backgroundColor="$background">
<YStack flex={1} justifyContent="center" alignItems="center" padding="$4" gap="$4">
<YStack
flex={1}
justifyContent="center"
alignItems="center"
padding="$4"
gap="$4"
>
<Circle
size={80}
backgroundColor="$primary"
Expand Down
40 changes: 38 additions & 2 deletions apps/mobile/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,34 @@ class ApiClient {
async createBookmark(data: {
url: string;
metadata?: Record<string, any>;
imageFile?: { uri: string; name: string; type: string };
}): Promise<Bookmark> {
const headers = await this.getAuthHeaders();
let headers: HeadersInit;
let body: string | FormData;

if (data.imageFile) {
headers = await this.getAuthHeadersWithoutContentType();
const formData = new FormData();
formData.append("url", data.url);
if (data.metadata) {
formData.append("metadata", JSON.stringify(data.metadata));
}
formData.append("image", {
uri: data.imageFile.uri,
name: data.imageFile.name,
type: data.imageFile.type,
} as any);
body = formData;
} else {
headers = await this.getAuthHeaders();
body = JSON.stringify(data);
}

const response = await fetch(`${API_BASE_URL}/api/bookmarks`, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify(data),
body,
});

if (!response.ok) {
Expand All @@ -117,6 +137,22 @@ class ApiClient {
return result.bookmark;
}

private async getAuthHeadersWithoutContentType(): Promise<HeadersInit> {
try {
const cookies = authClient.getCookie();
const headers: HeadersInit = {};

if (cookies) {
headers.Cookie = cookies;
}

return headers;
} catch (error) {
console.error("Error getting auth headers", error);
return {};
}
}

async updateBookmark(
id: string,
data: {
Expand Down
118 changes: 96 additions & 22 deletions apps/web/app/api/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { uploadFileToS3 } from "@/lib/aws-s3/aws-s3-upload-files";
import { BookmarkValidationError } from "@/lib/database/bookmark-validation";
import { createBookmark } from "@/lib/database/create-bookmark";
import { userRoute } from "@/lib/safe-route";
Expand All @@ -6,31 +7,104 @@ import { BookmarkType } from "@workspace/database";
import { NextResponse } from "next/server";
import { z } from "zod";

export const POST = userRoute
.body(
z.object({
url: z.string().url(),
transcript: z.string().optional(),
metadata: z.any().optional(),
}),
)
.handler(async (req, { body, ctx }) => {
try {
const bookmark = await createBookmark({
url: body.url,
userId: ctx.user.id,
transcript: body.transcript,
metadata: body.metadata,
});
return { status: "ok", bookmark };
} catch (error: unknown) {
if (error instanceof BookmarkValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/gif",
];

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;
Comment on lines +19 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

transcript = body.transcript;
metadata = body.metadata;

throw error;
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
}
});

const bookmark = await createBookmark({
url,
userId: ctx.user.id,
transcript,
metadata: metadata as Record<string, any> | undefined,
});

return { status: "ok", bookmark };
} catch (error: unknown) {
if (error instanceof BookmarkValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}

throw error;
}
});

export const GET = userRoute
.query(
Expand Down
Loading
Loading