@@ -4,6 +4,7 @@ import { auth } from "@/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { S3Client, PutBucketCorsCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
const HETZNER_KEYS = [
|
const HETZNER_KEYS = [
|
||||||
"hetzner_endpoint",
|
"hetzner_endpoint",
|
||||||
@@ -74,3 +75,51 @@ export async function saveHetznerConfig(
|
|||||||
revalidatePath("/settings");
|
revalidatePath("/settings");
|
||||||
return { success: true };
|
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<HetznerKey, string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,13 +37,56 @@ export async function POST(
|
|||||||
const fallbackTaskId = formData.get("fallbackTaskId") as string | null;
|
const fallbackTaskId = formData.get("fallbackTaskId") as string | null;
|
||||||
const newTaskTitle = (formData.get("newTaskTitle") as string | null) ?? "";
|
const newTaskTitle = (formData.get("newTaskTitle") as string | null) ?? "";
|
||||||
|
|
||||||
if (!file || !action || !shotId || !projectId) {
|
if (!action || !shotId || !projectId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing required fields: file, action, shotId, projectId" },
|
{ error: "Missing required fields: action, shotId, projectId" },
|
||||||
{ status: 400 }
|
{ 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());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
// Browsers (especially on Windows) often send an empty MIME type for .mov
|
// Browsers (especially on Windows) often send an empty MIME type for .mov
|
||||||
@@ -55,35 +98,6 @@ export async function POST(
|
|||||||
ext === "mp4" ? "video/mp4" :
|
ext === "mp4" ? "video/mp4" :
|
||||||
"application/octet-stream");
|
"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) ─────────────────────────────────────────────────
|
// ── Version upload (.mp4) ─────────────────────────────────────────────────
|
||||||
let resolvedTaskId: string;
|
let resolvedTaskId: string;
|
||||||
|
|
||||||
|
|||||||
@@ -155,36 +155,55 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (item.status === "update-highres") {
|
if (item.status === "update-highres") {
|
||||||
// MOV high-res files: use XHR so the browser streams the file from
|
// MOV high-res files: upload directly from the browser to Hetzner
|
||||||
// disk chunk-by-chunk, matching the behaviour of HighResUploadDialog
|
// via a presigned PUT URL so the file never passes through Nginx,
|
||||||
// which is known to work reliably for large files.
|
// avoiding HTTP 413 (Request Entity Too Large) errors.
|
||||||
await new Promise<void>((resolve, reject) => {
|
//
|
||||||
|
// 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 <bucket> \
|
||||||
|
// --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],
|
||||||
|
// "AllowedMethods":["PUT"],"AllowedHeaders":["*"]}]}' \
|
||||||
|
// --endpoint-url <hetzner_endpoint>
|
||||||
|
|
||||||
|
// 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();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
|
||||||
fd.append("action", "update-highres");
|
fd.append("action", "update-highres");
|
||||||
fd.append("shotId", item.shotId!);
|
fd.append("shotId", item.shotId!);
|
||||||
fd.append("projectId", projectId);
|
fd.append("projectId", projectId);
|
||||||
|
fd.append("key", key);
|
||||||
const xhr = new XMLHttpRequest();
|
fd.append("fileName", file.name);
|
||||||
xhr.open("POST", "/api/batch-upload/upload");
|
const commitRes = await fetch("/api/batch-upload/upload", { method: "POST", body: fd });
|
||||||
|
if (!commitRes.ok) {
|
||||||
xhr.addEventListener("load", () => {
|
const d = await commitRes.json().catch(() => ({ error: "Failed to save" }));
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
throw new Error((d as { error?: string }).error ?? "Failed to save");
|
||||||
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 {
|
} else {
|
||||||
// MP4 version uploads: existing fetch-based flow
|
// MP4 version uploads: existing fetch-based flow
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Eye, EyeOff, HardDrive, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Eye, EyeOff, HardDrive, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { saveHetznerConfig } from '@/actions/settings';
|
import { saveHetznerConfig, configureHetznerCors } from '@/actions/settings';
|
||||||
|
|
||||||
interface HetznerConfig {
|
interface HetznerConfig {
|
||||||
hetzner_endpoint: string;
|
hetzner_endpoint: string;
|
||||||
@@ -25,6 +25,9 @@ export function HetznerConfigForm({ initialConfig }: Props) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [corsLoading, setCorsLoading] = useState(false);
|
||||||
|
const [corsStatus, setCorsStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
const [corsError, setCorsError] = useState<string | null>(null);
|
||||||
|
|
||||||
function handleChange(key: keyof HetznerConfig) {
|
function handleChange(key: keyof HetznerConfig) {
|
||||||
return (e: React.ChangeEvent<HTMLInputElement>) => {
|
return (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -140,6 +158,40 @@ export function HetznerConfigForm({ initialConfig }: Props) {
|
|||||||
{loading ? 'Saving…' : 'Save Configuration'}
|
{loading ? 'Saving…' : 'Save Configuration'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-border pt-5 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Batch Upload — CORS</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Required once so browsers can upload large MOV files directly to
|
||||||
|
Hetzner, bypassing the Cloudflare / Nginx size limit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{corsStatus === 'success' && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-emerald-400">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
CORS policy applied — batch MOV uploads should now work.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{corsStatus === 'error' && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-400">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{corsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={corsLoading}
|
||||||
|
onClick={handleConfigureCors}
|
||||||
|
>
|
||||||
|
{corsLoading ? 'Applying…' : 'Configure CORS on bucket'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user