fix: image upload in circle posts - handle empty content type + show errors

- Server: normalise empty/missing MIME type by sniffing file extension so
  iOS HEIC/HEIF and camera photos (which send empty type) are accepted
- Server: add image/heif and image/gif to allowed types
- Server: return normalised contentType in presign response
- Client: check presignRes.ok before uploading; use server contentType
  for the PUT to R2 so the header matches what was signed
- Client: show error message in modal instead of silent catch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 02:18:14 +05:30
parent c24392f0a1
commit 66a765e75f
2 changed files with 67 additions and 20 deletions

View file

@ -110,20 +110,37 @@ export async function POST(
// ── Sub-action: get presigned upload URL for a circle post image ──────────
if (body.action === "presign") {
const { contentType, filename } = body;
const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
if (!contentType || !ALLOWED.includes(contentType)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
try {
const { filename } = body;
let { contentType } = body as { contentType?: string };
// Normalise empty/missing content type by sniffing the extension
if (!contentType) {
const ext = (filename ?? "").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] ?? "";
}
const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/webp",
"image/heic", "image/heif", "image/gif"];
if (!ALLOWED.includes(contentType)) {
return NextResponse.json({ error: `Unsupported file type: ${contentType || "unknown"}` }, { status: 400 });
}
const ext = filename?.split(".").pop()?.toLowerCase() ?? "jpg";
const tmpKey = `circle-posts/tmp/${familyId}/${Date.now()}.${ext}`;
const uploadUrl = await getSignedUrl(
makeR2Client(),
new PutObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: tmpKey, ContentType: contentType }),
{ expiresIn: 3600 }
);
return NextResponse.json({ uploadUrl, tmpKey, publicUrl: `${R2_PUBLIC_URL}/${tmpKey}`, contentType });
} catch (err: unknown) {
return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 });
}
const ext = filename?.split(".").pop()?.toLowerCase() ?? "jpg";
// Temp key — will be moved to circle-posts/{post_id}/ after post is created
const tmpKey = `circle-posts/tmp/${familyId}/${Date.now()}.${ext}`;
const uploadUrl = await getSignedUrl(
makeR2Client(),
new PutObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: tmpKey, ContentType: contentType }),
{ expiresIn: 3600 }
);
return NextResponse.json({ uploadUrl, tmpKey, publicUrl: `${R2_PUBLIC_URL}/${tmpKey}` });
}
// ── Sub-action: share from a private memory (copy-on-share) ──────────────

View file

@ -211,41 +211,66 @@ function CreatePostModal({
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const [step, setStep] = useState<"compose" | "confirm">("compose");
const [postError, setPostError] = useState<string | null>(null);
const pickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setImageFile(f);
setImagePreview(URL.createObjectURL(f));
setPostError(null);
};
const submit = async () => {
setPosting(true);
setPostError(null);
try {
let tmpKey: string | null = null;
if (imageFile) {
// Get presigned URL
// 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 { uploadUrl, tmpKey: key } = await presignRes.json();
// Upload to R2
await fetch(uploadUrl, { method: "PUT", body: imageFile, headers: { "Content-Type": imageFile.type } });
tmpKey = key;
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 },
});
if (!uploadRes.ok) {
setPostError("Image upload failed — please try again");
setPosting(false);
return;
}
tmpKey = presignData.tmpKey;
}
await fetch(`/api/circles/${circleId}/posts`, {
const postRes = await fetch(`/api/circles/${circleId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postBody: body, tmpKey }),
});
const postData = await postRes.json();
if (!postRes.ok) {
setPostError(postData.error ?? "Failed to create post");
setPosting(false);
return;
}
onPosted();
onClose();
} catch { /* silent */ }
} catch (err) {
setPostError(err instanceof Error ? err.message : "Something went wrong");
}
setPosting(false);
};
@ -302,6 +327,11 @@ function CreatePostModal({
</p>
</div>
{body && <p className="text-sm bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2 italic text-gray-600 dark:text-gray-300">"{body}"</p>}
{postError && (
<p className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-xl px-3 py-2 mb-2">
{postError}
</p>
)}
<div className="flex gap-3 mt-auto">
<button onClick={() => setStep("compose")} className="flex-1 py-3 border border-gray-200 dark:border-gray-600 rounded-xl text-sm"> Edit</button>
<button