import { NextRequest, NextResponse } from "next/server"; import { createReadStream, statSync } from "fs"; import { unlink, stat, readdir, rmdir } from "fs/promises"; import { execFile } from "child_process"; import { promisify } from "util"; import { join } from "path"; import { Readable } from "stream"; import { parseMultipartUpload } from "../parse-upload"; import { requireAuth } from "../auth-check"; const execFileAsync = promisify(execFile); function qpdfArgs(input: string, output: string): string[] { return [ input, output, "--object-streams=generate", "--compress-streams=y", "--recompress-flate", "--compression-level=9", "--remove-unreferenced-resources=yes", "--linearize", ]; } async function cleanup(dir: string) { try { const files = await readdir(dir); for (const f of files) { await unlink(join(dir, f)).catch(() => {}); } await rmdir(dir).catch(() => {}); } catch { // non-critical } } /** * Stream a file from disk as a Response — never loads into memory. */ function streamFileResponse( filePath: string, originalSize: number, compressedSize: number, filename: string, ): NextResponse { const nodeStream = createReadStream(filePath); const webStream = Readable.toWeb(nodeStream) as ReadableStream; return new NextResponse(webStream, { status: 200, headers: { "Content-Type": "application/pdf", "Content-Length": String(compressedSize), "Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`, "X-Original-Size": String(originalSize), "X-Compressed-Size": String(compressedSize), }, }); } export async function POST(req: NextRequest) { const authError = await requireAuth(req); if (authError) return authError; let tmpDir = ""; try { const upload = await parseMultipartUpload(req); tmpDir = upload.tmpDir; const inputPath = upload.filePath; const outputPath = join(upload.tmpDir, "output.pdf"); const originalSize = upload.size; console.log( `[compress-pdf] Starting qpdf on ${originalSize} bytes...`, ); if (originalSize < 100) { return NextResponse.json( { error: "Fișierul PDF este gol sau prea mic." }, { status: 400 }, ); } // Run qpdf try { await execFileAsync("qpdf", qpdfArgs(inputPath, outputPath), { timeout: 300_000, maxBuffer: 10 * 1024 * 1024, }); } catch (qpdfErr) { const msg = qpdfErr instanceof Error ? qpdfErr.message : "qpdf failed"; if (msg.includes("ENOENT") || msg.includes("not found")) { return NextResponse.json( { error: "qpdf nu este instalat pe server." }, { status: 501 }, ); } const exitCode = qpdfErr && typeof qpdfErr === "object" && "code" in qpdfErr ? (qpdfErr as { code: number }).code : null; if (exitCode !== 3) { console.error(`[compress-pdf] qpdf error:`, msg.slice(0, 300)); return NextResponse.json( { error: `qpdf error: ${msg.slice(0, 300)}` }, { status: 500 }, ); } } // Check output try { await stat(outputPath); } catch { return NextResponse.json( { error: "qpdf nu a produs fișier output." }, { status: 500 }, ); } const compressedSize = statSync(outputPath).size; console.log( `[compress-pdf] Done: ${originalSize} → ${compressedSize} (${Math.round((1 - compressedSize / originalSize) * 100)}% reduction)`, ); // Stream result from disk — if bigger, stream original if (compressedSize >= originalSize) { return streamFileResponse(inputPath, originalSize, originalSize, upload.filename); } // NOTE: cleanup is deferred — we can't delete files while streaming. // The files will be cleaned up by the OS temp cleaner or on next request. // For immediate cleanup, we'd need to buffer, but that defeats the purpose. return streamFileResponse(outputPath, originalSize, compressedSize, upload.filename); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; console.error(`[compress-pdf] Error:`, message); if (tmpDir) await cleanup(tmpDir); return NextResponse.json( { error: `Eroare la optimizare: ${message}` }, { status: 500 }, ); } // Note: no finally cleanup — files are being streamed }