From 78162470732d28f66cb738a38c7bf3bbe11a6e95 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 24 May 2026 13:53:49 +0530 Subject: [PATCH] fix(profile): fix parent avatar upload with 3-step proxy pattern + remove photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/api/auth/avatar/route.ts | 132 ++++++++++++++++++++----------- src/app/profile/page.tsx | 58 ++++++++++++-- 2 files changed, 135 insertions(+), 55 deletions(-) diff --git a/src/app/api/auth/avatar/route.ts b/src/app/api/auth/avatar/route.ts index b643826..51779d3 100644 --- a/src/app/api/auth/avatar/route.ts +++ b/src/app/api/auth/avatar/route.ts @@ -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 { 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 }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index d374454..52ad0c3 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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"} -

JPEG, PNG or WebP · max 5 MB

+ {avatarUrl && !uploading && ( + + )} + {!avatarUrl &&

JPEG, PNG or WebP · max 5 MB

}