fix(pdf): pdf-to-image presets no longer 404 on 2+ files (#643)

Upload two PDFs to pdf-to-jpg and it answered `Tool "pdf-to-jpg" not
found`. pdf-to-jpg, pdf-to-png and pdf-to-tiff share
registerPdfToImageRoute, which registered a single-file endpoint and
nothing else, so the shared preset settings component's 2+-file
submission fell through to the generic `:section/:toolId/batch` route,
whose registry lookup misses every tool outside
createToolRoute/registerToolProcessFn.

Mirror of #627, different fix. image-to-pdf is many-to-one, so #633 sent
every file in one request. This direction is one-to-many: separate PDFs
want separate conversions, which is what /batch is for. The route now
serves its own /batch, the shape svg-to-raster already uses, and the
literal path beats the generic parametric one.

One PDF fans out to many page images, so a per-file result is a ZIP, same
as the single-file route. A batch returns a ZIP of per-document ZIPs in
upload order, keyed by X-File-Results so each result pairs with the file
it came from. A document that is unreadable, locked, empty, short of the
requested page range, or carrying no pages at all fails alone; 422 with a
reason per file when none survive.

That literal path also shadows the generic route's requireToolAccess
call, which would have turned a 403 into a converted ZIP for roles
without tools:use. All four endpoints in this file now gate.

Four ways the batch path could have reported something untrue are closed
with it: a storage fault blamed on the document (statusCode-carrying
errors now reach the error handler, the rest are logged before being
reduced to a generic message), per-file reasons stranded in a field
parseApiError never reads, a zero-byte upload dropped so that later
results landed on the wrong file, and a mid-stream failure ended cleanly
enough to pass for success (the socket is destroyed instead).

Page rendering and ZIP assembly are shared helpers now, createUniqueNamer
moves to lib/filename.ts next to its two existing copies, and
tool-route-drift fails if any batch-dispatched preset loses its /batch
route. Follow-up for the same defects in the sibling custom routes: #645.

Fixes #632
This commit is contained in:
SnapOtter
2026-07-26 01:35:42 +08:00
committed by GitHub
parent d690a6e26d
commit a7137958a1
7 changed files with 917 additions and 99 deletions
+25
View File
@@ -122,3 +122,28 @@ export function sanitizeFilename(raw: string): string {
return name;
}
/**
* Give each output a name unique within one batch response.
*
* Two uploads may share a filename, and clients key results by name
* (the X-File-Results map), so a collision would drop a result rather than
* show it. Collisions get a `_1`, `_2`, ... suffix before the extension.
*/
export function createUniqueNamer(): (name: string) => string {
const used = new Set<string>();
return (name) => {
if (!used.has(name)) {
used.add(name);
return name;
}
const dotIdx = name.lastIndexOf(".");
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let counter = 1;
while (used.has(`${base}_${counter}${ext}`)) counter++;
const candidate = `${base}_${counter}${ext}`;
used.add(candidate);
return candidate;
};
}
+2 -19
View File
@@ -26,7 +26,7 @@ import { getSecurityHeaders } from "../lib/csp.js";
import { formatZodErrors } from "../lib/errors.js";
import { getFirstMissingBundleForTool } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { createUniqueNamer, sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { deleteObject, getObjectStream, putObject } from "../lib/object-storage.js";
@@ -551,24 +551,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
}
// Deduplicate output filenames and build X-File-Results header
const usedNames = new Set<string>();
function getUniqueName(name: string): string {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIdx = name.lastIndexOf(".");
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let counter = 1;
let candidate = `${base}_${counter}${ext}`;
while (usedNames.has(candidate)) {
counter++;
candidate = `${base}_${counter}${ext}`;
}
usedNames.add(candidate);
return candidate;
}
const getUniqueName = createUniqueNamer();
const fileResultsMap: Record<string, string> = {};
for (const entry of successEntries) {
+343 -59
View File
@@ -6,10 +6,19 @@ import * as mupdf from "mupdf";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { getSecurityHeaders } from "../../lib/csp.js";
import { formatZodErrors } from "../../lib/errors.js";
import { createUniqueNamer, sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import {
deletePrefix,
getObjectBuffer,
getObjectStream,
putObject,
} from "../../lib/object-storage.js";
import { requireToolAccess } from "../../permissions.js";
import { updateJobProgress } from "../progress.js";
// ── Settings schema ──────────────────────────────────────────────
const settingsSchema = z.object({
@@ -179,6 +188,102 @@ function isPdfBuffer(buf: Buffer): boolean {
return buf.subarray(0, 1024).includes("%PDF-");
}
/**
* A caller-fixable problem with one document: it is locked, or the requested
* page range does not fit it. Separated from render failures so the
* single-file route answers 400 rather than 422, and so the batch route can
* report the reason against the file that caused it.
*/
class PdfInputError extends Error {}
interface RenderedPages {
pages: Array<{ page: number; downloadUrl: string; size: number }>;
filenames: string[];
totalPages: number;
selectedPages: number[];
}
/**
* Render the settings-selected pages of one PDF into `outputs/<jobId>/`.
* Each page is written to storage as soon as it is encoded, so peak memory
* stays at a single page no matter how long the document is.
*/
async function renderPdfPages(
fileBuffer: Buffer,
settings: z.infer<typeof settingsSchema>,
jobId: string,
): Promise<RenderedPages> {
let doc: mupdf.Document | null = null;
try {
doc = mupdf.Document.openDocument(fileBuffer, "application/pdf");
if (doc.needsPassword()) {
throw new PdfInputError("Password-protected PDFs are not supported");
}
const totalPages = doc.countPages();
let selectedPages: number[];
try {
selectedPages = parsePageRange(settings.pages, totalPages);
} catch (err) {
throw new PdfInputError(err instanceof Error ? err.message : "Invalid page range");
}
// mupdf repairs and opens a document whose page tree is empty, which would
// otherwise render nothing and hand back a valid but empty ZIP labelled a
// success.
if (selectedPages.length === 0) {
throw new PdfInputError("This PDF has no pages to convert");
}
const ext = FORMAT_EXT[settings.format] ?? ".png";
const pages: RenderedPages["pages"] = [];
const filenames: string[] = [];
for (const pageNum of selectedPages) {
const pngBytes = renderPage(doc, pageNum - 1, settings.dpi);
const imageBuffer = await convertWithSharp(
pngBytes,
settings.format,
settings.quality,
settings.colorMode,
);
const filename = `page-${pageNum}${ext}`;
await putObject(`outputs/${jobId}/${filename}`, imageBuffer);
filenames.push(filename);
pages.push({
page: pageNum,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
size: imageBuffer.length,
});
}
return { pages, filenames, totalPages, selectedPages };
} finally {
doc?.destroy();
}
}
/**
* Zip one job's already-stored page images, reading a single entry at a time.
* The finished archive is materialized in memory, so peak cost is one whole
* document's worth of encoded pages.
*/
async function buildPagesZip(jobId: string, filenames: string[]): Promise<Buffer> {
const archive = archiver("zip", { zlib: { level: 5 } });
const chunks: Buffer[] = [];
archive.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<void>((resolve, reject) => {
archive.on("end", resolve);
archive.on("error", reject);
});
for (const filename of filenames) {
archive.append(await getObjectBuffer(`outputs/${jobId}/${filename}`), { name: filename });
}
await archive.finalize();
await done;
return Buffer.concat(chunks);
}
// ── Route registration ───────────────────────────────────────────
export function registerPdfToImageRoute(
app: FastifyInstance,
@@ -188,6 +293,8 @@ export function registerPdfToImageRoute(
// ── Info endpoint ────────────────────────────────────────────
app.post(`${basePath}/info`, async (request, reply) => {
if (!(await requireToolAccess(request, reply, opts.toolId))) return;
let fileBuffer: Buffer | null = null;
try {
const result = await readPdfFromParts(request);
@@ -230,6 +337,8 @@ export function registerPdfToImageRoute(
// ── Preview endpoint (thumbnails) ─────────────────────────────
app.post(`${basePath}/preview`, async (request, reply) => {
if (!(await requireToolAccess(request, reply, opts.toolId))) return;
let fileBuffer: Buffer | null = null;
try {
const result = await readPdfFromParts(request);
@@ -293,8 +402,231 @@ export function registerPdfToImageRoute(
}
});
// ── Batch endpoint ───────────────────────────────────────────
//
// This literal path takes priority over the generic
// `/api/v1/tools/:section/:toolId/batch` route, which only knows tools in
// the createToolRoute/registerToolProcessFn registry. pdf-to-image and its
// presets never register there, so without this they 404 the moment the web
// client sends a second file (issue #632).
//
// One PDF fans out to many page images, so the per-file result is a ZIP,
// matching what the single-file route already returns. The response is
// therefore a ZIP of per-document ZIPs, one per input, in upload order.
app.post(
`${basePath}/batch`,
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (request, reply) => {
if (!(await requireToolAccess(request, reply, opts.toolId))) return;
// Rasterizing several documents can outrun the default socket timeout.
request.raw.socket?.setTimeout?.(0);
const files: Array<{ buffer: Buffer; filename: string }> = [];
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
try {
for await (const part of request.parts()) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
// Empty parts keep their slot: the client maps results back onto
// its own file list by index, so dropping one here would label a
// converted document with a different file's name.
files.push({
buffer: Buffer.concat(chunks),
filename: sanitizeFilename(part.filename ?? "document.pdf"),
});
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
// Doubles as an object-key segment and a response header value, so
// anything outside the key charset is ignored rather than allowed
// to fail every file or to break writeHead after hijack.
const raw = part.value as string;
if (typeof raw === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No PDF files provided" });
}
// Backstop only: the multipart iterator reads MAX_BATCH_SIZE per call and
// busboy stops at the limit first, so this rarely fires.
if (env.MAX_BATCH_SIZE > 0 && files.length > env.MAX_BATCH_SIZE) {
return reply.status(400).send({
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
});
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
if (opts.lockedFormat) {
settings.format = opts.lockedFormat as typeof settings.format;
}
const jobId = clientJobId || randomUUID();
const results: Array<{ key: string; prefix: string; filename: string } | null> = new Array(
files.length,
).fill(null);
const errors: Array<{ filename: string; error: string }> = [];
const publishProgress = (completedFiles: number, currentFile?: string) =>
updateJobProgress({
jobId,
status: "processing",
totalFiles: files.length,
completedFiles,
failedFiles: errors.length,
errors,
...(currentFile ? { currentFile } : {}),
});
publishProgress(0);
// Sequential on purpose: mupdf rasterization is synchronous and CPU-bound,
// so interleaving documents mostly multiplies peak memory.
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
publishProgress(i, file.filename);
if (file.buffer.length === 0) {
throw new PdfInputError("File is empty");
}
if (!isPdfBuffer(file.buffer)) {
throw new PdfInputError("Invalid or corrupt PDF file");
}
const childJobId = `${jobId}-f${i}`;
const prefix = `outputs/${childJobId}`;
const { filenames } = await renderPdfPages(file.buffer, settings, childJobId);
const zipName = `${file.filename.replace(/\.pdf$/i, "")}-pages.zip`;
const key = `${prefix}/${zipName}`;
await putObject(key, await buildPagesZip(childJobId, filenames));
results[i] = { key, prefix, filename: zipName };
} catch (err) {
// A storage or infrastructure fault is not a bad document. Let it
// reach the error handler (which logs it, reports it, and honors its
// status) instead of telling the user their PDF is broken.
if (typeof (err as { statusCode?: number })?.statusCode === "number") throw err;
if (err instanceof PdfInputError) {
errors.push({ filename: file.filename, error: err.message });
} else {
request.log.error(
{ err, filename: file.filename, toolId: opts.toolId },
"PDF batch file conversion failed",
);
// Generic on purpose: only messages this route authors are safe to
// echo, and internal errors can carry absolute paths.
errors.push({ filename: file.filename, error: "PDF conversion failed" });
}
}
}
updateJobProgress({
jobId,
status: errors.length === files.length ? "failed" : "completed",
totalFiles: files.length,
completedFiles: files.length,
failedFiles: errors.length,
errors,
});
if (errors.length === files.length) {
// parseApiError on the client reads `error` and `details`, so the
// per-file reasons have to ride in `details` to be seen at all.
return reply.status(422).send({
error: "All files failed processing",
details: errors.map((e) => `${e.filename}: ${e.error}`),
errors,
});
}
const uniqueName = createUniqueNamer();
const fileResultsMap: Record<string, string> = {};
for (let i = 0; i < results.length; i++) {
const entry = results[i];
if (!entry) continue;
entry.filename = uniqueName(entry.filename);
fileResultsMap[String(i)] = entry.filename;
}
// Hijack and stream the ZIP response
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="batch-${opts.toolId}-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
...getSecurityHeaders(),
});
const archive = archiver("zip", { zlib: { level: 5 } });
// Headers are already out, so a failure here cannot change the status.
// Destroy the socket rather than end() it: a clean end on a chunked
// response is indistinguishable from success, and the client would keep
// a truncated ZIP believing it complete.
const failStream = (err: Error, msg: string) => {
request.log.error({ err, jobId }, msg);
archive.abort();
reply.raw.destroy(err);
};
archive.on("error", (err) => failStream(err, "Archiver error during PDF batch processing"));
archive.pipe(reply.raw);
try {
for (const entry of results) {
if (!entry) continue;
archive.append(await getObjectStream(entry.key), { name: entry.filename });
}
await archive.finalize();
} catch (err) {
failStream(
err instanceof Error ? err : new Error(String(err)),
"Failed to stream ZIP entries during PDF batch processing",
);
}
// The per-page images and per-document ZIPs exist only to build the
// response, which is now sent. Leaving them would strand every rendered
// page on the volume until the storage sweep, and they carry no jobs row
// for retention or GDPR deletion to find.
await Promise.all(
results.map((entry) => (entry ? deletePrefix(entry.prefix).catch(() => {}) : null)),
);
},
);
// ── Main processing endpoint ─────────────────────────────────
app.post(basePath, async (request, reply) => {
if (!(await requireToolAccess(request, reply, opts.toolId))) return;
let fileBuffer: Buffer | null = null;
let settingsRaw: string | null = null;
@@ -335,66 +667,16 @@ export function registerPdfToImageRoute(
settings.format = opts.lockedFormat as typeof settings.format;
}
let doc: mupdf.Document | null = null;
const jobId = randomUUID();
try {
doc = mupdf.Document.openDocument(fileBuffer, "application/pdf");
if (doc.needsPassword()) {
return reply.status(400).send({ error: "Password-protected PDFs are not supported" });
}
const { pages, filenames, totalPages, selectedPages } = await renderPdfPages(
fileBuffer,
settings,
jobId,
);
const totalPages = doc.countPages();
let selectedPages: number[];
try {
selectedPages = parsePageRange(settings.pages, totalPages);
} catch (err) {
return reply
.status(400)
.send({ error: err instanceof Error ? err.message : "Invalid page range" });
}
const ext = FORMAT_EXT[settings.format] ?? ".png";
const jobId = randomUUID();
const pages: Array<{ page: number; downloadUrl: string; size: number }> = [];
const pageFilenames: string[] = [];
for (const pageNum of selectedPages) {
const pngBytes = renderPage(doc, pageNum - 1, settings.dpi);
const imageBuffer = await convertWithSharp(
pngBytes,
settings.format,
settings.quality,
settings.colorMode,
);
const filename = `page-${pageNum}${ext}`;
await putObject(`outputs/${jobId}/${filename}`, imageBuffer);
pageFilenames.push(filename);
pages.push({
page: pageNum,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
size: imageBuffer.length,
});
}
doc.destroy();
doc = null;
// Build ZIP by streaming each entry from object storage (O(1-entry) peak)
const zipFilename = "pdf-pages.zip";
const archive = archiver("zip", { zlib: { level: 5 } });
const zipChunks: Buffer[] = [];
archive.on("data", (chunk: Buffer) => zipChunks.push(chunk));
const zipDone = new Promise<void>((resolve, reject) => {
archive.on("end", resolve);
archive.on("error", reject);
});
for (const fname of pageFilenames) {
const buf = await getObjectBuffer(`outputs/${jobId}/${fname}`);
archive.append(buf, { name: fname });
}
await archive.finalize();
await zipDone;
const zipBuffer = Buffer.concat(zipChunks);
const zipBuffer = await buildPagesZip(jobId, filenames);
await putObject(`outputs/${jobId}/${zipFilename}`, zipBuffer);
const zipUrl = `/api/v1/download/${jobId}/${encodeURIComponent(zipFilename)}`;
@@ -411,7 +693,9 @@ export function registerPdfToImageRoute(
zipSize: zipBuffer.length,
});
} catch (err) {
doc?.destroy();
if (err instanceof PdfInputError) {
return reply.status(400).send({ error: err.message });
}
return reply.status(422).send({
error: "PDF conversion failed",
details: err instanceof Error ? err.message : "Unknown error",
+2 -19
View File
@@ -9,7 +9,7 @@ import { env } from "../../config.js";
import { getSecurityHeaders } from "../../lib/csp.js";
import { resolveConcurrency } from "../../lib/env.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { createUniqueNamer, sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
@@ -312,24 +312,7 @@ export function registerSvgToRasterRoute(
}
// Deduplicate filenames and build X-File-Results header
const usedNames = new Set<string>();
function getUniqueName(name: string): string {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIdx = name.lastIndexOf(".");
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let counter = 1;
let candidate = `${base}_${counter}${ext}`;
while (usedNames.has(candidate)) {
counter++;
candidate = `${base}_${counter}${ext}`;
}
usedNames.add(candidate);
return candidate;
}
const getUniqueName = createUniqueNamer();
const fileResultsMap: Record<string, string> = {};
for (let i = 0; i < results.length; i++) {
@@ -0,0 +1,82 @@
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "./helpers";
const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures", "document", "valid");
async function uploadPdfs(page: Page, filenames: string[]): Promise<void> {
const fileChooserPromise = page.waitForEvent("filechooser");
await page
.getByRole("button", { name: /upload from computer/i })
.first()
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(filenames.map((f) => path.join(FIXTURES_DIR, f)));
await page.waitForTimeout(500);
}
/**
* Issue #632: pdf-to-image-group presets (pdf-to-jpg, pdf-to-png, pdf-to-tiff)
* turn one PDF into many page images, so 2+ uploads belong on the per-file
* /batch endpoint. Those presets register through registerPdfToImageRoute,
* which never entered the registry the generic
* `:section/:toolId/batch` route reads, so the second file used to 404 with
* `Tool "<id>" not found`. The route now serves its own /batch.
*/
test.describe("pdf-to-image conversion presets with multiple files (issue #632)", () => {
test("pdf-to-jpg converts 2 PDFs instead of failing", async ({ loggedInPage: page }) => {
const batchResponse = page.waitForResponse(
(res) => res.url().includes("/pdf-to-jpg/batch") && res.request().method() === "POST",
);
await page.goto("/pdf/pdf-to-jpg");
await uploadPdfs(page, ["test-3page.pdf", "alt-2page.pdf"]);
await expect(page.getByText("Files (2)")).toBeVisible();
await page.getByTestId("preset-submit").click();
const response = await batchResponse;
expect(response.status()).toBe(200);
await expect(page.getByText("Conversion complete").first()).toBeVisible({ timeout: 30_000 });
});
test("pdf-to-png reports the error in the panel when every PDF is unreadable", async ({
loggedInPage: page,
}) => {
await page.goto("/pdf/pdf-to-png");
// Route the batch call so the failure path is deterministic without
// needing a corrupt fixture that the dropzone would reject up front.
await page.route("**/pdf-to-png/batch", (route) =>
route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({ error: "All files failed processing", errors: [] }),
}),
);
await uploadPdfs(page, ["test-3page.pdf", "alt-2page.pdf"]);
await expect(page.getByText("Files (2)")).toBeVisible();
await page.getByTestId("preset-submit").click();
await expect(page.getByText(/All files failed processing/i)).toBeVisible({ timeout: 15_000 });
});
test("a single PDF still uses the single-file route", async ({ loggedInPage: page }) => {
const singleResponse = page.waitForResponse(
(res) => /\/pdf-to-jpg$/.test(res.url()) && res.request().method() === "POST",
);
await page.goto("/pdf/pdf-to-jpg");
await uploadPdfs(page, ["alt-2page.pdf"]);
await page.getByTestId("preset-submit").click();
const response = await singleResponse;
expect(response.status()).toBe(200);
const download = page.getByTestId("preset-download");
await expect(download).toBeVisible({ timeout: 30_000 });
await expect(download).toHaveAttribute("href", /\/api\/v1\/download\/[^/]+\/pdf-pages\.zip$/);
});
});
+29 -2
View File
@@ -1,7 +1,8 @@
import { apiToolPath, TOOLS } from "@snapotter/shared";
import { apiToolPath, CONVERSION_PRESETS, TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
import { getRegisteredToolIds, getToolConfig } from "../../apps/api/src/routes/tool-factory.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
/**
* Drift guards between the shared TOOLS catalog and the API.
@@ -121,4 +122,30 @@ describe("tool route drift", () => {
expect(res.statusCode, `tool "${tool.id}" has no live POST route (got 404)`).not.toBe(404);
}
}, 60_000);
/**
* ConversionPresetSettings posts 2+ files to `<toolPath>/batch` for every
* preset outside MULTI_FILE_TOOLS. Presets on a custom base (image-to-pdf,
* pdf-to-image, svg-to-raster) never enter the registry the generic
* `:section/:toolId/batch` route reads, so a base that neither joins
* MULTI_FILE_TOOLS nor registers its own /batch 404s on the second file.
* That shipped twice: issue #627 (image-to-pdf) and issue #632
* (pdf-to-image). An empty body is enough to prove the route resolves.
*/
it("every batch-dispatched conversion preset answers on POST .../batch", async () => {
const { body, contentType } = createMultipartPayload([{ name: "settings", content: "{}" }]);
for (const preset of CONVERSION_PRESETS) {
if (MULTI_FILE_TOOLS.has(preset.id)) continue;
const res = await testApp.app.inject({
method: "POST",
url: `${apiToolPath(preset.id)}/batch`,
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(
res.statusCode,
`preset "${preset.id}" (base "${preset.base}") has no live /batch route: ${res.body.slice(0, 200)}`,
).not.toBe(404);
}
}, 60_000);
});
@@ -0,0 +1,434 @@
/**
* Batch conversion for the pdf-to-image base tool and its three presets
* (pdf-to-jpg / pdf-to-png / pdf-to-tiff).
*
* Issue #632: the presets registered only a single-file route, so the web
* client's 2+-file submission fell through to the generic
* `/api/v1/tools/:section/:toolId/batch` route, whose registry lookup missed
* and 404'd with `Tool "<id>" not found`.
*
* One PDF fans out to N page images, so the per-file output is a ZIP (the same
* shape the single-file route already returns). A batch therefore nests: the
* response ZIP holds one per-PDF ZIP per input, in upload order.
*/
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
createUserAndLogin,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const PDF_3PAGE = readFixture(fixtures.document.pdf3);
const PDF_2PAGE = readFixture(fixtures.document.pdf2);
const PDF_ENCRYPTED = readFixture(fixtures.document.encrypted);
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
/** Session for a role that deliberately lacks tools:use. */
let noToolsToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
await app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "nopdftools",
description: "Everything except running tools",
permissions: ["files:own"],
},
});
noToolsToken = (await createUserAndLogin(app, "nopdfuser", "nopdftools")).token;
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** POST the given files to a tool's /batch endpoint with `dpi: 72` settings. */
function postBatch(
toolId: string,
files: Array<{ filename: string; content: Buffer }>,
settings: Record<string, unknown> = { dpi: 72 },
opts: { token?: string | null; clientJobId?: string } = {},
) {
const { body, contentType } = createMultipartPayload([
...files.map((f) => ({
name: "file",
filename: f.filename,
contentType: "application/pdf",
content: f.content,
})),
{ name: "settings", content: JSON.stringify(settings) },
...(opts.clientJobId ? [{ name: "clientJobId", content: opts.clientJobId }] : []),
]);
const token = opts.token === undefined ? adminToken : opts.token;
return app.inject({
method: "POST",
url: `/api/v1/tools/pdf/${toolId}/batch`,
body,
headers: {
"content-type": contentType,
...(token ? { authorization: `Bearer ${token}` } : {}),
},
});
}
/** Parse the index -> output filename map the web client uses to fan results out. */
function fileResults(res: { headers: Record<string, unknown> }): Record<string, string> {
return JSON.parse(decodeURIComponent(String(res.headers["x-file-results"] ?? "%7B%7D")));
}
describe("POST /api/v1/tools/pdf/:toolId/batch (issue #632)", () => {
// pdf-to-image is in the list because it shares registerPdfToImageRoute, not
// because the web client batches it: the base tool drives its own
// usePdfToImageStore and only ever posts one file. Its /batch is
// API-consumer surface that comes along with the presets' fix.
it.each(["pdf-to-jpg", "pdf-to-png", "pdf-to-tiff", "pdf-to-image"])(
"%s converts 2 PDFs into one ZIP entry per input",
async (toolId) => {
const res = await postBatch(toolId, [
{ filename: "test-3page.pdf", content: PDF_3PAGE },
{ filename: "alt-2page.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode, res.body.slice(0, 500)).toBe(200);
expect(res.headers["content-type"]).toContain("application/zip");
const entries = new AdmZip(res.rawPayload).getEntries();
expect(entries.map((e) => e.entryName)).toEqual([
"test-3page-pages.zip",
"alt-2page-pages.zip",
]);
},
);
it("maps every input index to its output name in X-File-Results", async () => {
const res = await postBatch("pdf-to-jpg", [
{ filename: "test-3page.pdf", content: PDF_3PAGE },
{ filename: "alt-2page.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(200);
expect(fileResults(res)).toEqual({
"0": "test-3page-pages.zip",
"1": "alt-2page-pages.zip",
});
});
it("nests each source PDF's pages in its own ZIP, in the preset's locked format", async () => {
const res = await postBatch("pdf-to-jpg", [
{ filename: "test-3page.pdf", content: PDF_3PAGE },
{ filename: "alt-2page.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(200);
const outer = new AdmZip(res.rawPayload);
const threePage = new AdmZip(outer.getEntry("test-3page-pages.zip")?.getData()).getEntries();
expect(threePage.map((e) => e.entryName)).toEqual(["page-1.jpg", "page-2.jpg", "page-3.jpg"]);
for (const entry of threePage) {
expect(entry.header.size).toBeGreaterThan(0);
}
const twoPage = new AdmZip(outer.getEntry("alt-2page-pages.zip")?.getData()).getEntries();
expect(twoPage.map((e) => e.entryName)).toEqual(["page-1.jpg", "page-2.jpg"]);
});
it("keeps the locked format even when the client asks for another one", async () => {
const res = await postBatch(
"pdf-to-png",
[
{ filename: "a.pdf", content: PDF_3PAGE },
{ filename: "b.pdf", content: PDF_2PAGE },
],
{ dpi: 72, format: "jpg" },
);
expect(res.statusCode).toBe(200);
const outer = new AdmZip(res.rawPayload);
const inner = new AdmZip(outer.getEntry("a-pages.zip")?.getData()).getEntries();
expect(inner.every((e) => e.entryName.endsWith(".png"))).toBe(true);
});
it("honors a page range across every input", async () => {
const res = await postBatch(
"pdf-to-png",
[
{ filename: "a.pdf", content: PDF_3PAGE },
{ filename: "b.pdf", content: PDF_2PAGE },
],
{ dpi: 72, pages: "1" },
);
expect(res.statusCode).toBe(200);
const outer = new AdmZip(res.rawPayload);
for (const name of ["a-pages.zip", "b-pages.zip"]) {
const inner = new AdmZip(outer.getEntry(name)?.getData()).getEntries();
expect(inner.map((e) => e.entryName)).toEqual(["page-1.png"]);
}
});
it("deduplicates outputs when two uploads share a filename", async () => {
const res = await postBatch("pdf-to-jpg", [
{ filename: "same.pdf", content: PDF_3PAGE },
{ filename: "same.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(200);
expect(fileResults(res)).toEqual({
"0": "same-pages.zip",
"1": "same-pages_1.zip",
});
});
it("skips an unreadable PDF and still returns the readable one", async () => {
const res = await postBatch("pdf-to-jpg", [
{ filename: "broken.pdf", content: Buffer.from("not a pdf at all") },
{ filename: "good.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(200);
// Index 0 failed, so only index 1 maps to an output; the client marks the
// rest failed rather than silently pairing the wrong result to the wrong file.
expect(fileResults(res)).toEqual({ "1": "good-pages.zip" });
const entries = new AdmZip(res.rawPayload).getEntries();
expect(entries.map((e) => e.entryName)).toEqual(["good-pages.zip"]);
});
it("keeps index alignment when an upload is empty", async () => {
// The client maps results back onto its own file list by index. Dropping a
// zero-byte part would shift every later index and label a converted file
// with the wrong source name.
const res = await postBatch("pdf-to-jpg", [
{ filename: "empty.pdf", content: Buffer.alloc(0) },
{ filename: "good.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(200);
expect(fileResults(res)).toEqual({ "1": "good-pages.zip" });
});
it("returns 422 with per-file reasons when every PDF fails", async () => {
const res = await postBatch("pdf-to-jpg", [
{ filename: "a.pdf", content: Buffer.from("nope") },
{ filename: "b.pdf", content: Buffer.from("also nope") },
]);
expect(res.statusCode).toBe(422);
const data = JSON.parse(res.body);
expect(data.error).toMatch(/All files failed/i);
expect(data.errors).toHaveLength(2);
expect(data.errors[0].filename).toBe("a.pdf");
expect(data.errors[0].error).toMatch(/Invalid or corrupt PDF/i);
});
it("rejects a password-protected PDF without failing its siblings", async () => {
const res = await postBatch("pdf-to-jpg", [
{ filename: "locked.pdf", content: PDF_ENCRYPTED },
{ filename: "open.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(200);
expect(fileResults(res)).toEqual({ "1": "open-pages.zip" });
});
it("reports the locked-PDF reason rather than a generic failure", async () => {
const res = await postBatch("pdf-to-jpg", [
{ filename: "locked.pdf", content: PDF_ENCRYPTED },
{ filename: "alsolocked.pdf", content: PDF_ENCRYPTED },
]);
expect(res.statusCode).toBe(422);
expect(JSON.parse(res.body).errors[0].error).toMatch(/password/i);
});
it("echoes the client job id so the progress stream can be matched up", async () => {
const res = await postBatch(
"pdf-to-jpg",
[
{ filename: "a.pdf", content: PDF_3PAGE },
{ filename: "b.pdf", content: PDF_2PAGE },
],
{ dpi: 72 },
{ clientJobId: "batch-632-client-job" },
);
expect(res.statusCode).toBe(200);
expect(res.headers["x-job-id"]).toBe("batch-632-client-job");
});
it("cleans up the intermediate page objects it only needed to build the ZIP", async () => {
const clientJobId = "batch-632-cleanup";
const res = await postBatch(
"pdf-to-jpg",
[{ filename: "a.pdf", content: PDF_3PAGE }],
{ dpi: 72 },
{ clientJobId },
);
expect(res.statusCode).toBe(200);
// Only the streamed outer ZIP is ever read, so nothing should be left on
// the volume waiting for the 72h storage sweep. Cleanup runs once the
// response is on the wire, so poll rather than assume it already landed.
const { objectExists } = await import("../../../../apps/api/src/lib/object-storage.js");
const gone = async () =>
!(await objectExists(`outputs/${clientJobId}-f0/page-1.jpg`)) &&
!(await objectExists(`outputs/${clientJobId}-f0/a-pages.zip`));
for (let i = 0; i < 100 && !(await gone()); i++) {
await new Promise((r) => setTimeout(r, 50));
}
expect(await gone()).toBe(true);
});
it("requires authentication", async () => {
const res = await postBatch(
"pdf-to-jpg",
[
{ filename: "a.pdf", content: PDF_3PAGE },
{ filename: "b.pdf", content: PDF_2PAGE },
],
{ dpi: 72 },
{ token: null },
);
expect(res.statusCode).toBe(401);
});
it("returns 400 when no files are provided", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ dpi: 72 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/pdf/pdf-to-jpg/batch",
body,
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/No PDF files/i);
});
it("returns 400 for invalid settings", async () => {
const res = await postBatch(
"pdf-to-jpg",
[
{ filename: "a.pdf", content: PDF_3PAGE },
{ filename: "b.pdf", content: PDF_2PAGE },
],
{ dpi: 5000 },
);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/Invalid settings/i);
});
it("refuses a PDF with no pages instead of returning an empty ZIP", async () => {
// mupdf repairs and opens a /Count 0 document, so the page loop produces
// nothing and the old shape reported "converted" with a 22-byte archive.
const emptyPdf = Buffer.from(
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
"2 0 obj<</Type/Pages/Kids[]/Count 0>>endobj\n" +
"trailer<</Root 1 0 R>>\n%%EOF\n",
);
const res = await postBatch("pdf-to-jpg", [
{ filename: "nopages.pdf", content: emptyPdf },
{ filename: "good.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(200);
expect(fileResults(res)).toEqual({ "1": "good-pages.zip" });
});
it("rejects a batch larger than MAX_BATCH_SIZE instead of converting part of it", async () => {
const { env } = await import("../../../../apps/api/src/config.js");
const original = env.MAX_BATCH_SIZE;
// multipartParts reads MAX_BATCH_SIZE per call, so busboy stops at the
// limit and the iterator throws before the route's own guard is reached.
// Either way the contract is the same: rejected outright, nothing converted.
env.MAX_BATCH_SIZE = 1;
try {
const res = await postBatch("pdf-to-jpg", [
{ filename: "a.pdf", content: PDF_3PAGE },
{ filename: "b.pdf", content: PDF_2PAGE },
]);
expect(res.statusCode).toBe(400);
expect(res.headers["content-type"]).not.toContain("application/zip");
expect(JSON.parse(res.body).error).toMatch(/Failed to parse multipart request/i);
} finally {
env.MAX_BATCH_SIZE = original;
}
});
});
/**
* The literal /batch path shadows the generic `:section/:toolId/batch` route,
* which gates on requireToolAccess. Without the same gate here, adding the
* route would have turned a 403 into a converted ZIP for roles that cannot run
* tools. The sibling endpoints on this route are held to the same rule so a
* blocked user cannot simply convert one file at a time instead.
*/
describe("pdf-to-image endpoints enforce tool access", () => {
it("returns 403 on /batch for a role without tools:use", async () => {
const res = await postBatch(
"pdf-to-jpg",
[
{ filename: "a.pdf", content: PDF_3PAGE },
{ filename: "b.pdf", content: PDF_2PAGE },
],
{ dpi: 72 },
{ token: noToolsToken },
);
expect(res.statusCode).toBe(403);
});
it.each(["", "/info", "/preview"])(
"returns 403 on %s for a role without tools:use",
async (suffix) => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "a.pdf",
contentType: "application/pdf",
content: PDF_3PAGE,
},
{ name: "settings", content: JSON.stringify({ dpi: 72 }) },
]);
const res = await app.inject({
method: "POST",
url: `/api/v1/tools/pdf/pdf-to-jpg${suffix}`,
body,
headers: { "content-type": contentType, authorization: `Bearer ${noToolsToken}` },
});
expect(res.statusCode).toBe(403);
},
);
it("still lets an admin through every endpoint", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "a.pdf", contentType: "application/pdf", content: PDF_3PAGE },
{ name: "settings", content: JSON.stringify({ dpi: 72, pages: "1" }) },
]);
for (const suffix of ["", "/info", "/preview"]) {
const res = await app.inject({
method: "POST",
url: `/api/v1/tools/pdf/pdf-to-jpg${suffix}`,
body,
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode, `${suffix || "/"} -> ${res.body.slice(0, 200)}`).toBe(200);
}
});
});