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:
parent
c24392f0a1
commit
66a765e75f
2 changed files with 67 additions and 20 deletions
|
|
@ -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) ──────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue