Update server.js
This commit is contained in:
120
server.js
120
server.js
@@ -6,9 +6,9 @@ import { createReadStream } from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
|
import libheif from "libheif-js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.raw({ type: "*/*", limit: "30mb" }));
|
app.use(express.raw({ type: "*/*", limit: "30mb" }));
|
||||||
|
|
||||||
app.get("/", (_req, res) => res.status(200).send("postconvert: ok"));
|
app.get("/", (_req, res) => res.status(200).send("postconvert: ok"));
|
||||||
@@ -30,65 +30,49 @@ function isPdfRequest(req) {
|
|||||||
return contentType === "application/pdf" || filename.endsWith(".pdf");
|
return contentType === "application/pdf" || filename.endsWith(".pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeHeic(buf) {
|
||||||
|
// ISO-BMFF "ftyp" at offset 4 is typical for HEIC/HEIF
|
||||||
|
if (!buf || buf.length < 32) return false;
|
||||||
|
if (buf.toString("ascii", 4, 8) !== "ftyp") return false;
|
||||||
|
const brands = buf.toString("ascii", 8, 32);
|
||||||
|
return (
|
||||||
|
brands.includes("heic") ||
|
||||||
|
brands.includes("heif") ||
|
||||||
|
brands.includes("mif1") ||
|
||||||
|
brands.includes("msf1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Core converters ----------
|
// ---------- Core converters ----------
|
||||||
|
|
||||||
async function toJpegWithSharp(inputBuffer) {
|
async function toJpegWithSharp(inputBuffer) {
|
||||||
return sharp(inputBuffer, {
|
return sharp(inputBuffer, { failOnError: false, limitInputPixels: 200e6 })
|
||||||
failOnError: false,
|
|
||||||
// Safety: avoid decompression bombs
|
|
||||||
limitInputPixels: 200e6,
|
|
||||||
})
|
|
||||||
.rotate()
|
.rotate()
|
||||||
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" })
|
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toJpegWithFfmpeg(inputBuffer) {
|
async function heicToJpegWithWasm(inputBuffer) {
|
||||||
const id = randomUUID();
|
// Decode HEIC/HEIF to raw RGBA using libheif-js (WASM)
|
||||||
const inPath = `/tmp/${id}.bin`; // ffmpeg probes; extension not required
|
const decoder = new libheif.HeifDecoder();
|
||||||
const outPath = `/tmp/${id}.jpg`;
|
const images = decoder.decode(inputBuffer);
|
||||||
|
|
||||||
await fs.writeFile(inPath, inputBuffer);
|
if (!images || images.length === 0) {
|
||||||
|
throw new Error("WASM HEIF decode produced no images");
|
||||||
await execFilePromise("ffmpeg", [
|
|
||||||
"-y",
|
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-i",
|
|
||||||
inPath,
|
|
||||||
"-frames:v",
|
|
||||||
"1",
|
|
||||||
"-q:v",
|
|
||||||
"1", // high-quality JPEG from ffmpeg
|
|
||||||
outPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const jpg = await fs.readFile(outPath);
|
|
||||||
|
|
||||||
// Normalize output with sharp to enforce consistent settings (quality 100, 4:4:4)
|
|
||||||
return toJpegWithSharp(jpg);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toJpegWithImagemagick(inputBuffer) {
|
|
||||||
const id = randomUUID();
|
|
||||||
const inPath = `/tmp/${id}.bin`;
|
|
||||||
const outPath = `/tmp/${id}.jpg`;
|
|
||||||
|
|
||||||
await fs.writeFile(inPath, inputBuffer);
|
|
||||||
|
|
||||||
// ImageMagick 7 uses `magick`; IM6 uses `convert`
|
|
||||||
// We'll try `magick` first, then fallback to `convert`.
|
|
||||||
const args = [inPath, "-auto-orient", "-quality", "100", outPath];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await execFilePromise("magick", args);
|
|
||||||
} catch (e) {
|
|
||||||
// If magick isn't installed, IM6 usually provides `convert`
|
|
||||||
await execFilePromise("convert", args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jpg = await fs.readFile(outPath);
|
const img = images[0];
|
||||||
return toJpegWithSharp(jpg);
|
|
||||||
|
// libheif-js exposes width/height; decode to RGBA
|
||||||
|
const width = img.get_width();
|
||||||
|
const height = img.get_height();
|
||||||
|
|
||||||
|
const rgba = img.display({ data: null, width, height, channels: 4 });
|
||||||
|
|
||||||
|
// Encode to JPEG with sharp (consistent settings)
|
||||||
|
return sharp(Buffer.from(rgba.data), { raw: { width, height, channels: 4 } })
|
||||||
|
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" })
|
||||||
|
.toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pdfFirstPageToJpeg(inputBuffer, dpi = 300) {
|
async function pdfFirstPageToJpeg(inputBuffer, dpi = 300) {
|
||||||
@@ -113,7 +97,6 @@ async function pdfFirstPageToJpeg(inputBuffer, dpi = 300) {
|
|||||||
|
|
||||||
// ---------- Endpoints ----------
|
// ---------- Endpoints ----------
|
||||||
|
|
||||||
// Single JPEG output (images + PDF first page)
|
|
||||||
app.post("/convert", async (req, res) => {
|
app.post("/convert", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!requireAuth(req, res)) return;
|
if (!requireAuth(req, res)) return;
|
||||||
@@ -128,21 +111,20 @@ app.post("/convert", async (req, res) => {
|
|||||||
return res.status(200).send(jpeg);
|
return res.status(200).send(jpeg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-PDF: sharp -> ffmpeg -> imagemagick
|
// Try sharp first
|
||||||
try {
|
try {
|
||||||
const jpeg = await toJpegWithSharp(input);
|
const jpeg = await toJpegWithSharp(input);
|
||||||
res.setHeader("Content-Type", "image/jpeg");
|
res.setHeader("Content-Type", "image/jpeg");
|
||||||
return res.status(200).send(jpeg);
|
return res.status(200).send(jpeg);
|
||||||
} catch (e1) {
|
} catch (sharpErr) {
|
||||||
try {
|
// If it looks like HEIC/HEIF, use WASM decoder (bulletproof)
|
||||||
const jpeg = await toJpegWithFfmpeg(input);
|
if (looksLikeHeic(input)) {
|
||||||
res.setHeader("Content-Type", "image/jpeg");
|
const jpeg = await heicToJpegWithWasm(input);
|
||||||
return res.status(200).send(jpeg);
|
|
||||||
} catch (e2) {
|
|
||||||
const jpeg = await toJpegWithImagemagick(input);
|
|
||||||
res.setHeader("Content-Type", "image/jpeg");
|
res.setHeader("Content-Type", "image/jpeg");
|
||||||
return res.status(200).send(jpeg);
|
return res.status(200).send(jpeg);
|
||||||
}
|
}
|
||||||
|
// Otherwise bubble up (or add more fallbacks later if desired)
|
||||||
|
throw sharpErr;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -159,12 +141,9 @@ app.post("/convert/pdf", async (req, res) => {
|
|||||||
if (!input || input.length === 0) return res.status(400).send("Empty body");
|
if (!input || input.length === 0) return res.status(400).send("Empty body");
|
||||||
|
|
||||||
if (!isPdfRequest(req)) {
|
if (!isPdfRequest(req)) {
|
||||||
return res
|
return res.status(415).send("This endpoint only accepts PDFs");
|
||||||
.status(415)
|
|
||||||
.send("This endpoint only accepts PDFs (Content-Type: application/pdf)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety limits
|
|
||||||
const dpi = clampInt(req.headers["x-pdf-dpi"], 72, 600, 300);
|
const dpi = clampInt(req.headers["x-pdf-dpi"], 72, 600, 300);
|
||||||
const maxPages = clampInt(req.headers["x-pdf-max-pages"], 1, 200, 50);
|
const maxPages = clampInt(req.headers["x-pdf-max-pages"], 1, 200, 50);
|
||||||
|
|
||||||
@@ -176,13 +155,7 @@ app.post("/convert/pdf", async (req, res) => {
|
|||||||
await fs.mkdir(outDir, { recursive: true });
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
await fs.writeFile(pdfPath, input);
|
await fs.writeFile(pdfPath, input);
|
||||||
|
|
||||||
await execFilePromise("pdftoppm", [
|
await execFilePromise("pdftoppm", ["-jpeg", "-r", String(dpi), pdfPath, outPrefix]);
|
||||||
"-jpeg",
|
|
||||||
"-r",
|
|
||||||
String(dpi),
|
|
||||||
pdfPath,
|
|
||||||
outPrefix,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const files = (await fs.readdir(outDir))
|
const files = (await fs.readdir(outDir))
|
||||||
.filter((f) => /^page-\d+\.jpg$/i.test(f))
|
.filter((f) => /^page-\d+\.jpg$/i.test(f))
|
||||||
@@ -190,17 +163,12 @@ app.post("/convert/pdf", async (req, res) => {
|
|||||||
|
|
||||||
if (files.length === 0) return res.status(500).send("PDF render produced no pages");
|
if (files.length === 0) return res.status(500).send("PDF render produced no pages");
|
||||||
if (files.length > maxPages) {
|
if (files.length > maxPages) {
|
||||||
return res
|
return res.status(413).send(`PDF has ${files.length} pages; exceeds maxPages=${maxPages}`);
|
||||||
.status(413)
|
|
||||||
.send(`PDF has ${files.length} pages; exceeds maxPages=${maxPages}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200);
|
res.status(200);
|
||||||
res.setHeader("Content-Type", "application/zip");
|
res.setHeader("Content-Type", "application/zip");
|
||||||
res.setHeader(
|
res.setHeader("Content-Disposition", `attachment; filename="pdf-pages-${id}.zip"`);
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="pdf-pages-${id}.zip"`
|
|
||||||
);
|
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 6 } });
|
const archive = archiver("zip", { zlib: { level: 6 } });
|
||||||
archive.on("error", (err) => {
|
archive.on("error", (err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user