Files
SnapOtter/apps/api/src/routes/files.ts
T
SnapOtterandGitHub 08961fcc89 fix: PDF tool QA sweep - library auto-save versioning, AI fileId threading, modality polish (#251)
* fix(pdf): never enlarge on compress, honor redact case, hide same-format convert

- compress-pdf: guard both modes so output is never larger than the input; low-DPI scans could be upsampled and grow. Falls back to the original bytes.

- doc_redact.py: caseSensitive=true now filters PyMuPDF's case-insensitive search to exact-case hits, so the toggle works instead of always over-redacting.

- convert-{document,presentation,spreadsheet}: omit the input's own format from the output dropdown; the backend already rejects same-format conversions.

Verified end-to-end against an isolated Docker stack during a full visual QA sweep of all 37 PDF tools.

* fix(ui): show real multi-file preview thumbnails per modality

The bottom multi-file preview strip rendered a raw <img src=blobUrl> for every file, so audio/video/PDF inputs showed a broken-image icon plus the filename. ThumbnailStrip now branches on FileEntry.previewKind: images use <img> (icon fallback on error), video shows a captured first frame, PDF shows a pdf.js page-1 render, and audio/other show a type icon + extension. Fixes the multi-file preview across all modalities.

Verified in the browser for image/PDF/audio/video.

* fix(modality): make pipeline, batch validation, save/upload, previews & UI modality-aware

The app grew up image-only; several paths still assumed image. They now dispatch on the tool/file modality (image/video/audio/document/file):

- pipeline /execute + /batch: validate+decode input via inputHandlerFor(modality) instead of validateImageBuffer, so PDF/audio/video/data pipelines work (were rejected 'Invalid image').

- batch: non-image inputs now get per-modality validation (ffprobe/qpdf) before the worker instead of passing through unchecked.

- files /upload, user-files /save-result + /thumbnail: accept non-image files (MIME from extension; video-poster / pdf-first-page thumbnails).

- postprocess CONTENT_TYPE_TO_EXT: cover video/audio/pdf/text/zip so output extensions are corrected for all modalities.

- worker pipeline-finalize: attach result payload to the complete SSE event so the sync-window-timeout fallback still delivers a download.

- frontend: batch-ZIP blob MIME by extension (not svg-only); modality-neutral fallback labels/filenames; 'smaller file' not 'smaller image'.

Found via a codebase-wide image-only-assumption audit. Verified: PDF/audio/video pipelines + batch now work; image paths unchanged. canBrowserPreview kept image-only by design (non-image is rendered by dedicated displayMode viewers).

* fix(pipeline): generate a modality-aware preview for pipeline results

processPipelineFinalize now derives the output content type from its extension and runs generatePreview (video poster / pdf first page / image thumb), sets previewRef on the result, and surfaces previewUrl in the /execute sync response and the SSE complete event (via buildLegacyResultPayload). Pipeline outputs get a preview like single-tool results instead of always returning previewUrl: undefined.

Verified: PDF pipeline -> previewUrl returns a valid PNG first-page render; png pipeline correctly has no previewUrl; audio/video/multi-step pipelines all 200.

* fix(worker): auto-save a new library version when processing a library file

The worker hardcoded savedFileId = undefined ('No auto-save') even though the whole versioning feature was wired around it: the frontend sends fileId for library files and reads result.savedFileId, tool-factory threads fileId into ToolJobData, and autoSaveToLibrary implements the new-version save -- but the worker never called it (dead code from the tool-first-workflow merge). processToolJob now calls autoSaveToLibrary with data.fileId; without a fileId it is a no-op, so tool-first uploads are unchanged.

Verified: processing a library PDF with fileId creates version 2 (parent linked, toolChain appended, savedFileId returned); processing without fileId saves nothing.

* fix(library): ownership check + modality-aware dimensions in autoSaveToLibrary

- Only create a new version when the requester owns the parent (parent.userId === opts.userId); prevents versioning another user's file via a known fileId.

- Dimensions are modality-aware: sharp for images, ffprobe (probeMedia) for video, null for audio/document. Previously sharp-only, so non-image versions always got null dims.

* fix(ai): thread fileId + real userId through the 16 AI tool routes

AI custom routes parsed neither the fileId multipart field nor the authenticated user (they hardcoded userId: null), so processing a library file via an AI tool never created a new version, and AI jobs were unattributed. Each route now parses fileId like clientJobId and passes getAuthUser(request)?.id as userId to enqueueToolJob.

Verified: ocr-pdf on a library PDF creates a new version (v2); the ownership check still denies cross-user versioning.
2026-06-16 15:48:07 +08:00

262 lines
8.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { extname } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { readImageDimensions } from "../lib/exiftool.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { getObjectSize, getObjectStream, putObject } from "../lib/object-storage.js";
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
/**
* Guard against path traversal in URL params.
*/
function isPathTraversal(segment: string): boolean {
return (
segment.includes("..") ||
segment.includes("/") ||
segment.includes("\\") ||
segment.includes("\0")
);
}
export async function fileRoutes(app: FastifyInstance): Promise<void> {
// ── POST /api/v1/upload ────────────────────────────────────────
app.post(
"/api/v1/upload",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const jobId = randomUUID();
const uploadedFiles: Array<{
name: string;
size: number;
format: string;
}> = [];
const parts = request.parts();
for await (const part of parts) {
// Skip non-file fields
if (part.type !== "file") continue;
// Consume buffer from the stream
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Skip empty parts (e.g. empty file field)
if (buffer.length === 0) continue;
// Try image validation; non-image files are accepted with format from extension
const validation = await validateImageBuffer(buffer, part.filename).catch(() => null);
const isValidImage = validation?.valid === true;
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
const safeBuffer = isValidImage && isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
// Sanitize filename (canonical; do NOT re-sanitize downstream)
const safeName = sanitizeFilename(part.filename ?? "upload");
// Write to object storage uploads prefix
await putObject(`uploads/${jobId}/${safeName}`, safeBuffer);
const fileExt = safeName.split(".").pop()?.toLowerCase() ?? "";
uploadedFiles.push({
name: safeName,
size: safeBuffer.length,
format: isValidImage ? validation.format : fileExt,
});
}
if (uploadedFiles.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
return reply.send({
jobId,
files: uploadedFiles,
});
},
);
// ── GET /api/v1/download/:jobId/:filename ──────────────────────
app.get(
"/api/v1/download/:jobId/:filename",
async (
request: FastifyRequest<{
Params: { jobId: string; filename: string };
}>,
reply: FastifyReply,
) => {
const { jobId, filename } = request.params;
// Guard against path traversal
if (isPathTraversal(jobId) || isPathTraversal(filename)) {
return reply.status(400).send({ error: "Invalid path" });
}
// Resolve from object storage: outputs/ first, then uploads/
let key = `outputs/${jobId}/${filename}`;
let size: number;
try {
size = await getObjectSize(key);
} catch {
key = `uploads/${jobId}/${filename}`;
try {
size = await getObjectSize(key);
} catch {
return reply.status(404).send({ error: "File not found" });
}
}
const ext = extname(filename).toLowerCase().replace(/^\./, "");
const contentType = getContentType(ext);
// Check Range header before setting content headers (a 416 must not
// carry the attachment Content-Type that would confuse serialization).
reply.header("Accept-Ranges", "bytes");
const range = request.headers.range;
if (range) {
const m = range.match(/^bytes=(\d+)-(\d*)$/);
const start = m ? Number.parseInt(m[1], 10) : Number.NaN;
const end = m?.[2] ? Number.parseInt(m[2], 10) : size - 1;
if (!m || Number.isNaN(start) || start >= size || end < start) {
return reply
.code(416)
.header("Content-Range", `bytes */${size}`)
.send({ error: "Range not satisfiable" });
}
const clampedEnd = Math.min(end, size - 1);
return reply
.code(206)
.header("Content-Type", contentType)
.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
)
.header("Content-Range", `bytes ${start}-${clampedEnd}/${size}`)
.header("Content-Length", String(clampedEnd - start + 1))
.send(await getObjectStream(key, { start, end: clampedEnd }));
}
reply
.header("Content-Type", contentType)
.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
)
.header("Content-Length", String(size));
return reply.send(await getObjectStream(key));
},
);
// ── POST /api/v1/preview ──────────────────────────────────────
// Returns a WebP preview for formats browsers can't display (HEIC/HEIF).
app.post("/api/v1/preview", async (request: FastifyRequest, reply: FastifyReply) => {
const data = await request.file();
if (!data) {
return reply.status(400).send({ error: "No file provided" });
}
const originalBuffer = await data.toBuffer();
let buffer = originalBuffer;
const ext = data.filename?.split(".").pop()?.toLowerCase();
const validation = await validateImageBuffer(buffer, data.filename);
if (!validation.valid) {
return reply.status(400).send({ error: validation.reason });
}
// Decode HEIC/HEIF via system decoder
if (validation.format === "heif") {
try {
buffer = await decodeHeic(buffer);
} catch {
return reply.status(422).send({ error: "Failed to decode HEIC/HEIF file" });
}
}
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools
if (needsCliDecode(validation.format)) {
try {
buffer = await decodeToSharpCompat(buffer, validation.format);
} catch {
// CLI decoder unavailable -- try Sharp directly as fallback for preview
try {
await sharp(buffer).metadata();
} catch {
return reply.status(422).send({
error: `Failed to decode ${validation.format.toUpperCase()} file`,
});
}
}
}
try {
const preMeta = await sharp(buffer).metadata();
let origWidth = preMeta.width ?? 0;
let origHeight = preMeta.height ?? 0;
if (validation.format === "raw") {
const dims = await readImageDimensions(originalBuffer, ext);
if (dims) {
origWidth = dims.width;
origHeight = dims.height;
}
}
const webp = await sharp(buffer)
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
return reply
.header("Content-Type", "image/webp")
.header("X-Original-Width", String(origWidth))
.header("X-Original-Height", String(origHeight))
.send(webp);
} catch {
return reply.status(422).send({
error: `Failed to generate preview for ${validation.format.toUpperCase()} file`,
});
}
});
}
function getContentType(ext: string): string {
const map: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
avif: "image/avif",
svg: "image/svg+xml",
pdf: "application/pdf",
zip: "application/zip",
ico: "image/x-icon",
json: "application/json",
jxl: "image/jxl",
dng: "image/x-adobe-dng",
cr2: "image/x-canon-cr2",
nef: "image/x-nikon-nef",
arw: "image/x-sony-arw",
orf: "image/x-olympus-orf",
rw2: "image/x-panasonic-rw2",
tga: "image/x-tga",
psd: "image/vnd.adobe.photoshop",
exr: "image/x-exr",
hdr: "image/vnd.radiance",
heic: "image/heic",
heif: "image/heif",
};
return map[ext] ?? "application/octet-stream";
}