Files
PostConvert/server.js
Matthew Jackson baa17b0fc7 Add PDF-to-JPEG support in web UI and /convert/pdf endpoint
Web UI now accepts both images and PDFs. Images go through /strip
as before; PDFs go through new /convert/pdf endpoint which renders
all pages via pdftoppm and returns base64 JPEGs. Also updates
page title and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 10:01:42 -07:00

847 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server.js
import express from "express";
import sharp from "sharp";
import { execFile } from "child_process";
import fs from "fs/promises";
import { randomUUID } from "crypto";
import libheifModule from "libheif-js";
import exifReader from "exif-reader";
const libheif = libheifModule?.default ?? libheifModule;
const app = express();
app.use(express.raw({ type: "*/*", limit: "30mb" }));
app.get("/health", (_req, res) => res.status(200).send("ok"));
/* ------------------------------------------------------------------ */
/* Request context / logging */
/* ------------------------------------------------------------------ */
const DEFAULT_REQ_TIMEOUT_MS = clampInt(
process.env.REQ_TIMEOUT_MS,
5_000,
10 * 60_000,
120_000
);
const DEFAULT_REQ_TIMEOUT_PDF_MS = clampInt(
process.env.REQ_TIMEOUT_PDF_MS,
10_000,
30 * 60_000,
5 * 60_000
);
app.use((req, res, next) => {
const requestId =
String(req.headers["x-request-id"] || "").trim() || randomUUID();
req.requestId = requestId;
res.setHeader("x-request-id", requestId);
const started = Date.now();
req.setTimeout(DEFAULT_REQ_TIMEOUT_MS);
res.setTimeout(DEFAULT_REQ_TIMEOUT_MS);
res.on("finish", () => {
const ms = Date.now() - started;
const len =
Number(req.headers["content-length"] || 0) ||
(req.body?.length ?? 0) ||
0;
console.log(
JSON.stringify({
requestId,
method: req.method,
path: req.originalUrl,
status: res.statusCode,
bytesIn: len,
ms,
})
);
});
next();
});
function isAborted(req, res) {
return Boolean(req.aborted || res.writableEnded || res.destroyed);
}
function sendError(res, status, code, message, requestId) {
if (res.headersSent) {
try {
res.end();
} catch {}
return;
}
res.status(status).json({ error: code, message, requestId });
}
/* ------------------------------------------------------------------ */
/* Auth */
/* ------------------------------------------------------------------ */
function requireAuth(req, res) {
const token = process.env.CONVERTER_TOKEN;
const auth = req.headers.authorization || "";
if (!token || auth !== `Bearer ${token}`) {
sendError(res, 401, "unauthorized", "Unauthorized", req.requestId);
return false;
}
return true;
}
/* ------------------------------------------------------------------ */
/* Type detection */
/* ------------------------------------------------------------------ */
function isPdfRequest(req) {
const ct = String(req.headers["content-type"] || "").toLowerCase();
const fn = String(req.headers["x-filename"] || "").toLowerCase();
return ct.startsWith("application/pdf") || fn.endsWith(".pdf");
}
function looksLikeHeic(buf) {
if (!buf || buf.length < 16) return false;
if (buf.toString("ascii", 4, 8) !== "ftyp") return false;
const brands = buf.toString("ascii", 8, Math.min(buf.length, 256));
return (
brands.includes("heic") ||
brands.includes("heif") ||
brands.includes("heix") ||
brands.includes("hevc") ||
brands.includes("hevx") ||
brands.includes("mif1") ||
brands.includes("msf1")
);
}
async function assertSupportedRaster(input) {
if (looksLikeHeic(input)) return;
try {
await sharp(input, { failOnError: false }).metadata();
} catch {
throw Object.assign(new Error("Unsupported image input"), {
statusCode: 415,
code: "unsupported_media_type",
});
}
}
/* ------------------------------------------------------------------ */
/* Options */
/* ------------------------------------------------------------------ */
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 parseOptions(req) {
return {
quality: clampInt(req.headers["x-jpeg-quality"], 40, 100, 85),
maxDim: clampInt(req.headers["x-max-dimension"], 500, 6000, 2000),
withoutEnlargement: parseBool(req.headers["x-without-enlargement"], true),
pdfDpi: clampInt(req.headers["x-pdf-dpi"], 72, 600, 300),
};
}
/* ------------------------------------------------------------------ */
/* Vision-safe normalization */
/* ------------------------------------------------------------------ */
function normalizeForVision(input, opts) {
const sharpInputOpts = {
failOnError: false,
limitInputPixels: 200e6,
...(opts?.raw ? { raw: opts.raw } : {}),
};
let pipeline = sharp(input, sharpInputOpts).rotate();
// Normalize into sRGB (NOT "rgb"). This avoids:
// vips_colourspace: no known route from 'srgb' to 'rgb'
pipeline = pipeline.toColorspace("srgb");
// If input has alpha, flatten to white so OCR/vision doesn't get weird transparency artifacts.
pipeline = pipeline.flatten({ background: { r: 255, g: 255, b: 255 } });
if (opts.maxDim) {
pipeline = pipeline.resize({
width: opts.maxDim,
height: opts.maxDim,
fit: "inside",
withoutEnlargement: opts.withoutEnlargement,
});
}
return pipeline
.jpeg({
quality: opts.quality,
chromaSubsampling: "4:4:4",
mozjpeg: true,
progressive: true,
})
.withMetadata(false)
.toBuffer();
}
/* ------------------------------------------------------------------ */
/* HEIC via WASM */
/* ------------------------------------------------------------------ */
function heifDisplayToRGBA(img) {
return new Promise((resolve, reject) => {
try {
const w = img.get_width();
const h = img.get_height();
const rgba = new Uint8Array(w * h * 4);
img.display({ data: rgba, width: w, height: h, channels: 4 }, () =>
resolve({ width: w, height: h, rgba })
);
} catch (e) {
reject(e);
}
});
}
async function heicToJpeg(input, opts) {
if (!libheif?.HeifDecoder) throw new Error("libheif-js unavailable");
const dec = new libheif.HeifDecoder();
const imgs = dec.decode(input);
if (!imgs?.length) throw new Error("HEIC decode failed");
const { width, height, rgba } = await heifDisplayToRGBA(imgs[0]);
// Feed Sharp raw pixel metadata so it doesn't treat the buffer as an encoded image.
return normalizeForVision(Buffer.from(rgba), {
...opts,
raw: { width, height, channels: 4 },
});
}
/* ------------------------------------------------------------------ */
/* PDF handling */
/* ------------------------------------------------------------------ */
async function pdfFirstPageToJpeg(input, opts) {
const id = randomUUID();
const pdf = `/tmp/${id}.pdf`;
const out = `/tmp/${id}.jpg`;
try {
await fs.writeFile(pdf, input);
await execFilePromise(
"pdftoppm",
["-jpeg", "-singlefile", "-r", String(opts.pdfDpi), pdf, `/tmp/${id}`],
DEFAULT_REQ_TIMEOUT_PDF_MS
);
const buf = await fs.readFile(out);
return normalizeForVision(buf, opts);
} finally {
await safeUnlink(pdf);
await safeUnlink(out);
}
}
async function pdfAllPagesToJpegs(input, opts) {
const id = randomUUID();
const pdf = `/tmp/${id}.pdf`;
const prefix = `/tmp/${id}`;
try {
await fs.writeFile(pdf, input);
await execFilePromise(
"pdftoppm",
["-jpeg", "-r", String(opts.pdfDpi), pdf, prefix],
DEFAULT_REQ_TIMEOUT_PDF_MS
);
// pdftoppm writes <prefix>-01.jpg, <prefix>-02.jpg, etc.
const dir = await fs.readdir("/tmp");
const pages = dir
.filter((f) => f.startsWith(`${id}-`) && f.endsWith(".jpg"))
.sort();
const results = [];
for (const page of pages) {
const pagePath = `/tmp/${page}`;
const buf = await fs.readFile(pagePath);
const jpeg = await normalizeForVision(buf, opts);
results.push(jpeg);
await safeUnlink(pagePath);
}
return results;
} finally {
await safeUnlink(pdf);
}
}
/* ------------------------------------------------------------------ */
/* Single-flight per machine (ONLY for /convert) */
/* ------------------------------------------------------------------ */
const MAX_CONVERT_INFLIGHT = 1;
let convertInflight = 0;
async function withConvertSingleFlight(req, res, fn) {
if (convertInflight >= MAX_CONVERT_INFLIGHT) {
res.setHeader("Retry-After", "1");
return sendError(res, 429, "busy", "Converter busy; retry shortly", req.requestId);
}
convertInflight++;
try {
return await fn();
} finally {
convertInflight--;
}
}
/* ------------------------------------------------------------------ */
/* Routes */
/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* Web UI */
/* ------------------------------------------------------------------ */
app.get("/", (_req, res) => {
res.setHeader("Content-Type", "text/html");
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostConvert</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, system-ui, sans-serif; background: #111; color: #eee;
display: flex; align-items: flex-start; justify-content: center; min-height: 100vh; padding-top: 4rem; }
.wrap { max-width: 420px; width: 100%; padding: 2rem; }
.drop { border: 2px dashed #444; border-radius: 12px; padding: 3rem 1.5rem; text-align: center;
cursor: pointer; transition: border-color .2s; }
.drop.over { border-color: #4a9eff; }
.drop p { color: #888; font-size: .95rem; }
.drop input { display: none; }
.status { margin-top: 1rem; text-align: center; font-size: .9rem; color: #888; }
.status.error { color: #f66; }
.removed { margin-top: 1.2rem; background: #1a1a1a; border: 1px solid #333; border-radius: 10px;
padding: 1rem 1.2rem; font-size: .85rem; }
.removed h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #666;
margin-bottom: .6rem; }
.removed table { width: 100%; border-collapse: collapse; }
.removed td { padding: .25rem 0; vertical-align: top; }
.removed td:first-child { color: #888; padding-right: .8rem; white-space: nowrap; }
.removed td:last-child { color: #eee; word-break: break-word; }
.removed .warn { color: #f90; }
.removed .sep td { padding: 0; height: 1px; }
.removed .sep hr { border: none; border-top: 1px solid #333; margin: .5rem 0; }
.overlay { position: fixed; inset: 0; background: #111; display: flex;
align-items: center; justify-content: center; z-index: 10; }
.auth { max-width: 340px; width: 100%; padding: 2rem; text-align: center; }
.auth h2 { font-size: 1.1rem; margin-bottom: 1rem; }
.auth input { width: 100%; padding: .6rem .8rem; border: 1px solid #444; border-radius: 8px;
background: #222; color: #eee; font-size: .95rem; margin-bottom: .8rem; }
.auth input:focus { outline: none; border-color: #4a9eff; }
.auth button { width: 100%; padding: .6rem; border: none; border-radius: 8px;
background: #4a9eff; color: #fff; font-size: .95rem; cursor: pointer; }
.auth button:hover { background: #3a8eef; }
.auth .err { color: #f66; font-size: .85rem; margin-top: .5rem; }
</style>
</head>
<body>
<div class="overlay" id="auth-overlay" style="display:none">
<div class="auth">
<h2>Enter access token</h2>
<input type="password" id="token-input" placeholder="Token" autocomplete="off">
<button id="token-submit">Continue</button>
<div class="err" id="token-err"></div>
</div>
</div>
<div class="wrap" id="main" style="display:none">
<div class="drop" id="drop">
<p>Drop a pdf/image here to tap to choose</p>
<input type="file" id="file" accept="image/*,application/pdf,.pdf">
</div>
<div class="status" id="status"></div>
<div class="removed" id="removed" style="display:none">
<h3>Metadata Removed</h3>
<table><tbody id="removed-body"></tbody></table>
</div>
</div>
<script>
(function() {
const KEY = "postconvert_token";
const overlay = document.getElementById("auth-overlay");
const main = document.getElementById("main");
const tokenInput = document.getElementById("token-input");
const tokenSubmit = document.getElementById("token-submit");
const tokenErr = document.getElementById("token-err");
const drop = document.getElementById("drop");
const fileInput = document.getElementById("file");
const status = document.getElementById("status");
const removedDiv = document.getElementById("removed");
const removedBody = document.getElementById("removed-body");
function showApp() { overlay.style.display = "none"; main.style.display = ""; }
function showAuth() { overlay.style.display = ""; main.style.display = "none"; tokenErr.textContent = ""; }
if (localStorage.getItem(KEY)) {
showApp();
} else {
showAuth();
}
tokenSubmit.addEventListener("click", () => {
const t = tokenInput.value.trim();
if (!t) { tokenErr.textContent = "Token required"; return; }
localStorage.setItem(KEY, t);
showApp();
});
tokenInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") tokenSubmit.click();
});
function getToken() { return localStorage.getItem(KEY); }
function isPdf(file) {
return file.type === "application/pdf" || (file.name || "").toLowerCase().endsWith(".pdf");
}
function downloadBase64Jpeg(b64, name) {
const byteString = atob(b64);
const bytes = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) bytes[i] = byteString.charCodeAt(i);
const blob = new Blob([bytes], { type: "image/jpeg" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
async function handleFile(file) {
const token = getToken();
if (!token) { showAuth(); return; }
status.className = "status";
removedDiv.style.display = "none";
removedBody.innerHTML = "";
if (isPdf(file)) {
await convertPdf(file, token);
} else {
await stripImage(file, token);
}
}
async function convertPdf(file, token) {
status.textContent = "Converting PDF…";
try {
const res = await fetch("/convert/pdf", {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/pdf",
},
body: file,
});
if (res.status === 401) {
localStorage.removeItem(KEY);
showAuth();
tokenErr.textContent = "Invalid token. Please try again.";
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || "PDF conversion failed");
}
const data = await res.json();
const baseName = (file.name || "document").replace(/\\.[^.]+$/, "");
for (let i = 0; i < data.pages.length; i++) {
const name = data.pages.length === 1
? baseName + ".jpg"
: baseName + "_page" + (i + 1) + ".jpg";
downloadBase64Jpeg(data.pages[i], name);
}
status.className = "status";
status.textContent = "Done — saved " + data.pages.length + " page" + (data.pages.length === 1 ? "" : "s");
} catch (e) {
status.className = "status error";
status.textContent = e.message;
}
}
async function stripImage(file, token) {
status.textContent = "Stripping metadata…";
try {
const res = await fetch("/strip", {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": file.type || "image/jpeg",
"Accept": "application/json",
},
body: file,
});
if (res.status === 401) {
localStorage.removeItem(KEY);
showAuth();
tokenErr.textContent = "Invalid token. Please try again.";
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || "Strip failed");
}
const data = await res.json();
// Show removed metadata
const meta = data.metadata;
if (meta) {
const keys = Object.keys(meta);
if (keys.length > 0) {
keys.forEach(function(k) {
if (k === "Settings") {
const sep = document.createElement("tr");
sep.className = "sep";
const sc = document.createElement("td");
sc.colSpan = 2;
sc.innerHTML = "<hr>";
sep.appendChild(sc);
removedBody.appendChild(sep);
}
const tr = document.createElement("tr");
const td1 = document.createElement("td");
td1.textContent = k;
const td2 = document.createElement("td");
td2.textContent = meta[k];
if (k === "GPS Location" || k === "GPS Coordinates") td2.className = "warn";
tr.appendChild(td1);
tr.appendChild(td2);
removedBody.appendChild(tr);
});
removedDiv.style.display = "";
}
}
const name = (file.name || "photo").replace(/\\.[^.]+$/, "") + "_clean.jpg";
downloadBase64Jpeg(data.image, name);
status.className = "status";
status.textContent = "Done — saved " + name;
} catch (e) {
status.className = "status error";
status.textContent = e.message;
}
}
drop.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); });
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
drop.addEventListener("dragleave", () => drop.classList.remove("over"));
drop.addEventListener("drop", (e) => {
e.preventDefault();
drop.classList.remove("over");
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
});
})();
</script>
</body>
</html>`);
});
async function extractMetadata(buf) {
const items = [];
try {
const meta = await sharp(buf, { failOnError: false }).metadata();
if (meta.exif) {
try {
const exif = exifReader(meta.exif);
// Scary / identifiable stuff first
if (exif.GPSInfo?.GPSLatitude && exif.GPSInfo?.GPSLongitude) {
const lat = exif.GPSInfo.GPSLatitude;
const lng = exif.GPSInfo.GPSLongitude;
const latRef = exif.GPSInfo.GPSLatitudeRef || "N";
const lngRef = exif.GPSInfo.GPSLongitudeRef || "E";
const latVal = typeof lat === "number" ? lat : (lat[0] + lat[1] / 60 + lat[2] / 3600);
const lngVal = typeof lng === "number" ? lng : (lng[0] + lng[1] / 60 + lng[2] / 3600);
const latFinal = latRef === "S" ? -latVal : latVal;
const lngFinal = lngRef === "W" ? -lngVal : lngVal;
let loc = `${latFinal.toFixed(4)}, ${lngFinal.toFixed(4)}`;
try {
const geoRes = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${latFinal}&lon=${lngFinal}&format=json&zoom=14`, {
headers: { "User-Agent": "PostConvert/1.0" },
signal: AbortSignal.timeout(3000),
});
if (geoRes.ok) {
const geo = await geoRes.json();
if (geo.display_name) loc = geo.display_name;
}
} catch {}
items.push(["GPS Location", loc]);
items.push(["GPS Coordinates", `${latFinal.toFixed(6)}, ${lngFinal.toFixed(6)}`]);
}
if (exif.Photo?.DateTimeOriginal) {
const d = exif.Photo.DateTimeOriginal;
items.push(["Date Taken", d instanceof Date ? d.toISOString().slice(0, 19).replace("T", " ") : String(d)]);
}
if (exif.Image?.Make && exif.Image?.Model) {
items.push(["Camera", `${exif.Image.Make} ${exif.Image.Model}`]);
} else if (exif.Image?.Model) {
items.push(["Camera", String(exif.Image.Model)]);
}
if (exif.Image?.HostComputer) {
let device = String(exif.Image.HostComputer);
if (exif.Image?.Software) device += `, iOS ${exif.Image.Software}`;
items.push(["Device", device]);
}
if (exif.Photo?.LensModel) items.push(["Lens", String(exif.Photo.LensModel)]);
if (exif.Photo?.ImageUniqueID) items.push(["Unique ID", String(exif.Photo.ImageUniqueID)]);
// Camera settings
const settings = [];
if (exif.Photo?.FNumber) settings.push(`f/${Math.round(exif.Photo.FNumber * 100) / 100}`);
if (exif.Photo?.ExposureTime) settings.push(`1/${Math.round(1 / exif.Photo.ExposureTime)}s`);
if (exif.Photo?.ISOSpeedRatings) settings.push(`ISO ${exif.Photo.ISOSpeedRatings}`);
if (exif.Photo?.FocalLength) settings.push(`${Math.round(exif.Photo.FocalLength * 100) / 100}mm`);
if (settings.length) items.push(["Settings", settings.join(" ")]);
} catch {}
}
// Technical details at the bottom
if (meta.width && meta.height) items.push(["Dimensions", `${meta.width} × ${meta.height}`]);
if (meta.hasProfile && meta.icc) items.push(["ICC Profile", `${(meta.icc.length / 1024).toFixed(1)} KB`]);
if (meta.exif) items.push(["EXIF Data", `${(meta.exif.length / 1024).toFixed(1)} KB`]);
if (meta.xmp) items.push(["XMP Data", `${(meta.xmp.length / 1024).toFixed(1)} KB`]);
if (meta.iptc) items.push(["IPTC Data", `${(meta.iptc.length / 1024).toFixed(1)} KB`]);
} catch {}
// Convert to ordered object
const removed = {};
for (const [k, v] of items) removed[k] = v;
return removed;
}
app.post("/strip", async (req, res) => {
res.setHeader("Connection", "close");
return withConvertSingleFlight(req, res, async () => {
try {
if (!requireAuth(req, res)) return;
if (!req.body?.length) {
return sendError(res, 400, "empty_body", "Empty body", req.requestId);
}
const opts = {
quality: clampInt(req.headers["x-jpeg-quality"], 40, 100, 92),
maxDim: 0,
withoutEnlargement: true,
};
// Extract metadata before stripping
const removed = await extractMetadata(req.body);
let jpeg;
if (looksLikeHeic(req.body)) {
if (isAborted(req, res)) return;
jpeg = await heicToJpeg(req.body, opts);
} else {
await assertSupportedRaster(req.body);
if (isAborted(req, res)) return;
jpeg = await normalizeForVision(req.body, opts);
}
if (isAborted(req, res)) return;
// If client wants JSON, return image + metadata together
const wantsJson = String(req.headers["accept"] || "").includes("application/json");
if (wantsJson) {
res.setHeader("Content-Type", "application/json");
return res.json({
image: jpeg.toString("base64"),
metadata: removed,
});
}
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg);
} catch (e) {
const status = e?.statusCode || 500;
const code = e?.code || "strip_failed";
console.error(JSON.stringify({ requestId: req.requestId, err: String(e?.stack || e) }));
return sendError(
res,
status,
code,
status === 415 ? "Unsupported media type" : "Metadata strip failed",
req.requestId
);
}
});
});
app.post("/convert", async (req, res) => {
res.setHeader("Connection", "close");
return withConvertSingleFlight(req, res, async () => {
try {
if (!requireAuth(req, res)) return;
if (!req.body?.length) {
return sendError(res, 400, "empty_body", "Empty body", req.requestId);
}
const opts = parseOptions(req);
if (isPdfRequest(req)) {
if (isAborted(req, res)) return;
const jpeg = await pdfFirstPageToJpeg(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg);
}
if (looksLikeHeic(req.body)) {
if (isAborted(req, res)) return;
const jpeg = await heicToJpeg(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg);
}
await assertSupportedRaster(req.body);
if (isAborted(req, res)) return;
const jpeg = await normalizeForVision(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "image/jpeg");
return res.send(jpeg);
} catch (e) {
const status = e?.statusCode || 500;
const code = e?.code || "conversion_failed";
console.error(JSON.stringify({ requestId: req.requestId, err: String(e?.stack || e) }));
return sendError(
res,
status,
code,
status === 415 ? "Unsupported media type" : "Conversion failed",
req.requestId
);
}
});
});
app.post("/convert/pdf", async (req, res) => {
res.setHeader("Connection", "close");
req.setTimeout(DEFAULT_REQ_TIMEOUT_PDF_MS);
res.setTimeout(DEFAULT_REQ_TIMEOUT_PDF_MS);
return withConvertSingleFlight(req, res, async () => {
try {
if (!requireAuth(req, res)) return;
if (!req.body?.length) {
return sendError(res, 400, "empty_body", "Empty body", req.requestId);
}
if (!isPdfRequest(req)) {
return sendError(res, 415, "unsupported_media_type", "Expected a PDF", req.requestId);
}
const opts = parseOptions(req);
if (isAborted(req, res)) return;
const jpegs = await pdfAllPagesToJpegs(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "application/json");
return res.json({
pages: jpegs.map((buf) => buf.toString("base64")),
});
} catch (e) {
const status = e?.statusCode || 500;
const code = e?.code || "conversion_failed";
console.error(JSON.stringify({ requestId: req.requestId, err: String(e?.stack || e) }));
return sendError(
res,
status,
code,
status === 415 ? "Unsupported media type" : "PDF conversion failed",
req.requestId
);
}
});
});
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function execFilePromise(cmd, args, timeoutMs) {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout: timeoutMs }, (err, _stdout, stderr) => {
if (err) {
if (err.code === "ENOENT") return reject(new Error(`Missing dependency: ${cmd}`));
if (err.killed || err.signal === "SIGTERM") {
return reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
}
return reject(new Error(stderr || String(err)));
}
resolve();
});
});
}
function clampInt(v, min, max, fallback) {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, Math.floor(n)));
}
async function safeUnlink(p) {
try { await fs.unlink(p); } catch {}
}
/* ------------------------------------------------------------------ */
const port = Number(process.env.PORT) || 8080;
const server = app.listen(port, "0.0.0.0", () =>
console.log(`converter listening on :${port}`)
);
server.keepAliveTimeout = 5_000;
server.headersTimeout = 10_000;