117 lines
3.4 KiB
TypeScript
117 lines
3.4 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { auth } from "@/auth";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { listHetznerKeys, uploadToHetznerWithKey } from "@/lib/storage";
|
|
|
|
export const maxDuration = 300; // 5 min — large video uploads
|
|
|
|
const MIME_MAP: Record<string, string> = {
|
|
".mp4": "video/mp4",
|
|
".mov": "video/quicktime",
|
|
".avi": "video/x-msvideo",
|
|
".mxf": "application/mxf",
|
|
".webm": "video/webm",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
};
|
|
|
|
function walkDir(
|
|
dir: string,
|
|
base: string
|
|
): Array<{ key: string; sizeBytes: number }> {
|
|
const results: Array<{ key: string; sizeBytes: number }> = [];
|
|
if (!fs.existsSync(dir)) return results;
|
|
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const abs = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...walkDir(abs, base));
|
|
} else if (entry.isFile()) {
|
|
const key = path.relative(base, abs).replace(/\\/g, "/");
|
|
results.push({ key, sizeBytes: fs.statSync(abs).size });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/** GET /api/admin/migration — list local files with Hetzner sync status */
|
|
export async function GET() {
|
|
const session = await auth();
|
|
if (!session?.user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
|
|
const localFiles = walkDir(uploadDir, uploadDir);
|
|
|
|
let hetznerKeys = new Set<string>();
|
|
let hetznerConfigured = true;
|
|
try {
|
|
hetznerKeys = await listHetznerKeys();
|
|
} catch {
|
|
hetznerConfigured = false;
|
|
}
|
|
|
|
const files = localFiles.map(({ key, sizeBytes }) => ({
|
|
key,
|
|
filename: path.basename(key),
|
|
folder: path.dirname(key).replace(/\\/g, "/") || "root",
|
|
sizeBytes,
|
|
hetznerExists: hetznerKeys.has(key),
|
|
}));
|
|
|
|
return NextResponse.json({ files, hetznerConfigured });
|
|
}
|
|
|
|
/** POST /api/admin/migration — migrate a single file to Hetzner */
|
|
export async function POST(req: NextRequest) {
|
|
const session = await auth();
|
|
if (!session?.user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const key = body?.key;
|
|
if (!key || typeof key !== "string") {
|
|
return NextResponse.json({ error: "key is required" }, { status: 400 });
|
|
}
|
|
|
|
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
|
|
const absPath = path.resolve(path.join(uploadDir, key));
|
|
|
|
// Path traversal guard
|
|
if (!absPath.startsWith(uploadDir + path.sep)) {
|
|
return NextResponse.json({ error: "Invalid key" }, { status: 403 });
|
|
}
|
|
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(absPath);
|
|
} catch {
|
|
return NextResponse.json({ error: "File not found on disk" }, { status: 404 });
|
|
}
|
|
|
|
if (!stat.isFile()) {
|
|
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
|
}
|
|
|
|
const ext = path.extname(key).toLowerCase();
|
|
const contentType = MIME_MAP[ext] ?? "application/octet-stream";
|
|
|
|
const start = Date.now();
|
|
try {
|
|
const stream = fs.createReadStream(absPath);
|
|
await uploadToHetznerWithKey(stream, key, contentType, stat.size);
|
|
return NextResponse.json({ success: true, key, ms: Date.now() - start });
|
|
} catch (e: unknown) {
|
|
return NextResponse.json(
|
|
{ error: e instanceof Error ? e.message : "Upload failed", key },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|