87ac81c6c9
Filenames with Romanian characters (Ș, Ț, etc.) caused ByteString errors. Also pass original filename through to extreme mode response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
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
|
|
}
|