diff --git a/server.js b/server.js index b13a2d1..336a795 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,8 @@ app.use(express.raw({ type: "*/*", limit: "30mb" })); app.get("/", (_req, res) => res.status(200).send("postconvert: ok")); app.get("/health", (_req, res) => res.status(200).send("ok")); +// ---------------- Auth ---------------- + function requireAuth(req, res) { const token = process.env.CONVERTER_TOKEN; const auth = req.headers.authorization || ""; @@ -27,35 +29,107 @@ function requireAuth(req, res) { return true; } +// ---------------- Type checks ---------------- + function isPdfRequest(req) { const contentType = String(req.headers["content-type"] || "").toLowerCase(); const filename = String(req.headers["x-filename"] || "").toLowerCase(); - return contentType === "application/pdf" || filename.endsWith(".pdf"); + return contentType.startsWith("application/pdf") || filename.endsWith(".pdf"); } function looksLikeHeic(buf) { - // ISO-BMFF container: "ftyp" at offset 4, brand includes heic/heif/mif1/msf1 - if (!buf || buf.length < 32) return false; + // ISO-BMFF container: "ftyp" at offset 4. Scan brands for HEIF-family. + if (!buf || buf.length < 16) return false; if (buf.toString("ascii", 4, 8) !== "ftyp") return false; - const brands = buf.toString("ascii", 8, 32); + + // Scan more than first 32 bytes; compatible brands can appear later. + const scanEnd = Math.min(buf.length, 256); + const brands = buf.toString("ascii", 8, scanEnd); + return ( brands.includes("heic") || brands.includes("heif") || + brands.includes("heix") || + brands.includes("hevc") || + brands.includes("hevx") || brands.includes("mif1") || brands.includes("msf1") + // If you want to treat AVIF similarly, add: || brands.includes("avif") ); } -// ---------- Core converters ---------- +// ---------------- Resize / Quality options ---------------- +// Headers: +// - x-jpeg-quality: 0..100 (default 100) +// - x-max-dimension: px (max width/height), preserves aspect (default none) +// - x-width: px (optional) +// - x-height: px (optional) +// - x-fit: inside|cover|contain|fill|outside (default inside) +// - x-without-enlargement: true|false (default true) -async function toJpegWithSharp(inputBuffer) { - return sharp(inputBuffer, { +function parseBool(v, fallback = false) { + if (v == null) return fallback; + const s = String(v).toLowerCase().trim(); + if (["1", "true", "yes", "y", "on"].includes(s)) return true; + if (["0", "false", "no", "n", "off"].includes(s)) return false; + return fallback; +} + +function parseResizeOptions(req) { + const quality = clampInt(req.headers["x-jpeg-quality"], 0, 100, 100); + + const width = clampInt(req.headers["x-width"], 1, 20000, 0) || null; + const height = clampInt(req.headers["x-height"], 1, 20000, 0) || null; + + const maxDim = clampInt(req.headers["x-max-dimension"], 1, 20000, 0) || null; + + const fitRaw = String(req.headers["x-fit"] || "inside").toLowerCase(); + const fit = ["inside", "cover", "contain", "fill", "outside"].includes(fitRaw) + ? fitRaw + : "inside"; + + const withoutEnlargement = parseBool(req.headers["x-without-enlargement"], true); + + return { quality, width, height, maxDim, fit, withoutEnlargement }; +} + +function applyResizeAndJpeg(pipeline, opts) { + const { width, height, maxDim, fit, withoutEnlargement, quality } = opts; + + // Resize: explicit width/height wins; else max dimension inside box. + if (width || height) { + pipeline = pipeline.resize({ + width: width ?? undefined, + height: height ?? undefined, + fit, + withoutEnlargement, + }); + } else if (maxDim) { + pipeline = pipeline.resize({ + width: maxDim, + height: maxDim, + fit: "inside", + withoutEnlargement, + }); + } + + // JPEG encode. mozjpeg requires sharp build support; if unavailable, sharp ignores it. + return pipeline.jpeg({ + quality, + chromaSubsampling: "4:4:4", + mozjpeg: true, + }); +} + +// ---------------- Core converters ---------------- + +async function toJpegWithSharp(inputBuffer, opts) { + const pipeline = sharp(inputBuffer, { failOnError: false, limitInputPixels: 200e6, // safety - }) - .rotate() - .jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) - .toBuffer(); + }).rotate(); + + return applyResizeAndJpeg(pipeline, opts).toBuffer(); } function heifDisplayToRGBA(img) { @@ -69,7 +143,6 @@ function heifDisplayToRGBA(img) { const bufObj = { data: rgba, width, height, channels: 4 }; img.display(bufObj, (out) => { - // out can be null on failure if (!out || !out.data) { return reject(new Error("libheif-js display() failed (returned null)")); } @@ -85,7 +158,7 @@ function heifDisplayToRGBA(img) { }); } -async function heicToJpegWithWasm(inputBuffer) { +async function heicToJpegWithWasm(inputBuffer, opts) { if (!libheif?.HeifDecoder) { throw new Error("libheif-js not available (HeifDecoder missing)"); } @@ -101,32 +174,37 @@ async function heicToJpegWithWasm(inputBuffer) { const { width, height, rgba } = await heifDisplayToRGBA(img); // Encode to JPEG with sharp (consistent output settings) - return sharp(Buffer.from(rgba), { raw: { width, height, channels: 4 } }) - .jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) - .toBuffer(); + const pipeline = sharp(Buffer.from(rgba), { raw: { width, height, channels: 4 } }); + return applyResizeAndJpeg(pipeline, opts).toBuffer(); } -async function pdfFirstPageToJpeg(inputBuffer, dpi = 300) { +async function pdfFirstPageToJpeg(inputBuffer, opts, dpi = 300) { const id = randomUUID(); const pdfPath = `/tmp/${id}.pdf`; const outPrefix = `/tmp/${id}`; - await fs.writeFile(pdfPath, inputBuffer); + try { + await fs.writeFile(pdfPath, inputBuffer); - await execFilePromise("pdftoppm", [ - "-jpeg", - "-r", - String(dpi), - "-singlefile", - pdfPath, - outPrefix, - ]); + await execFilePromise("pdftoppm", [ + "-jpeg", + "-r", + String(dpi), + "-singlefile", + pdfPath, + outPrefix, + ]); - const pageJpg = await fs.readFile(`${outPrefix}.jpg`); - return toJpegWithSharp(pageJpg); + const pageJpg = await fs.readFile(`${outPrefix}.jpg`); + return toJpegWithSharp(pageJpg, opts); + } finally { + // best-effort cleanup + await safeUnlink(pdfPath); + await safeUnlink(`${outPrefix}.jpg`); + } } -// ---------- Endpoints ---------- +// ---------------- Endpoints ---------------- // Single JPEG output (images + PDF first page) app.post("/convert", async (req, res) => { @@ -136,22 +214,24 @@ app.post("/convert", async (req, res) => { const input = req.body; if (!input || input.length === 0) return res.status(400).send("Empty body"); + const opts = parseResizeOptions(req); + // PDF: always handle via poppler if (isPdfRequest(req)) { - const jpeg = await pdfFirstPageToJpeg(input, 300); + const jpeg = await pdfFirstPageToJpeg(input, opts, 300); res.setHeader("Content-Type", "image/jpeg"); return res.status(200).send(jpeg); } // Try sharp first (fast path) try { - const jpeg = await toJpegWithSharp(input); + const jpeg = await toJpegWithSharp(input, opts); res.setHeader("Content-Type", "image/jpeg"); return res.status(200).send(jpeg); } catch (sharpErr) { // If it looks like HEIC/HEIF, decode via WASM and encode to JPEG if (looksLikeHeic(input)) { - const jpeg = await heicToJpegWithWasm(input); + const jpeg = await heicToJpegWithWasm(input, opts); res.setHeader("Content-Type", "image/jpeg"); return res.status(200).send(jpeg); } @@ -165,8 +245,15 @@ app.post("/convert", async (req, res) => { } }); -// PDF all pages -> ZIP of JPEG pages +// PDF all pages -> ZIP of JPEG pages (supports resize/quality by re-encoding each page) app.post("/convert/pdf", async (req, res) => { + let archive = null; + + const id = randomUUID(); + const pdfPath = `/tmp/${id}.pdf`; + const outDir = `/tmp/${id}-pages`; + const outPrefix = path.join(outDir, "page"); + try { if (!requireAuth(req, res)) return; @@ -177,17 +264,14 @@ app.post("/convert/pdf", async (req, res) => { return res.status(415).send("This endpoint only accepts PDFs"); } + const opts = parseResizeOptions(req); const dpi = clampInt(req.headers["x-pdf-dpi"], 72, 600, 300); const maxPages = clampInt(req.headers["x-pdf-max-pages"], 1, 200, 50); - const id = randomUUID(); - const pdfPath = `/tmp/${id}.pdf`; - const outDir = `/tmp/${id}-pages`; - const outPrefix = path.join(outDir, "page"); - await fs.mkdir(outDir, { recursive: true }); await fs.writeFile(pdfPath, input); + // Render all pages to JPG via poppler await execFilePromise("pdftoppm", ["-jpeg", "-r", String(dpi), pdfPath, outPrefix]); const files = (await fs.readdir(outDir)) @@ -203,7 +287,20 @@ app.post("/convert/pdf", async (req, res) => { res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Disposition", `attachment; filename="pdf-pages-${id}.zip"`); - const archive = archiver("zip", { zlib: { level: 6 } }); + archive = archiver("zip", { zlib: { level: 6 } }); + + // Abort work if client disconnects + res.on("close", () => { + try { + archive?.abort(); + } catch {} + }); + res.on("aborted", () => { + try { + archive?.abort(); + } catch {} + }); + archive.on("error", (err) => { console.error(err); if (!res.headersSent) res.status(500); @@ -212,16 +309,26 @@ app.post("/convert/pdf", async (req, res) => { archive.pipe(res); + // Re-encode each rendered page with resize + quality controls, then append as buffers for (let i = 0; i < files.length; i++) { const f = files[i]; const n = String(i + 1).padStart(3, "0"); - archive.append(createReadStream(path.join(outDir, f)), { name: `${n}.jpg` }); + const pagePath = path.join(outDir, f); + + const pageBuf = await fs.readFile(pagePath); + const jpegBuf = await toJpegWithSharp(pageBuf, opts); + + archive.append(jpegBuf, { name: `${n}.jpg` }); } await archive.finalize(); } catch (e) { console.error(e); return res.status(500).send(String(e?.stack || e)); + } finally { + // Best-effort cleanup for PDF zip path + await safeUnlink(pdfPath); + await safeRmrf(outDir); } }); @@ -238,7 +345,8 @@ app.listen(port, "0.0.0.0", () => { console.log(`converter listening on 0.0.0.0:${port}`); }); -// ---------- Helpers ---------- +// ---------------- Helpers ---------------- + function execFilePromise(cmd, args) { return new Promise((resolve, reject) => { execFile(cmd, args, (err, _stdout, stderr) => { @@ -258,3 +366,17 @@ function clampInt(value, min, max, fallback) { if (!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, Math.floor(n))); } + +async function safeUnlink(p) { + if (!p) return; + try { + await fs.unlink(p); + } catch {} +} + +async function safeRmrf(p) { + if (!p) return; + try { + await fs.rm(p, { recursive: true, force: true }); + } catch {} +}