diff --git a/server.js b/server.js index 397ec57..4059e80 100644 --- a/server.js +++ b/server.js @@ -1,43 +1,159 @@ import express from "express"; import sharp from "sharp"; +import { execFile } from "child_process"; +import fs from "fs/promises"; +import { createReadStream } from "fs"; +import path from "path"; +import { randomUUID } from "crypto"; +import archiver from "archiver"; const app = express(); -// Raw binary uploads (HEIC/JPEG/etc) app.use(express.raw({ type: "*/*", limit: "30mb" })); -// Friendly "is it up" endpoints app.get("/", (_req, res) => res.status(200).send("postconvert: ok")); app.get("/health", (_req, res) => res.status(200).send("ok")); +function requireAuth(req, res) { + const token = process.env.CONVERTER_TOKEN; + const auth = req.headers.authorization || ""; + if (!token || auth !== `Bearer ${token}`) { + res.status(401).send("Unauthorized"); + return false; + } + return true; +} + +// --- Endpoint 1: images (and PDF first page) -> single JPEG --- app.post("/convert", async (req, res) => { try { - const token = process.env.CONVERTER_TOKEN; - const auth = req.headers.authorization || ""; + if (!requireAuth(req, res)) return; - if (!token || auth !== `Bearer ${token}`) { - return res.status(401).send("Unauthorized"); + const input = req.body; + if (!input || input.length === 0) return res.status(400).send("Empty body"); + + const contentType = (req.headers["content-type"] || "").toLowerCase(); + const filename = (req.headers["x-filename"] || "").toLowerCase(); + const isPdf = contentType === "application/pdf" || filename.endsWith(".pdf"); + + let imageBuffer = input; + + if (isPdf) { + // PDF -> first page JPEG at 300 DPI + const id = randomUUID(); + const pdfPath = `/tmp/${id}.pdf`; + const outPrefix = `/tmp/${id}`; // output will be `${outPrefix}.jpg` + + await fs.writeFile(pdfPath, input); + + await execFilePromise("pdftoppm", [ + "-jpeg", + "-r", + "300", + "-singlefile", + pdfPath, + outPrefix, + ]); + + imageBuffer = await fs.readFile(`${outPrefix}.jpg`); } - const input = req.body; // Buffer - if (!input || input.length === 0) { - return res.status(400).send("Empty body"); - } - - const jpeg = await sharp(input, { failOnError: false }) - .rotate() // respect EXIF orientation - // "Best quality" JPEG: max quality + 4:4:4 chroma (less color smearing) + const jpeg = await sharp(imageBuffer, { failOnError: false }) + .rotate() .jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) .toBuffer(); res.setHeader("Content-Type", "image/jpeg"); return res.status(200).send(jpeg); } catch (e) { + console.error(e); return res.status(500).send(String(e?.stack || e)); } }); -// Express will throw for oversized bodies; return a clean 413 +// --- Endpoint 2: PDF all pages -> ZIP of JPEG pages --- +app.post("/convert/pdf", async (req, res) => { + try { + if (!requireAuth(req, res)) return; + + const input = req.body; + if (!input || input.length === 0) return res.status(400).send("Empty body"); + + const contentType = (req.headers["content-type"] || "").toLowerCase(); + const filename = (req.headers["x-filename"] || "").toLowerCase(); + const isPdf = contentType === "application/pdf" || filename.endsWith(".pdf"); + + if (!isPdf) { + return res.status(415).send("This endpoint only accepts PDFs (Content-Type: application/pdf)"); + } + + // Safety limits (tweak as you like) + const dpi = clampInt(req.headers["x-pdf-dpi"], 72, 600, 300); + const maxPages = clampInt(req.headers["x-pdf-max-pages"], 1, 50, 20); + + const id = randomUUID(); + const pdfPath = `/tmp/${id}.pdf`; + const outDir = `/tmp/${id}-pages`; + const outPrefix = path.join(outDir, "page"); // produces page-1.jpg, page-2.jpg, ... + + await fs.mkdir(outDir, { recursive: true }); + await fs.writeFile(pdfPath, input); + + // Render all pages to JPEG files + await execFilePromise("pdftoppm", [ + "-jpeg", + "-r", + String(dpi), + pdfPath, + outPrefix, + ]); + + // Collect rendered pages + const files = (await fs.readdir(outDir)) + .filter((f) => /^page-\d+\.jpg$/i.test(f)) + .sort((a, b) => pageNum(a) - pageNum(b)); + + if (files.length === 0) { + return res.status(500).send("PDF render produced no pages"); + } + if (files.length > maxPages) { + return res + .status(413) + .send(`PDF has ${files.length} pages; exceeds maxPages=${maxPages}`); + } + + // Stream a ZIP back + res.status(200); + res.setHeader("Content-Type", "application/zip"); + res.setHeader( + "Content-Disposition", + `attachment; filename="pdf-pages-${id}.zip"` + ); + + const archive = archiver("zip", { zlib: { level: 6 } }); + archive.on("error", (err) => { + console.error(err); + if (!res.headersSent) res.status(500); + res.end(); + }); + + archive.pipe(res); + + // Add each page file to the zip as 001.jpg, 002.jpg, ... + 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` }); + } + + await archive.finalize(); + } catch (e) { + console.error(e); + return res.status(500).send(String(e?.stack || e)); + } +}); + +// Oversize handler app.use((err, _req, res, next) => { if (err?.type === "entity.too.large") { return res.status(413).send("Payload too large (max 30mb)"); @@ -46,8 +162,3 @@ app.use((err, _req, res, next) => { }); const port = Number(process.env.PORT) || 8080; - -// IMPORTANT for Fly.io: bind to 0.0.0.0 (not localhost) -app.listen(port, "0.0.0.0", () => { - console.log(`converter listening on ${port}`); -});