Image url update
Deploy / deploy (push) Successful in 2m30s

This commit is contained in:
twotalesanimation
2026-07-29 07:46:00 +02:00
parent f0dcf17e93
commit 0d0f3e1a33
3 changed files with 142 additions and 54 deletions
+69
View File
@@ -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 }
);
}
}
+41 -29
View File
@@ -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);
})();
}); });
} }
+32 -25
View 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;
} }