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:
parent
5235e26cad
commit
7816247073
2 changed files with 135 additions and 55 deletions
|
|
@ -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 });
|
||||
|
||||
// Parse file from multipart
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||
return rows[0]?.user_id ?? null;
|
||||
}
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
|
||||
if (!ALLOWED.includes(file.type)) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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 ext = (filename?.split(".").pop() || "jpg").toLowerCase();
|
||||
const key = `avatars/${userId}/${Date.now()}.${ext}`;
|
||||
const baseUrl = publicUrl || `https://pub-${accountId}.r2.dev`;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue