From 15fda1ec447bcbcc553bff44e8432e4aa3b5f47a Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:03:02 +0200 Subject: [PATCH] CORS UPloads --- actions/settings.ts | 49 ++++++++++++ app/api/batch-upload/upload/route.ts | 76 +++++++++++-------- components/batch-upload/BatchUploadClient.tsx | 75 +++++++++++------- components/settings/HetznerConfigForm.tsx | 54 ++++++++++++- 4 files changed, 194 insertions(+), 60 deletions(-) diff --git a/actions/settings.ts b/actions/settings.ts index 07e0bfe..aa3023a 100644 --- a/actions/settings.ts +++ b/actions/settings.ts @@ -4,6 +4,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { S3Client, PutBucketCorsCommand } from "@aws-sdk/client-s3"; const HETZNER_KEYS = [ "hetzner_endpoint", @@ -74,3 +75,51 @@ export async function saveHetznerConfig( revalidatePath("/settings"); return { success: true }; } + +/** + * Applies a CORS policy to the Hetzner bucket that allows direct browser + * PUT uploads (used by batch-upload presigned URL flow). + */ +export async function configureHetznerCors(): Promise<{ success: true }> { + await requireAdmin(); + + const rows = await db.systemConfig.findMany({ + where: { key: { in: [...HETZNER_KEYS] } }, + }); + const map = Object.fromEntries(rows.map((r) => [r.key, r.value])) as Partial< + Record + >; + + const endpoint = map.hetzner_endpoint ?? process.env.HETZNER_ENDPOINT ?? ""; + const accessKey = map.hetzner_access_key ?? process.env.HETZNER_ACCESS_KEY ?? ""; + const secretKey = map.hetzner_secret_key ?? process.env.HETZNER_SECRET_KEY ?? ""; + const bucket = map.hetzner_bucket_name ?? process.env.HETZNER_BUCKET_NAME ?? ""; + + if (!endpoint || !accessKey || !secretKey || !bucket) { + throw new Error("Hetzner storage is not fully configured. Save credentials first."); + } + + const client = new S3Client({ + region: "auto", + endpoint, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + }); + + await client.send( + new PutBucketCorsCommand({ + Bucket: bucket, + CORSConfiguration: { + CORSRules: [ + { + AllowedOrigins: ["*"], + AllowedMethods: ["PUT", "GET"], + AllowedHeaders: ["*"], + MaxAgeSeconds: 3600, + }, + ], + }, + }) + ); + + return { success: true }; +} diff --git a/app/api/batch-upload/upload/route.ts b/app/api/batch-upload/upload/route.ts index f9ddb23..ef3543d 100644 --- a/app/api/batch-upload/upload/route.ts +++ b/app/api/batch-upload/upload/route.ts @@ -37,13 +37,56 @@ export async function POST( const fallbackTaskId = formData.get("fallbackTaskId") as string | null; const newTaskTitle = (formData.get("newTaskTitle") as string | null) ?? ""; - if (!file || !action || !shotId || !projectId) { + if (!action || !shotId || !projectId) { return NextResponse.json( - { error: "Missing required fields: file, action, shotId, projectId" }, + { error: "Missing required fields: action, shotId, projectId" }, { status: 400 } ); } + // ── High-res (.mov) — file was uploaded directly to Hetzner via presigned URL ── + // The file never passes through this server or the Nginx proxy, avoiding + // HTTP 413 errors caused by client_max_body_size limits. + if (action === "update-highres") { + const preUploadedKey = formData.get("key") as string | null; + const preUploadedFileName = formData.get("fileName") as string | null; + + if (!preUploadedKey || !preUploadedFileName) { + return NextResponse.json( + { error: "key and fileName are required for update-highres" }, + { status: 400 } + ); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { id: true, highResKey: true }, + }); + if (!shot) { + return NextResponse.json({ error: "Shot not found" }, { status: 404 }); + } + + if (shot.highResKey) { + await deleteFromHetzner(shot.highResKey).catch(() => {}); + } + + await db.shot.update({ + where: { id: shotId }, + data: { highResKey: preUploadedKey, highResFilename: preUploadedFileName }, + }); + + return NextResponse.json({ + success: true, + action: "update-highres", + fileName: preUploadedFileName, + }); + } + + // For all other actions (MP4 version uploads) a file is required. + if (!file) { + return NextResponse.json({ error: "file is required" }, { status: 400 }); + } + const buffer = Buffer.from(await file.arrayBuffer()); // Browsers (especially on Windows) often send an empty MIME type for .mov @@ -55,35 +98,6 @@ export async function POST( ext === "mp4" ? "video/mp4" : "application/octet-stream"); - // ── High-res (.mov) ─────────────────────────────────────────────────────── - if (action === "update-highres") { - const shot = await db.shot.findUnique({ - where: { id: shotId }, - select: { id: true, highResKey: true }, - }); - if (!shot) { - return NextResponse.json({ error: "Shot not found" }, { status: 404 }); - } - - // Remove the previous high-res file if one exists - if (shot.highResKey) { - await deleteFromHetzner(shot.highResKey).catch(() => {}); - } - - const { key } = await uploadToHetzner(buffer, file.name, contentType, "highres"); - - await db.shot.update({ - where: { id: shotId }, - data: { highResKey: key, highResFilename: file.name }, - }); - - return NextResponse.json({ - success: true, - action: "update-highres", - fileName: file.name, - }); - } - // ── Version upload (.mp4) ───────────────────────────────────────────────── let resolvedTaskId: string; diff --git a/components/batch-upload/BatchUploadClient.tsx b/components/batch-upload/BatchUploadClient.tsx index e3fe2a0..8799278 100644 --- a/components/batch-upload/BatchUploadClient.tsx +++ b/components/batch-upload/BatchUploadClient.tsx @@ -155,36 +155,55 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { try { if (item.status === "update-highres") { - // MOV high-res files: use XHR so the browser streams the file from - // disk chunk-by-chunk, matching the behaviour of HighResUploadDialog - // which is known to work reliably for large files. - await new Promise((resolve, reject) => { - const fd = new FormData(); - fd.append("file", file); - fd.append("action", "update-highres"); - fd.append("shotId", item.shotId!); - fd.append("projectId", projectId); + // MOV high-res files: upload directly from the browser to Hetzner + // via a presigned PUT URL so the file never passes through Nginx, + // avoiding HTTP 413 (Request Entity Too Large) errors. + // + // Prerequisite: the Hetzner bucket must have a CORS policy that + // allows PUT from this origin. Add it once via the Hetzner console + // or AWS CLI: aws s3api put-bucket-cors --bucket \ + // --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"], + // "AllowedMethods":["PUT"],"AllowedHeaders":["*"]}]}' \ + // --endpoint-url - const xhr = new XMLHttpRequest(); - xhr.open("POST", "/api/batch-upload/upload"); - - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - 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); + // Step 1 — obtain a presigned PUT URL + server-generated storage key + const presignRes = await fetch("/api/batch-upload/presign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileName: file.name }), }); + if (!presignRes.ok) { + const d = await presignRes.json().catch(() => ({ error: "Failed to get upload URL" })); + throw new Error((d as { error?: string }).error ?? "Failed to get upload URL"); + } + const { presignedUrl, key } = await presignRes.json() as { presignedUrl: string; key: string }; + + // Step 2 — PUT the file directly to Hetzner (no proxy in the path) + const putRes = await fetch(presignedUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type || "video/quicktime" }, + }); + if (!putRes.ok) { + throw new Error( + putRes.status === 403 + ? "Storage upload failed — check Hetzner CORS / bucket policy" + : `Storage upload failed (HTTP ${putRes.status})` + ); + } + + // Step 3 — commit: record the uploaded key in the DB + const fd = new FormData(); + fd.append("action", "update-highres"); + fd.append("shotId", item.shotId!); + fd.append("projectId", projectId); + fd.append("key", key); + fd.append("fileName", file.name); + const commitRes = await fetch("/api/batch-upload/upload", { method: "POST", body: fd }); + if (!commitRes.ok) { + const d = await commitRes.json().catch(() => ({ error: "Failed to save" })); + throw new Error((d as { error?: string }).error ?? "Failed to save"); + } } else { // MP4 version uploads: existing fetch-based flow const fd = new FormData(); diff --git a/components/settings/HetznerConfigForm.tsx b/components/settings/HetznerConfigForm.tsx index 7e2a7c9..b070d1a 100644 --- a/components/settings/HetznerConfigForm.tsx +++ b/components/settings/HetznerConfigForm.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Eye, EyeOff, HardDrive, CheckCircle2, AlertCircle } from 'lucide-react'; -import { saveHetznerConfig } from '@/actions/settings'; +import { saveHetznerConfig, configureHetznerCors } from '@/actions/settings'; interface HetznerConfig { hetzner_endpoint: string; @@ -25,6 +25,9 @@ export function HetznerConfigForm({ initialConfig }: Props) { const [loading, setLoading] = useState(false); const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [errorMsg, setErrorMsg] = useState(null); + const [corsLoading, setCorsLoading] = useState(false); + const [corsStatus, setCorsStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [corsError, setCorsError] = useState(null); function handleChange(key: keyof HetznerConfig) { return (e: React.ChangeEvent) => { @@ -49,6 +52,21 @@ export function HetznerConfigForm({ initialConfig }: Props) { } } + async function handleConfigureCors() { + setCorsLoading(true); + setCorsStatus('idle'); + setCorsError(null); + try { + await configureHetznerCors(); + setCorsStatus('success'); + } catch (err: unknown) { + setCorsStatus('error'); + setCorsError(err instanceof Error ? err.message : 'Failed to configure CORS.'); + } finally { + setCorsLoading(false); + } + } + return ( @@ -140,6 +158,40 @@ export function HetznerConfigForm({ initialConfig }: Props) { {loading ? 'Saving…' : 'Save Configuration'} + +
+
+

Batch Upload — CORS

+

+ Required once so browsers can upload large MOV files directly to + Hetzner, bypassing the Cloudflare / Nginx size limit. +

+
+ + {corsStatus === 'success' && ( +
+ + CORS policy applied — batch MOV uploads should now work. +
+ )} + + {corsStatus === 'error' && ( +
+ + {corsError} +
+ )} + + +
);