import { NextRequest, NextResponse } from "next/server"; import JSZip from "jszip"; import { marked } from "marked"; import { prisma } from "@/lib/prisma"; import { requireApiAuth } from "@/lib/api-auth"; type ZipEntry = { path: string; isDir: boolean; content?: string; }; type ParsedMarkdown = { title: string; order: number; tags: string[]; htmlContent: string; }; const MAX_RESTORE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB function normalizeZipPath(input: string): string { const noBackslashes = input.replace(/\\/g, "/"); const noTrailingSlash = noBackslashes.endsWith("/") ? noBackslashes.slice(0, -1) : noBackslashes; return noTrailingSlash.trim(); } function getDepth(path: string): number { return path.split("/").length; } function getParentPath(path: string): string | null { const parts = path.split("/"); if (parts.length <= 1) return null; return parts.slice(0, -1).join("/"); } function getNameFromPath(path: string): string { return path.split("/").pop() || "Untitled"; } async function parseMarkdownWithFrontmatter(filePath: string, content: string): Promise { const defaultTitle = getNameFromPath(filePath).replace(/\.md$/i, "") || "Untitled"; let title = defaultTitle; let tags: string[] = []; let order = 0; let markdownBody = content; const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/); if (fmMatch) { const fmString = fmMatch[1]; markdownBody = markdownBody.slice(fmMatch[0].length); const titleMatch = fmString.match(/title:\s*"(.*)"/); if (titleMatch && titleMatch[1]) title = titleMatch[1]; const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/); if (tagsMatch && tagsMatch[1]) { tags = tagsMatch[1] .split(",") .map((item) => item.trim().replace(/^"|"$/g, "")) .filter(Boolean); } const orderMatch = fmString.match(/order:\s*(\d+)/); if (orderMatch && orderMatch[1]) { order = parseInt(orderMatch[1], 10); } } const htmlContent = await marked(markdownBody, { gfm: true, breaks: true }); return { title, order, tags, htmlContent }; } function collectFolderPaths(entries: ZipEntry[]): string[] { const folderSet = new Set(); for (const entry of entries) { const normalized = normalizeZipPath(entry.path); if (!normalized) continue; if (entry.isDir) { folderSet.add(normalized); } let current = getParentPath(normalized); while (current) { folderSet.add(current); current = getParentPath(current); } } return Array.from(folderSet).sort((a, b) => getDepth(a) - getDepth(b)); } export async function POST(req: NextRequest) { const authError = await requireApiAuth(); if (authError) return authError; try { const formData = await req.formData(); const file = formData.get("file"); if (!(file instanceof File)) { return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); } if (file.size <= 0) { return NextResponse.json({ error: "Empty file" }, { status: 400 }); } if (file.size > MAX_RESTORE_FILE_SIZE) { return NextResponse.json({ error: "File too large" }, { status: 413 }); } const buffer = await file.arrayBuffer(); const zip = await JSZip.loadAsync(buffer); const entries: ZipEntry[] = []; const loadTasks: Promise[] = []; zip.forEach((rawPath, zipEntry) => { if (rawPath.startsWith("__MACOSX") || rawPath.includes(".DS_Store")) return; loadTasks.push((async () => { const path = normalizeZipPath(rawPath); if (!path) return; if (zipEntry.dir) { entries.push({ path, isDir: true }); return; } if (!path.endsWith(".md")) return; const content = await zipEntry.async("string"); entries.push({ path, isDir: false, content }); })()); }); await Promise.all(loadTasks); if (entries.length === 0) { return NextResponse.json({ error: "No valid markdown entries found in zip" }, { status: 400 }); } const folderPaths = collectFolderPaths(entries); const fileEntries = entries .filter((entry): entry is ZipEntry & { content: string } => !entry.isDir && typeof entry.content === "string") .sort((a, b) => getDepth(a.path) - getDepth(b.path)); // Parse markdown before DB transaction to keep lock time low. const parsedFiles = await Promise.all( fileEntries.map(async (entry) => { const parsed = await parseMarkdownWithFrontmatter(entry.path, entry.content); return { entry, parsed }; }) ); await prisma.$transaction(async (tx) => { await tx.page.deleteMany(); const folderIdMap = new Map(); for (const folderPath of folderPaths) { const parentPath = getParentPath(folderPath); const parentId = parentPath ? folderIdMap.get(parentPath) || null : null; const folder = await tx.page.create({ data: { title: getNameFromPath(folderPath), type: "folder", parentId, }, }); folderIdMap.set(folderPath, folder.id); } for (const { entry, parsed } of parsedFiles) { const parentPath = getParentPath(entry.path); const parentId = parentPath ? folderIdMap.get(parentPath) || null : null; await tx.page.create({ data: { title: parsed.title, type: "file", content: parsed.htmlContent, tags: JSON.stringify(parsed.tags), order: parsed.order, parentId, }, }); } }); return NextResponse.json({ success: true, count: entries.length, files: parsedFiles.length, folders: folderPaths.length, }); } catch (error) { console.error("Restore failed:", error); return NextResponse.json({ error: "Restore failed" }, { status: 500 }); } }