fix: route circle post image upload through server to avoid R2 CORS

Direct browser PUT to R2's S3 endpoint is blocked by CORS. Replace the
presigned-URL client-side upload with a server-side upload endpoint:
client sends FormData → server uploads to R2 with PutObjectCommand →
returns tmpKey. No browser-to-R2 connection needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 07:35:59 +05:30
parent 66a765e75f
commit 27125047bb
2 changed files with 92 additions and 19 deletions

View file

@ -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<string, string> = {
"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<string, string> = {
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 });
}
}

View file

@ -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`, {
// 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",
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 },
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`, {