From 0d0f3e1a3389b906bd92a2b9ffb8d06389561ccc Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:46:00 +0200 Subject: [PATCH] Image url update --- app/api/upload/presign/route.ts | 69 ++++++++++++++++++++++++++ components/shots/FootageViewer.tsx | 70 ++++++++++++++++----------- components/versions/VersionUpload.tsx | 57 ++++++++++++---------- 3 files changed, 142 insertions(+), 54 deletions(-) create mode 100644 app/api/upload/presign/route.ts diff --git a/app/api/upload/presign/route.ts b/app/api/upload/presign/route.ts new file mode 100644 index 0000000..f84c22c --- /dev/null +++ b/app/api/upload/presign/route.ts @@ -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 } + ); + } +} diff --git a/components/shots/FootageViewer.tsx b/components/shots/FootageViewer.tsx index 7af4a80..efb917f 100644 --- a/components/shots/FootageViewer.tsx +++ b/components/shots/FootageViewer.tsx @@ -34,38 +34,50 @@ function uploadViaXhr( onProgress: (fraction: number) => void ): Promise<{ url: string; key: string }> { return new Promise((resolve, reject) => { - const formData = new FormData(); - formData.append("file", file); - - const xhr = new XMLHttpRequest(); - xhr.open("POST", "/api/upload/local"); - - xhr.upload.addEventListener("progress", (e) => { - if (e.lengthComputable) onProgress(e.loaded / e.total); - }); - - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - const json = JSON.parse(xhr.responseText); - if (json.url) resolve({ url: json.url, key: json.key ?? "" }); - 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}`)); + (async () => { + // Ask the server for a presigned Hetzner PUT URL, then upload the + // bytes directly from the browser — this bypasses this app's server + // (and any reverse-proxy / CDN body size limit, e.g. Cloudflare's + // 100MB edge cap) entirely for large footage plates. + let presignedUrl: string; + let key: string; + let url: string; + try { + const presignRes = await fetch("/api/upload/presign", { + method: "POST", + 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 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"))); - xhr.addEventListener("abort", () => reject(new Error("Upload aborted"))); - xhr.send(formData); + const xhr = new XMLHttpRequest(); + xhr.open("PUT", presignedUrl); + 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); + })(); }); } diff --git a/components/versions/VersionUpload.tsx b/components/versions/VersionUpload.tsx index f05eb8c..c679629 100644 --- a/components/versions/VersionUpload.tsx +++ b/components/versions/VersionUpload.tsx @@ -77,8 +77,10 @@ export function VersionUpload({ setUploadProgress(0); try { - // Upload via local API (XHR for progress) or UploadThing - const fileUrl = await uploadViaLocal(file, (p) => + // Upload directly to storage (XHR for progress), bypassing this app's + // 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)) ); @@ -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, onProgress: (fraction: number) => void ): Promise { - return new Promise((resolve, reject) => { - const formData = new FormData(); - formData.append("file", file); + const presignRes = await fetch("/api/upload/presign", { + method: "POST", + 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((resolve, reject) => { 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) => { if (e.lengthComputable) onProgress(e.loaded / e.total); }); xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - 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}`)); - } - } + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error(`Upload failed (HTTP ${xhr.status})`)); }); xhr.addEventListener("error", () => reject(new Error("Network error"))); xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); - xhr.send(formData); + xhr.send(file); }); + + return url; }