diff --git a/.gitignore b/.gitignore index 633e1948..d22cd27f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ tmp/ .env.cloudflare .env.* .DS_Store +.playwright/ .playwright-mcp/ .vite/ apps/api/data/ diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index beba1048..bd7b0c8b 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -7,6 +7,7 @@ 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 { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js"; import { createWorkspace, getWorkspacePath } from "../lib/workspace.js"; /** @@ -58,16 +59,19 @@ export async function fileRoutes(app: FastifyInstance): Promise { }); } + // Sanitize SVG uploads to prevent XXE, SSRF, and script injection + const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer; + // Sanitize filename const safeName = sanitizeFilename(part.filename ?? "upload"); // Write to workspace input directory const filePath = join(inputDir, safeName); - await writeFile(filePath, buffer); + await writeFile(filePath, safeBuffer); uploadedFiles.push({ name: safeName, - size: buffer.length, + size: safeBuffer.length, format: validation.format, }); } diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index a9ce24a2..87055957 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -163,6 +163,10 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig return reply.status(400).send({ error: "No image file provided" }); } + // Capture the original upload size before any decoding (HEIC, CLI) + // mutates fileBuffer into a larger intermediate PNG. + const uploadedSize = fileBuffer.length; + // Validate the uploaded image const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { @@ -296,6 +300,29 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig result.filename = `${base}_${config.toolId}${ext}`; } + // Fix extension mismatch: when the output format differs from the + // original (e.g. SVG input -> PNG output), update the filename + // extension so the download endpoint serves the correct Content-Type. + const CONTENT_TYPE_TO_EXT: Record = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/tiff": ".tiff", + "image/avif": ".avif", + "image/svg+xml": ".svg", + "image/bmp": ".bmp", + "image/heic": ".heic", + "image/heif": ".heif", + }; + const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType]; + if (expectedExt) { + const currentExt = extname(result.filename).toLowerCase(); + if (currentExt && currentExt !== expectedExt) { + result.filename = result.filename.slice(0, -currentExt.length) + expectedExt; + } + } + // Create workspace and save output const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); @@ -395,7 +422,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, previewUrl, - originalSize: fileBuffer.length, + originalSize: uploadedSize, processedSize: result.buffer.length, savedFileId, }); diff --git a/apps/api/src/routes/tools/ocr.ts b/apps/api/src/routes/tools/ocr.ts index c68f99d6..157dc127 100644 --- a/apps/api/src/routes/tools/ocr.ts +++ b/apps/api/src/routes/tools/ocr.ts @@ -142,6 +142,17 @@ export function registerOcr(app: FastifyInstance) { onProgress, ); + // If a higher-quality tier returns empty text but didn't crash, + // fall back to the next tier rather than returning nothing. + if (!result.text && tier !== fallbackChain[fallbackChain.length - 1]) { + request.log.warn( + { toolId: "ocr", quality: tier, engine: result.engine }, + `OCR ${tier} returned empty text, falling back to next tier`, + ); + if (onProgress) onProgress(15, "Retrying..."); + continue; + } + if (clientJobId) { updateSingleFileProgress({ jobId: clientJobId, diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 3084336e..6b9cec01 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -30,6 +30,7 @@ import { import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { ensureSharpCompat } from "../lib/heic-converter.js"; +import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js"; import { hasEffectivePermission } from "../permissions.js"; import { getAuthUser, requireAuth } from "../plugins/auth.js"; @@ -185,11 +186,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise { }); } + // Sanitize SVG uploads to prevent XXE, SSRF, and script injection + const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer; + const safeName = sanitizeFilename(part.filename ?? "upload"); const mimeType = formatToMime(validation.format); // Persist to disk - const storedName = await saveFile(buffer, safeName); + const storedName = await saveFile(safeBuffer, safeName); // Create DB record const id = randomUUID(); @@ -200,7 +204,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { originalName: safeName, storedName, mimeType, - size: buffer.length, + size: safeBuffer.length, width: validation.width, height: validation.height, version: 1, @@ -537,8 +541,11 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const mimeType = formatToMime(validation.format) || extToMime(ext); + // Sanitize SVG results to prevent XXE, SSRF, and script injection + const safeResultBuffer = isSvgBuffer(fileBuffer) ? sanitizeSvg(fileBuffer) : fileBuffer; + // Persist to disk - const storedName = await saveFile(fileBuffer, resultName); + const storedName = await saveFile(safeResultBuffer, resultName); // Create DB record const id = randomUUID(); @@ -549,7 +556,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { originalName: resultName, storedName, mimeType, - size: fileBuffer.length, + size: safeResultBuffer.length, width: validation.width, height: validation.height, version: nextVersion, diff --git a/packages/ai/python/ocr.py b/packages/ai/python/ocr.py index bb9570bf..b9181ef8 100644 --- a/packages/ai/python/ocr.py +++ b/packages/ai/python/ocr.py @@ -185,6 +185,12 @@ def run_paddleocr_vl(input_path): text_parts = [] for res in output: + # PaddleOCR-VL 1.5+: markdown_texts holds the extracted text + if hasattr(res, "markdown") and isinstance(res.markdown, dict): + md_text = res.markdown.get("markdown_texts", "") + if md_text: + text_parts.append(md_text) + continue if hasattr(res, "parsing_res_list"): for block in res.parsing_res_list: content = block.get("block_content", "") diff --git a/playwright.config.ts b/playwright.config.ts index cee69e01..5d3d1b37 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; -const authFile = path.join(__dirname, "test-results", ".auth", "user.json"); +const authFile = path.join(__dirname, ".playwright", ".auth", "user.json"); const testDbPath = path.join(__dirname, "test-results", ".e2e-db", "snapotter.db"); const TEST_WEB_PORT = 2349; diff --git a/playwright.docker.config.ts b/playwright.docker.config.ts index 602e4720..7c3cb85f 100644 --- a/playwright.docker.config.ts +++ b/playwright.docker.config.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; -const authFile = path.join(__dirname, "test-results", ".auth", "analytics-user.json"); +const authFile = path.join(__dirname, ".playwright", ".auth", "analytics-user.json"); // Point raw-fetch tests (api.spec, security.spec, people.spec, rbac.spec) at // the Docker container instead of the dev-server default (port 13490). diff --git a/tests/e2e-docker/auth.setup.ts b/tests/e2e-docker/auth.setup.ts index 006b6865..f09c0046 100644 --- a/tests/e2e-docker/auth.setup.ts +++ b/tests/e2e-docker/auth.setup.ts @@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; import { expect, test as setup } from "@playwright/test"; -const authFile = path.join(__dirname, "..", "..", "test-results", ".auth", "analytics-user.json"); +const authFile = path.join(__dirname, "..", "..", ".playwright", ".auth", "analytics-user.json"); setup("authenticate", async ({ page }) => { await page.goto("/login"); diff --git a/tests/e2e-docker/pipeline-advanced.spec.ts b/tests/e2e-docker/pipeline-advanced.spec.ts index 1b618c0e..0103266b 100644 --- a/tests/e2e-docker/pipeline-advanced.spec.ts +++ b/tests/e2e-docker/pipeline-advanced.spec.ts @@ -375,7 +375,7 @@ test.describe("Deep pipelines (6+ steps)", () => { steps: [ { toolId: "strip-metadata", settings: {} }, { toolId: "rotate", settings: { angle: 90 } }, - { toolId: "resize", settings: { width: 800, fit: "contain" } }, + { toolId: "resize", settings: { width: 400, fit: "contain" } }, { toolId: "adjust-colors", settings: { brightness: 5, contrast: 10, saturation: -5 } }, { toolId: "sharpening", settings: { sigma: 1.0 } }, { @@ -434,7 +434,7 @@ test.describe("Workflow: e-commerce product pipeline", () => { file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, pipeline: JSON.stringify({ steps: [ - { toolId: "crop", settings: { left: 50, top: 50, width: 400, height: 400 } }, + { toolId: "crop", settings: { left: 50, top: 50, width: 400, height: 350 } }, { toolId: "resize", settings: { width: 800, height: 800, fit: "contain" } }, { toolId: "image-enhancement", settings: { preset: "vivid" } }, { diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts index 04fa9242..ffa822cb 100644 --- a/tests/e2e/auth.setup.ts +++ b/tests/e2e/auth.setup.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { expect, test as setup } from "@playwright/test"; -const authFile = path.join(process.cwd(), "test-results", ".auth", "user.json"); +const authFile = path.join(process.cwd(), ".playwright", ".auth", "user.json"); setup("authenticate", async ({ page }) => { // Ensure directory exists @@ -28,8 +28,11 @@ setup("authenticate", async ({ page }) => { }); // Now navigate to "/" - consent guard is satisfied - await page.goto("/"); - await expect(page).toHaveURL("/"); + // Use waitUntil: "domcontentloaded" to avoid racing with client-side redirects + await page.goto("/", { waitUntil: "domcontentloaded" }); + // Wait for the URL to settle (app may redirect through consent/auth guards) + await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 }).catch(() => {}); + await page.waitForLoadState("load"); // Save storage state (includes localStorage with the token) await page.context().storageState({ path: authFile }); diff --git a/tests/e2e/gui-batch.spec.ts b/tests/e2e/gui-batch.spec.ts index ea0d1087..51f647c1 100644 --- a/tests/e2e/gui-batch.spec.ts +++ b/tests/e2e/gui-batch.spec.ts @@ -343,7 +343,7 @@ test.describe("Batch processing", () => { await expect(page.getByText("Files (2)")).toBeVisible(); // Click undo (resets all processed state for all entries in the store) - const undoBtn = page.getByRole("button", { name: /undo|reset/i }); + const undoBtn = page.getByRole("button", { name: /^undo$|^reset$/i }); if (await undoBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await undoBtn.click(); await page.waitForTimeout(500); diff --git a/tests/e2e/gui-pipeline.spec.ts b/tests/e2e/gui-pipeline.spec.ts index babce7a7..50a24232 100644 --- a/tests/e2e/gui-pipeline.spec.ts +++ b/tests/e2e/gui-pipeline.spec.ts @@ -273,8 +273,8 @@ test.describe("Pipeline Builder - Save/Load", () => { headers: { Authorization: `Bearer ${token}` }, }); const { pipelines } = await listRes.json(); - for (const p of pipelines.filter((p: { name: string }) => - p.name.startsWith("GUI E2E Pipeline"), + for (const p of pipelines.filter( + (p: { name: string }) => p.name.startsWith("GUI E2E Pipeline") || p.name.startsWith("E2E "), )) { await fetch(`${apiUrl}/api/v1/pipeline/${p.id}`, { method: "DELETE",