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:
parent
66a765e75f
commit
27125047bb
2 changed files with 92 additions and 19 deletions
83
src/app/api/circles/[id]/posts/upload/route.ts
Normal file
83
src/app/api/circles/[id]/posts/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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`, {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue