@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
const ALLOWED_FOLDERS = new Set(["videos", "image"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/upload/presign
|
||||||
|
* Body: { fileName: string, contentType: string, folder?: "videos" | "image" }
|
||||||
|
*
|
||||||
|
* Returns a presigned PUT URL so the browser can upload large files (video
|
||||||
|
* versions, footage plates) directly to Hetzner Object Storage, bypassing
|
||||||
|
* this app's server entirely — and with it any reverse-proxy / CDN body
|
||||||
|
* size limits (nginx `client_max_body_size`, Cloudflare's 100MB edge cap,
|
||||||
|
* etc). The returned `key`/`url` is passed straight to the relevant server
|
||||||
|
* action (createVersion, addFootagePlate, ...) once the direct upload
|
||||||
|
* finishes — no bytes ever touch the Next.js server.
|
||||||
|
*/
|
||||||
|
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, contentType, folder } = body as {
|
||||||
|
fileName?: unknown;
|
||||||
|
contentType?: unknown;
|
||||||
|
folder?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fileName || typeof fileName !== "string") {
|
||||||
|
return NextResponse.json({ error: "fileName is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!contentType || typeof contentType !== "string") {
|
||||||
|
return NextResponse.json({ error: "contentType is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetFolder = typeof folder === "string" && ALLOWED_FOLDERS.has(folder) ? folder : "videos";
|
||||||
|
|
||||||
|
if (targetFolder === "videos" && !contentType.match(/^video\//)) {
|
||||||
|
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (targetFolder === "image" && !contentType.match(/^image\//)) {
|
||||||
|
return NextResponse.json({ error: "Only image files are accepted" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${targetFolder}/${randomUUID()}-${sanitizeFileName(fileName)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const presignedUrl = await generateHetznerPresignedUploadUrl(key, contentType);
|
||||||
|
return NextResponse.json({ presignedUrl, key, url: `/api/files/${key}` });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[upload/presign]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to generate upload URL. Check Hetzner storage configuration." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,38 +34,50 @@ function uploadViaXhr(
|
|||||||
onProgress: (fraction: number) => void
|
onProgress: (fraction: number) => void
|
||||||
): Promise<{ url: string; key: string }> {
|
): Promise<{ url: string; key: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const formData = new FormData();
|
(async () => {
|
||||||
formData.append("file", file);
|
// Ask the server for a presigned Hetzner PUT URL, then upload the
|
||||||
|
// bytes directly from the browser — this bypasses this app's server
|
||||||
const xhr = new XMLHttpRequest();
|
// (and any reverse-proxy / CDN body size limit, e.g. Cloudflare's
|
||||||
xhr.open("POST", "/api/upload/local");
|
// 100MB edge cap) entirely for large footage plates.
|
||||||
|
let presignedUrl: string;
|
||||||
xhr.upload.addEventListener("progress", (e) => {
|
let key: string;
|
||||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
let url: string;
|
||||||
});
|
try {
|
||||||
|
const presignRes = await fetch("/api/upload/presign", {
|
||||||
xhr.addEventListener("load", () => {
|
method: "POST",
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
headers: { "Content-Type": "application/json" },
|
||||||
try {
|
body: JSON.stringify({ fileName: file.name, contentType: file.type, folder: "videos" }),
|
||||||
const json = JSON.parse(xhr.responseText);
|
});
|
||||||
if (json.url) resolve({ url: json.url, key: json.key ?? "" });
|
if (!presignRes.ok) {
|
||||||
else reject(new Error(json.error ?? "Upload failed"));
|
const json = await presignRes.json().catch(() => null);
|
||||||
} catch {
|
throw new Error(json?.error ?? `Failed to prepare upload (HTTP ${presignRes.status})`);
|
||||||
reject(new Error("Invalid server response"));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(xhr.responseText);
|
|
||||||
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
|
|
||||||
} catch {
|
|
||||||
reject(new Error(`HTTP ${xhr.status}`));
|
|
||||||
}
|
}
|
||||||
|
const presignJson = await presignRes.json();
|
||||||
|
presignedUrl = presignJson.presignedUrl;
|
||||||
|
key = presignJson.key;
|
||||||
|
url = presignJson.url;
|
||||||
|
} catch (err) {
|
||||||
|
reject(err instanceof Error ? err : new Error("Failed to prepare upload"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener("error", () => reject(new Error("Network error")));
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
xhr.open("PUT", presignedUrl);
|
||||||
xhr.send(formData);
|
xhr.setRequestHeader("Content-Type", file.type);
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("progress", (e) => {
|
||||||
|
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("load", () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) resolve({ url, key });
|
||||||
|
else reject(new Error(`Upload failed (HTTP ${xhr.status})`));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("error", () => reject(new Error("Network error")));
|
||||||
|
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||||
|
xhr.send(file);
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ export function VersionUpload({
|
|||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload via local API (XHR for progress) or UploadThing
|
// Upload directly to storage (XHR for progress), bypassing this app's
|
||||||
const fileUrl = await uploadViaLocal(file, (p) =>
|
// server/reverse-proxy so large videos aren't subject to
|
||||||
|
// client_max_body_size / Cloudflare's edge upload cap.
|
||||||
|
const fileUrl = await uploadDirectToStorage(file, (p) =>
|
||||||
setUploadProgress(Math.round(p * 0.85))
|
setUploadProgress(Math.round(p * 0.85))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,45 +246,50 @@ export function VersionUpload({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload a file to /api/upload/local using XHR so we get progress events. */
|
/**
|
||||||
function uploadViaLocal(
|
* Upload a file directly to Hetzner Object Storage using a presigned PUT URL,
|
||||||
|
* via XHR so we get progress events. Bypasses this app's server entirely —
|
||||||
|
* no reverse-proxy / CDN body size limit applies to the actual file bytes.
|
||||||
|
*/
|
||||||
|
async function uploadDirectToStorage(
|
||||||
file: File,
|
file: File,
|
||||||
onProgress: (fraction: number) => void
|
onProgress: (fraction: number) => void
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
const presignRes = await fetch("/api/upload/presign", {
|
||||||
const formData = new FormData();
|
method: "POST",
|
||||||
formData.append("file", file);
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fileName: file.name, contentType: file.type, folder: "videos" }),
|
||||||
|
});
|
||||||
|
if (!presignRes.ok) {
|
||||||
|
const json = await presignRes.json().catch(() => null);
|
||||||
|
throw new Error(json?.error ?? `Failed to prepare upload (HTTP ${presignRes.status})`);
|
||||||
|
}
|
||||||
|
const { presignedUrl, url } = (await presignRes.json()) as {
|
||||||
|
presignedUrl: string;
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", "/api/upload/local");
|
xhr.open("PUT", presignedUrl);
|
||||||
|
xhr.setRequestHeader("Content-Type", file.type);
|
||||||
|
|
||||||
xhr.upload.addEventListener("progress", (e) => {
|
xhr.upload.addEventListener("progress", (e) => {
|
||||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener("load", () => {
|
xhr.addEventListener("load", () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||||
try {
|
else reject(new Error(`Upload failed (HTTP ${xhr.status})`));
|
||||||
const json = JSON.parse(xhr.responseText);
|
|
||||||
if (json.url) resolve(json.url);
|
|
||||||
else reject(new Error(json.error ?? "Upload failed"));
|
|
||||||
} catch {
|
|
||||||
reject(new Error("Invalid server response"));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(xhr.responseText);
|
|
||||||
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("error", () => reject(new Error("Network error")));
|
||||||
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
|
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user