diff --git a/src/app/api/circles/[id]/posts/upload/route.ts b/src/app/api/circles/[id]/posts/upload/route.ts new file mode 100644 index 0000000..45439f2 --- /dev/null +++ b/src/app/api/circles/[id]/posts/upload/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; + +const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? ""; +const MAX_BYTES = 20 * 1024 * 1024; // 20 MB + +const ALLOWED: Record = { + "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", + "image/webp": "webp", "image/heic": "heic", "image/heif": "heif", + "image/gif": "gif", +}; + +function makeR2Client() { + return new S3Client({ + region: "auto", + endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); +} + +// POST — server-side image upload for circle posts (avoids CORS on direct R2 PUT) +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId } = await params; + + // Must be a circle member + const rows = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + if (!rows[0]) return NextResponse.json({ error: "Not a circle member" }, { status: 403 }); + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); + + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 }); + } + + // Normalise content type — browsers sometimes send empty type for HEIC/camera photos + let contentType = file.type || ""; + if (!contentType || !ALLOWED[contentType]) { + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + const EXT_MAP: Record = { + jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", + webp: "image/webp", heic: "image/heic", heif: "image/heif", gif: "image/gif", + }; + contentType = EXT_MAP[ext] ?? contentType; + } + if (!ALLOWED[contentType]) { + return NextResponse.json({ error: `Unsupported file type: ${contentType || file.name}` }, { status: 400 }); + } + + const ext = ALLOWED[contentType]; + const tmpKey = `circle-posts/tmp/${familyId}/${Date.now()}.${ext}`; + + const buffer = Buffer.from(await file.arrayBuffer()); + await makeR2Client().send(new PutObjectCommand({ + Bucket: process.env.R2_BUCKET_NAME!, + Key: tmpKey, + Body: buffer, + ContentType: contentType, + })); + + return NextResponse.json({ tmpKey, publicUrl: `${R2_PUBLIC_URL}/${tmpKey}` }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/circle/[id]/page.tsx b/src/app/circle/[id]/page.tsx index 736dd55..3901208 100644 --- a/src/app/circle/[id]/page.tsx +++ b/src/app/circle/[id]/page.tsx @@ -228,30 +228,20 @@ function CreatePostModal({ let tmpKey: string | null = null; if (imageFile) { - // Get presigned URL — pass both type and name so server can normalise empty types - const presignRes = await fetch(`/api/circles/${circleId}/posts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "presign", contentType: imageFile.type, filename: imageFile.name }), - }); - const presignData = await presignRes.json(); - if (!presignRes.ok || !presignData.uploadUrl) { - setPostError(presignData.error ?? "Could not prepare image upload"); - setPosting(false); - return; - } - // Upload directly to R2 using the normalised content type from server - const uploadRes = await fetch(presignData.uploadUrl, { - method: "PUT", - body: imageFile, - headers: { "Content-Type": presignData.contentType ?? imageFile.type }, + // Upload via server (avoids CORS issues with direct R2 PUT) + const fd = new FormData(); + fd.append("file", imageFile); + const uploadRes = await fetch(`/api/circles/${circleId}/posts/upload`, { + method: "POST", + body: fd, }); + const uploadData = await uploadRes.json(); if (!uploadRes.ok) { - setPostError("Image upload failed — please try again"); + setPostError(uploadData.error ?? "Image upload failed"); setPosting(false); return; } - tmpKey = presignData.tmpKey; + tmpKey = uploadData.tmpKey; } const postRes = await fetch(`/api/circles/${circleId}/posts`, {