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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
|
|
||||||
const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
|
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) {
|
async function getAuthedUserId(): Promise<string | null> {
|
||||||
// Auth
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get("tia_session")?.value;
|
const token = cookieStore.get("tia_session")?.value;
|
||||||
if (!token) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!token) return null;
|
||||||
|
const rows = await sql`
|
||||||
const sessions = await sql`
|
|
||||||
SELECT user_id FROM sessions
|
SELECT user_id FROM sessions
|
||||||
WHERE session_token = ${token} AND expires > NOW()
|
WHERE session_token = ${token} AND expires > NOW()
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
const userId = sessions[0]?.user_id;
|
return rows[0]?.user_id ?? null;
|
||||||
if (!userId) return NextResponse.json({ error: "Invalid session" }, { status: 401 });
|
}
|
||||||
|
|
||||||
// Parse file from multipart
|
function makeR2Client() {
|
||||||
let formData: FormData;
|
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 {
|
try {
|
||||||
formData = await req.formData();
|
const client = makeR2Client();
|
||||||
} catch {
|
await client.send(new DeleteObjectCommand({
|
||||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
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 });
|
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 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 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 });
|
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 key = `avatars/${userId}/${Date.now()}.${ext}`;
|
||||||
const baseUrl = publicUrl || `https://pub-${accountId}.r2.dev`;
|
|
||||||
|
|
||||||
const client = new S3Client({
|
const client = makeR2Client();
|
||||||
region: "auto",
|
const uploadUrl = await getSignedUrl(
|
||||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
client,
|
||||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }),
|
||||||
});
|
{ expiresIn: 3600 }
|
||||||
|
);
|
||||||
|
|
||||||
const bytes = await file.arrayBuffer();
|
return NextResponse.json({ key, uploadUrl, publicUrl: `${publicUrl}/${key}` });
|
||||||
await client.send(new PutObjectCommand({
|
}
|
||||||
Bucket: bucket,
|
|
||||||
Key: key,
|
|
||||||
Body: Buffer.from(bytes),
|
|
||||||
ContentType: file.type,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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
|
const { avatarUrl } = await req.json();
|
||||||
await sql`
|
if (!avatarUrl) return NextResponse.json({ error: "avatarUrl required" }, { status: 400 });
|
||||||
UPDATE users SET avatar_url = ${avatarUrl}, updated_at = NOW()
|
|
||||||
WHERE id = ${userId}
|
// 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 });
|
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);
|
setUploading(true);
|
||||||
setSaveMsg("");
|
setSaveMsg("");
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
// Step 1: get R2 key + presigned upload URL
|
||||||
form.append("file", file);
|
const initRes = await fetch("/api/auth/avatar", {
|
||||||
const res = await fetch("/api/auth/avatar", { method: "POST", body: form });
|
method: "POST",
|
||||||
const data = await res.json();
|
headers: { "Content-Type": "application/json" },
|
||||||
if (!res.ok) throw new Error(data.error || "Upload failed");
|
body: JSON.stringify({ contentType: file.type, filename: file.name }),
|
||||||
setAvatarUrl(data.avatarUrl);
|
});
|
||||||
|
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!");
|
setSaveMsg("Photo updated!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
|
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
|
||||||
|
|
@ -52,6 +75,19 @@ export default function ProfilePage() {
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
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 () => {
|
const saveProfile = async () => {
|
||||||
if (!name.trim()) { setSaveMsg("Please enter your name"); return; }
|
if (!name.trim()) { setSaveMsg("Please enter your name"); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -115,7 +151,15 @@ export default function ProfilePage() {
|
||||||
>
|
>
|
||||||
{uploading ? "Uploading…" : "Change Photo"}
|
{uploading ? "Uploading…" : "Change Photo"}
|
||||||
</button>
|
</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
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue