diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bc9aef2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# PostConvert + +Image/PDF-to-JPEG conversion microservice. Single-file Node.js/Express app (`server.js`). + +## Tech Stack + +- Node.js 18+ (ES modules), Express +- Sharp (image processing), libheif-js (HEIC/HEIF via WASM) +- pdftoppm from Poppler (PDF rendering), archiver (ZIP output) +- Docker (node:20-bookworm-slim), deployed on Fly.io (sjc region) + +## Endpoints + +- `GET /health` — health check +- `POST /convert` — image or first PDF page → JPEG +- `POST /convert/pdf` — all PDF pages → ZIP of JPEGs + +## Auth + +Bearer token via `CONVERTER_TOKEN` env var on all endpoints. + +## Key Design + +- Single-flight concurrency (1 conversion at a time, 429 if busy) +- Resize/quality via headers: `x-jpeg-quality`, `x-max-dimension`, `x-pdf-dpi`, `x-without-enlargement` +- Pixel limit 200M, bounded /tmp, no stack traces leaked, request timeouts +- Fly.io auto-scaling 0–10 machines, 2 concurrent connection hard limit + +## Environment Variables + +- `PORT` (default 8080) +- `CONVERTER_TOKEN` (required) +- `REQ_TIMEOUT_MS` (default 120s, range 5–600s) +- `REQ_TIMEOUT_PDF_MS` (default 5m, range 10s–30m) + +## Development + +```sh +npm install +node server.js +``` + +Docker: +```sh +docker build -t postconvert . +docker run -e CONVERTER_TOKEN=secret -p 8080:8080 postconvert +``` + +## Fly.io Deployment + +Tokens are in `.env` (gitignored). Load with `source .env` or use `dotenv`. + +Deploy: `FLY_API_TOKEN="$FLY_API_TOKEN" fly deploy` diff --git a/server.js b/server.js index bb8b990..bae867f 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,6 @@ const libheif = libheifModule?.default ?? libheifModule; const app = express(); 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")); /* ------------------------------------------------------------------ */ @@ -275,6 +274,167 @@ async function withConvertSingleFlight(req, res, fn) { /* Routes */ /* ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ */ +/* Web UI */ +/* ------------------------------------------------------------------ */ + +app.get("/", (_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.send(` + + + + +PostConvert – Strip Metadata + + + +
+

Strip Photo Metadata

+
+

Drop a photo here or tap to choose

+ +
+
+
+ + +`); +}); + +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, + }; + + 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; + 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");