mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user