feat(editor+trimmer): save output to cloud account via V2 File service
- New /api/files/upload: generic user-scoped Browser→Next→MinIO upload (presign → PUT → confirm), 200MB cap, image+video only, returns public URL - image-editor-export: stageToBlob() + saveStageToCloud(); "Save to my account" button in the Image Editor export popover - Trimmer: "Save to my account" button uploads the trimmed clip blob - i18n: saveToCloud/savingToCloud/savedToCloud/saveToCloudFailed in fa+en (parity 1002/1002) Connects the two client-side editors to V2 storage — output now lands in the user's account instead of only a local download. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { gatewayUrl } from "@/lib/api/gateway";
|
||||
import { getAccessToken } from "@/lib/auth/session";
|
||||
import { MINIO_PUBLIC_URL } from "@/lib/files";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Generous cap for editor/trimmer output (trimmed clips, high-res exports).
|
||||
const MAX_BYTES = 200 * 1024 * 1024; // 200 MB
|
||||
|
||||
/**
|
||||
* Generic user-scoped upload: Browser → Next → MinIO (presign → PUT → confirm).
|
||||
*
|
||||
* Unlike /api/profile/upload (avatar-only, persists to Identity), this just stores
|
||||
* the file in the user's `user-uploads` bucket and returns the public URL. Used by
|
||||
* the Image Editor "Save to cloud" and the Video Trimmer "Save to cloud" actions so
|
||||
* a user's work lands in their account instead of only a local download.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const form = await req.formData().catch(() => null);
|
||||
const file = form?.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return NextResponse.json({ error: "File too large (max 200MB)" }, { status: 413 });
|
||||
}
|
||||
const mime = file.type || "application/octet-stream";
|
||||
if (!mime.startsWith("image/") && !mime.startsWith("video/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only image or video files are allowed" },
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
|
||||
// 1. presigned PUT URL
|
||||
const presignRes = await fetch(gatewayUrl("/v1/files/presigned-upload"), {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
mime_type: mime,
|
||||
size_bytes: file.size,
|
||||
}),
|
||||
});
|
||||
const presign = await presignRes.json().catch(() => null);
|
||||
if (!presignRes.ok || !presign?.upload_url || !presign?.file_id) {
|
||||
return NextResponse.json(
|
||||
{ error: presign?.error?.message ?? "Could not start upload" },
|
||||
{ status: presignRes.status || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. PUT the bytes to MinIO (server-side; reaches minio:9000)
|
||||
const put = await fetch(presign.upload_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": mime },
|
||||
body: Buffer.from(await file.arrayBuffer()),
|
||||
});
|
||||
if (!put.ok) {
|
||||
return NextResponse.json({ error: "Upload to storage failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
// 3. confirm
|
||||
await fetch(gatewayUrl(`/v1/files/${presign.file_id}/confirm`), {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: auth,
|
||||
});
|
||||
|
||||
// 4. resolve the public URL
|
||||
const detailRes = await fetch(gatewayUrl(`/v1/files/${presign.file_id}`), {
|
||||
cache: "no-store",
|
||||
headers: auth,
|
||||
});
|
||||
const detail = await detailRes.json().catch(() => null);
|
||||
const bucket = detail?.minio_bucket ?? "user-uploads";
|
||||
const key = detail?.minio_key;
|
||||
const url = key ? `${MINIO_PUBLIC_URL}/${bucket}/${key}` : null;
|
||||
|
||||
return NextResponse.json({ id: presign.file_id, name: file.name, mime_type: mime, url });
|
||||
}
|
||||
Reference in New Issue
Block a user