@@ -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<void>((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 <bucket> \
|
||||
// --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],
|
||||
// "AllowedMethods":["PUT"],"AllowedHeaders":["*"]}]}' \
|
||||
// --endpoint-url <hetzner_endpoint>
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<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) {
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -140,6 +158,40 @@ export function HetznerConfigForm({ initialConfig }: Props) {
|
||||
{loading ? 'Saving…' : 'Save Configuration'}
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user