@@ -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 };
|
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
|
* Generate a presigned download URL from Hetzner that forces a file download
|
||||||
* with the original filename preserved via Content-Disposition.
|
* with the original filename preserved via Content-Disposition.
|
||||||
|
|||||||
Reference in New Issue
Block a user