@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, Trash2, CheckCircle2, AlertCircle, ExternalLink } from "lucide-react";
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
key: string;
|
||||
streamUrl: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
uploadMs: number;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export default function StorageTestPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<TestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/storage-test", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Upload failed");
|
||||
setResult(data);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!result) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/storage-test?key=${encodeURIComponent(result.key)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? "Delete failed");
|
||||
}
|
||||
setResult(null);
|
||||
setFile(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Delete failed");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Object Storage Playback Test</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Upload a video to Hetzner object storage and play it back directly. Use this to compare
|
||||
streaming performance against the local file server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<div
|
||||
className="border-2 border-dashed border-border rounded-md p-8 text-center cursor-pointer hover:border-primary/40 transition-colors"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
{file ? (
|
||||
<p className="text-sm font-medium">
|
||||
{file.name}{" "}
|
||||
<span className="text-muted-foreground">({formatBytes(file.size)})</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Click to select a video file</p>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
setFile(e.target.files?.[0] ?? null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full">
|
||||
{uploading ? "Uploading to Hetzner…" : "Upload & Test Playback"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-400 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-400 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-2">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Uploaded in <strong>{result.uploadMs}ms</strong> · {formatBytes(result.fileSize)} ·
|
||||
URL expires at {new Date(result.expiresAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Player */}
|
||||
<div className="rounded-lg overflow-hidden bg-black border border-border aspect-video">
|
||||
<video
|
||||
key={result.streamUrl}
|
||||
src={result.streamUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL + cleanup */}
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="flex-1 text-xs text-muted-foreground bg-muted rounded px-2 py-1.5 break-all select-all">
|
||||
{result.streamUrl}
|
||||
</code>
|
||||
<a
|
||||
href={result.streamUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open URL directly"
|
||||
className="shrink-0 p-1.5 rounded border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-red-400 border-red-500/30 hover:bg-red-500/10"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleting ? "Deleting…" : "Delete test file from Hetzner"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { uploadToHetzner, deleteFromHetzner, generateHetznerStreamUrl } from "@/lib/storage";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
/** POST /api/storage-test — upload a file to Hetzner and return a playback URL */
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const uploadStart = Date.now();
|
||||
const { key } = await uploadToHetzner(buffer, file.name, file.type, "storage-test");
|
||||
const uploadMs = Date.now() - uploadStart;
|
||||
|
||||
const streamUrl = await generateHetznerStreamUrl(key, 3600);
|
||||
|
||||
return NextResponse.json({
|
||||
key,
|
||||
streamUrl,
|
||||
filename: file.name,
|
||||
fileSize: file.size,
|
||||
uploadMs,
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE /api/storage-test?key=storage-test/... — clean up a test file */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const key = searchParams.get("key");
|
||||
|
||||
// Restrict deletions to the storage-test/ folder only
|
||||
if (!key || !key.startsWith("storage-test/")) {
|
||||
return NextResponse.json({ error: "Invalid key — only storage-test/ files can be deleted here" }, { status: 400 });
|
||||
}
|
||||
|
||||
await deleteFromHetzner(key);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -277,6 +277,19 @@ export async function uploadToHetzner(
|
||||
return { key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for streaming a Hetzner object directly in the browser.
|
||||
* No Content-Disposition header — suitable for <video src="..."> playback.
|
||||
*/
|
||||
export async function generateHetznerStreamUrl(
|
||||
key: string,
|
||||
expiresIn: number = 3600
|
||||
): Promise<string> {
|
||||
const { client, bucket } = await buildHetznerClient();
|
||||
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned download URL from Hetzner that forces a file download
|
||||
* with the original filename preserved via Content-Disposition.
|
||||
|
||||
Reference in New Issue
Block a user