fix(profile): fix parent avatar upload with 3-step proxy pattern + remove photo

- Switch from broken FormData POST to 3-step flow: POST init → PUT /api/upload proxy → PATCH save
- Add remove photo option that clears DB and deletes R2 object (DELETE /api/auth/avatar)
- Add deleteOldAvatar() helper that cleans up old R2 object on every upload
- No orphaned objects in R2, no wasted storage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 13:53:49 +05:30
parent 5235e26cad
commit 7816247073
2 changed files with 135 additions and 55 deletions

View file

@ -1,78 +1,114 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { S3Client, DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { sql } from "@/db";
const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
export async function POST(req: NextRequest) {
// Auth
async function getAuthedUserId(): Promise<string | null> {
const cookieStore = await cookies();
const token = cookieStore.get("tia_session")?.value;
if (!token) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const sessions = await sql`
if (!token) return null;
const rows = await sql`
SELECT user_id FROM sessions
WHERE session_token = ${token} AND expires > NOW()
LIMIT 1
`;
const userId = sessions[0]?.user_id;
if (!userId) return NextResponse.json({ error: "Invalid session" }, { status: 401 });
return rows[0]?.user_id ?? null;
}
// Parse file from multipart
let formData: FormData;
function makeR2Client() {
const accountId = process.env.R2_ACCOUNT_ID!;
const accessKeyId = process.env.R2_ACCESS_KEY_ID!;
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY!;
return new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
}
async function deleteOldAvatar(oldUrl: string | null) {
if (!oldUrl) return;
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
if (!oldUrl.startsWith(baseUrl + "/")) return;
const key = oldUrl.slice(baseUrl.length + 1);
if (!key.startsWith("avatars/")) return; // only delete our own uploads
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
const client = makeR2Client();
await client.send(new DeleteObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: key,
}));
} catch (e) {
console.error("Failed to delete old avatar from R2:", e);
// non-fatal
}
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!ALLOWED.includes(file.type)) {
// POST — step 1: generate R2 key + presigned URL, return key + publicUrl to client
export async function POST(req: NextRequest) {
const userId = await getAuthedUserId();
if (!userId) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const { contentType, filename } = await req.json();
if (!contentType || !ALLOWED.includes(contentType)) {
return NextResponse.json({ error: "Unsupported file type. Use JPEG, PNG, or WebP." }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "File too large (max 5 MB)" }, { status: 400 });
}
// R2 config
const accountId = process.env.R2_ACCOUNT_ID;
const accessKey = process.env.R2_ACCESS_KEY_ID;
const secretKey = process.env.R2_SECRET_ACCESS_KEY;
const bucket = process.env.R2_BUCKET_NAME;
const publicUrl = process.env.R2_PUBLIC_URL;
const accountId = process.env.R2_ACCOUNT_ID;
const bucket = process.env.R2_BUCKET_NAME;
const publicUrl = process.env.R2_PUBLIC_URL || `https://pub-${accountId}.r2.dev`;
if (!accountId || !accessKey || !secretKey || !bucket) {
if (!accountId || !process.env.R2_ACCESS_KEY_ID || !process.env.R2_SECRET_ACCESS_KEY || !bucket) {
return NextResponse.json({ error: "Storage not configured" }, { status: 500 });
}
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
const key = `avatars/${userId}/${Date.now()}.${ext}`;
const baseUrl = publicUrl || `https://pub-${accountId}.r2.dev`;
const ext = (filename?.split(".").pop() || "jpg").toLowerCase();
const key = `avatars/${userId}/${Date.now()}.${ext}`;
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
});
const client = makeR2Client();
const uploadUrl = await getSignedUrl(
client,
new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }),
{ expiresIn: 3600 }
);
const bytes = await file.arrayBuffer();
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: Buffer.from(bytes),
ContentType: file.type,
}));
return NextResponse.json({ key, uploadUrl, publicUrl: `${publicUrl}/${key}` });
}
const avatarUrl = `${baseUrl}/${key}`;
// PATCH — step 3: save new avatarUrl to DB, delete old R2 object
export async function PATCH(req: NextRequest) {
const userId = await getAuthedUserId();
if (!userId) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
// Save to users table
await sql`
UPDATE users SET avatar_url = ${avatarUrl}, updated_at = NOW()
WHERE id = ${userId}
`;
const { avatarUrl } = await req.json();
if (!avatarUrl) return NextResponse.json({ error: "avatarUrl required" }, { status: 400 });
// Fetch old URL before overwriting
const existing = await sql`SELECT avatar_url FROM users WHERE id = ${userId} LIMIT 1`;
const oldUrl: string | null = existing[0]?.avatar_url ?? null;
await sql`UPDATE users SET avatar_url = ${avatarUrl}, updated_at = NOW() WHERE id = ${userId}`;
// Clean up old R2 object
if (oldUrl && oldUrl !== avatarUrl) await deleteOldAvatar(oldUrl);
return NextResponse.json({ success: true, avatarUrl });
}
// DELETE — remove avatar: clear DB + delete R2 object
export async function DELETE(_req: NextRequest) {
const userId = await getAuthedUserId();
if (!userId) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const existing = await sql`SELECT avatar_url FROM users WHERE id = ${userId} LIMIT 1`;
const oldUrl: string | null = existing[0]?.avatar_url ?? null;
await sql`UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE id = ${userId}`;
await deleteOldAvatar(oldUrl);
return NextResponse.json({ success: true });
}

View file

@ -38,12 +38,35 @@ export default function ProfilePage() {
setUploading(true);
setSaveMsg("");
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/auth/avatar", { method: "POST", body: form });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Upload failed");
setAvatarUrl(data.avatarUrl);
// Step 1: get R2 key + presigned upload URL
const initRes = await fetch("/api/auth/avatar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, filename: file.name }),
});
const initData = await initRes.json();
if (!initRes.ok) throw new Error(initData.error || "Upload init failed");
const { key, publicUrl: newPublicUrl } = initData;
// Step 2: proxy PUT through our server (avoids CORS on direct R2 PUT)
const putParams = new URLSearchParams({ key, contentType: file.type });
const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!putRes.ok) throw new Error("Upload to storage failed");
// Step 3: save the new avatarUrl in DB (server cleans up old R2 object)
const patchRes = await fetch("/api/auth/avatar", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarUrl: newPublicUrl }),
});
const patchData = await patchRes.json();
if (!patchRes.ok) throw new Error(patchData.error || "Failed to save photo");
setAvatarUrl(newPublicUrl);
setSaveMsg("Photo updated!");
} catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
@ -52,6 +75,19 @@ export default function ProfilePage() {
if (fileRef.current) fileRef.current.value = "";
};
const handleRemovePhoto = async () => {
setSaveMsg("");
try {
const res = await fetch("/api/auth/avatar", { method: "DELETE" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to remove photo");
setAvatarUrl(null);
setSaveMsg("Photo removed.");
} catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
}
};
const saveProfile = async () => {
if (!name.trim()) { setSaveMsg("Please enter your name"); return; }
setSaving(true);
@ -115,7 +151,15 @@ export default function ProfilePage() {
>
{uploading ? "Uploading…" : "Change Photo"}
</button>
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP · max 5 MB</p>
{avatarUrl && !uploading && (
<button
onClick={handleRemovePhoto}
className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 hover:text-red-400 transition-colors"
>
Remove photo
</button>
)}
{!avatarUrl && <p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP · max 5 MB</p>}
<input
ref={fileRef}