Files
PostConvert/server.js
2026-03-13 18:30:25 -07:00

698 lines
23 KiB
JavaScript
Raw 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);
}
}
/* ------------------------------------------------------------------ */
/* 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 Strip Metadata</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: center; justify-content: center; min-height: 100vh; }
.wrap { max-width: 420px; width: 100%; padding: 2rem; }
h1 { font-size: 1.3rem; margin-bottom: 1.5rem; text-align: center; }
.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; }
.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">
<h1>Strip Photo Metadata</h1>
<div class="drop" id="drop">
<p>Drop a photo here or tap to choose</p>
<input type="file" id="file" accept="image/*">
</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); }
async function strip(file) {
const token = getToken();
if (!token) { showAuth(); return; }
status.className = "status";
status.textContent = "Stripping metadata…";
removedDiv.style.display = "none";
removedBody.innerHTML = "";
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) {
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 = "";
}
}
// Download clean image
const byteString = atob(data.image);
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");
const name = (file.name || "photo").replace(/\\.[^.]+$/, "") + "_clean.jpg";
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
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]) strip(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]) strip(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) items.push(["Device", String(exif.Image.HostComputer)]);
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
);
}
});
});
/* ------------------------------------------------------------------ */
/* 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;