@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { generateHetznerPresignedUploadUrl } from "@/lib/storage";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-upload/presign
|
||||||
|
* Body: { fileName: string }
|
||||||
|
*
|
||||||
|
* Returns a presigned PUT URL for uploading a high-res file directly from
|
||||||
|
* the browser to Hetzner Object Storage, together with the storage key that
|
||||||
|
* must be passed to POST /api/batch-upload/upload (action=update-highres) to
|
||||||
|
* commit the shot record after the direct upload completes.
|
||||||
|
*
|
||||||
|
* This two-phase approach avoids routing large MOV files through the app
|
||||||
|
* server / reverse proxy, removing any client_max_body_size limits.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fileName } = body as { fileName?: unknown };
|
||||||
|
|
||||||
|
if (!fileName || typeof fileName !== "string") {
|
||||||
|
return NextResponse.json({ error: "fileName is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||||
|
const contentType =
|
||||||
|
ext === "mov" ? "video/quicktime" :
|
||||||
|
ext === "mp4" ? "video/mp4" :
|
||||||
|
"application/octet-stream";
|
||||||
|
|
||||||
|
const key = `highres/${randomUUID()}-${fileName}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const presignedUrl = await generateHetznerPresignedUploadUrl(key, contentType);
|
||||||
|
return NextResponse.json({ presignedUrl, key });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[batch-upload/presign]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to generate upload URL. Check Hetzner storage configuration." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,23 +154,56 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
if (item.status === "update-highres") {
|
||||||
fd.append("file", file);
|
// MOV high-res files: use XHR so the browser streams the file from
|
||||||
fd.append("action", item.status);
|
// disk chunk-by-chunk, matching the behaviour of HighResUploadDialog
|
||||||
fd.append("shotId", item.shotId!);
|
// which is known to work reliably for large files.
|
||||||
fd.append("projectId", projectId);
|
await new Promise<void>((resolve, reject) => {
|
||||||
if (item.taskId) fd.append("taskId", item.taskId);
|
const fd = new FormData();
|
||||||
if (item.fallbackTaskId) fd.append("fallbackTaskId", item.fallbackTaskId);
|
fd.append("file", file);
|
||||||
if (item.newTaskTitle) fd.append("newTaskTitle", item.newTaskTitle);
|
fd.append("action", "update-highres");
|
||||||
|
fd.append("shotId", item.shotId!);
|
||||||
|
fd.append("projectId", projectId);
|
||||||
|
|
||||||
const res = await fetch("/api/batch-upload/upload", {
|
const xhr = new XMLHttpRequest();
|
||||||
method: "POST",
|
xhr.open("POST", "/api/batch-upload/upload");
|
||||||
body: fd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
xhr.addEventListener("load", () => {
|
||||||
const data = await res.json().catch(() => ({ error: "Upload failed" }));
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
throw new Error(data.error ?? "Upload failed");
|
resolve();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(xhr.responseText) as { error?: string };
|
||||||
|
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`HTTP ${xhr.status}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("error", () => reject(new Error("Network error")));
|
||||||
|
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
|
||||||
|
xhr.send(fd);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// MP4 version uploads: existing fetch-based flow
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("action", item.status);
|
||||||
|
fd.append("shotId", item.shotId!);
|
||||||
|
fd.append("projectId", projectId);
|
||||||
|
if (item.taskId) fd.append("taskId", item.taskId);
|
||||||
|
if (item.fallbackTaskId) fd.append("fallbackTaskId", item.fallbackTaskId);
|
||||||
|
if (item.newTaskTitle) fd.append("newTaskTitle", item.newTaskTitle);
|
||||||
|
|
||||||
|
const res = await fetch("/api/batch-upload/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ error: "Upload failed" }));
|
||||||
|
throw new Error(data.error ?? "Upload failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadStates((prev) => ({
|
setUploadStates((prev) => ({
|
||||||
|
|||||||
@@ -279,6 +279,25 @@ export async function uploadToHetzner(
|
|||||||
return { key };
|
return { key };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a presigned PUT URL for direct browser-to-Hetzner uploads.
|
||||||
|
* The caller must use the returned `key` to commit the upload via the API.
|
||||||
|
* Expires in 1 hour by default.
|
||||||
|
*/
|
||||||
|
export async function generateHetznerPresignedUploadUrl(
|
||||||
|
key: string,
|
||||||
|
contentType: string,
|
||||||
|
expiresIn: number = 3600
|
||||||
|
): Promise<string> {
|
||||||
|
const { client, bucket } = await buildHetznerClient();
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
ContentType: contentType,
|
||||||
|
});
|
||||||
|
return getSignedUrl(client, command, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-process cache for Hetzner key existence checks.
|
* In-process cache for Hetzner key existence checks.
|
||||||
* Positive hits (key exists) are cached indefinitely for the process lifetime
|
* Positive hits (key exists) are cached indefinitely for the process lifetime
|
||||||
|
|||||||
Reference in New Issue
Block a user