From 3264abb432d609f6aaa8c5c627fc7f1689412311 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:58:06 +0200 Subject: [PATCH] Object storage test --- .../settings/storage-test/page.tsx | 177 ++++++++++++++++++ app/api/storage-test/route.ts | 54 ++++++ lib/storage.ts | 13 ++ 3 files changed, 244 insertions(+) create mode 100644 app/(dashboard)/settings/storage-test/page.tsx create mode 100644 app/api/storage-test/route.ts diff --git a/app/(dashboard)/settings/storage-test/page.tsx b/app/(dashboard)/settings/storage-test/page.tsx new file mode 100644 index 0000000..49682e5 --- /dev/null +++ b/app/(dashboard)/settings/storage-test/page.tsx @@ -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(null); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(false); + const inputRef = useRef(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 ( +
+
+

Object Storage Playback Test

+

+ Upload a video to Hetzner object storage and play it back directly. Use this to compare + streaming performance against the local file server. +

+
+ + {/* Upload */} +
+
inputRef.current?.click()} + > + + {file ? ( +

+ {file.name}{" "} + ({formatBytes(file.size)}) +

+ ) : ( +

Click to select a video file

+ )} + { + setFile(e.target.files?.[0] ?? null); + setResult(null); + setError(null); + }} + /> +
+ + +
+ + {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Result */} + {result && ( +
+
+ + + Uploaded in {result.uploadMs}ms · {formatBytes(result.fileSize)} · + URL expires at {new Date(result.expiresAt).toLocaleTimeString()} + +
+ + {/* Player */} +
+
+ + {/* URL + cleanup */} +
+ + {result.streamUrl} + + + + +
+ + +
+ )} +
+ ); +} diff --git a/app/api/storage-test/route.ts b/app/api/storage-test/route.ts new file mode 100644 index 0000000..2d2fe00 --- /dev/null +++ b/app/api/storage-test/route.ts @@ -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 }); +} diff --git a/lib/storage.ts b/lib/storage.ts index 959426f..20908b6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -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