Merge pull request #79 from ashim-hq/fix/state-bleed-on-tool-switch

fix: state bleed on tool switch, AVIF compress, OCR segfault, long filenames
This commit is contained in:
Ashim
2026-04-20 22:17:45 +08:00
committed by GitHub
10 changed files with 354 additions and 5 deletions
+13
View File
@@ -45,5 +45,18 @@ export function sanitizeFilename(raw: string): string {
} }
} }
// Truncate to filesystem-safe length (255 byte NAME_MAX minus margin for _toolId suffix)
const MAX_NAME_BYTES = 200;
const enc = new TextEncoder();
if (enc.encode(name).length > MAX_NAME_BYTES) {
const dotIdx = name.lastIndexOf(".");
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
while (enc.encode(base + ext).length > MAX_NAME_BYTES) {
base = base.slice(0, -1);
}
name = base + ext;
}
return name; return name;
} }
+1
View File
@@ -17,6 +17,7 @@ const FORMAT_MAP: Record<
gif: { format: "gif", extension: "gif", contentType: "image/gif" }, gif: { format: "gif", extension: "gif", contentType: "image/gif" },
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" }, tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
avif: { format: "avif", extension: "avif", contentType: "image/avif" }, avif: { format: "avif", extension: "avif", contentType: "image/avif" },
heif: { format: "avif", extension: "avif", contentType: "image/avif" },
}; };
const DEFAULT_QUALITY = 95; const DEFAULT_QUALITY = 95;
+3 -1
View File
@@ -2,6 +2,7 @@ import { compress } from "@ashim/image-engine";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js"; import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -16,6 +17,7 @@ export function registerCompress(app: FastifyInstance) {
settingsSchema, settingsSchema,
process: async (inputBuffer, settings, filename) => { process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer); const image = sharp(inputBuffer);
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const compressOptions: { const compressOptions: {
quality?: number; quality?: number;
@@ -31,7 +33,7 @@ export function registerCompress(app: FastifyInstance) {
const result = await compress(image, compressOptions); const result = await compress(image, compressOptions);
const buffer = await result.toBuffer(); const buffer = await result.toBuffer();
return { buffer, filename, contentType: "image/jpeg" }; return { buffer, filename, contentType: outputFormat.contentType };
}, },
}); });
} }
+27 -2
View File
@@ -27,8 +27,14 @@ import { useMobile } from "@/hooks/use-mobile";
import { formatFileSize } from "@/lib/download"; import { formatFileSize } from "@/lib/download";
import { ICON_MAP } from "@/lib/icon-map"; import { ICON_MAP } from "@/lib/icon-map";
import { getToolRegistryEntry } from "@/lib/tool-registry"; import { getToolRegistryEntry } from "@/lib/tool-registry";
import { useBase64Store } from "@/stores/base64-store";
import { useCollageStore } from "@/stores/collage-store";
import { useDuplicateStore } from "@/stores/duplicate-store";
import { useFeaturesStore } from "@/stores/features-store"; import { useFeaturesStore } from "@/stores/features-store";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
import { useQrStore } from "@/stores/qr-store";
import { useSplitStore } from "@/stores/split-store";
/** Formats that browsers can render in <img> tags. */ /** Formats that browsers can render in <img> tags. */
const BROWSER_PREVIEWABLE_EXTS = new Set([ const BROWSER_PREVIEWABLE_EXTS = new Set([
@@ -202,11 +208,30 @@ export function ToolPage() {
// Center of the painted mask as a 0-100 percentage — used to init the slider at the right spot // Center of the painted mask as a 0-100 percentage — used to init the slider at the right spot
const [eraserSliderInitPos, setEraserSliderInitPos] = useState<number | null>(null); const [eraserSliderInitPos, setEraserSliderInitPos] = useState<number | null>(null);
// Reset crop state when the image changes // biome-ignore lint/correctness/useExhaustiveDependencies: toolId triggers intentional reset on tool navigation
useEffect(() => { useEffect(() => {
useFileStore.getState().undoProcessing();
useBase64Store.getState().reset();
useCollageStore.getState().reset();
useDuplicateStore.getState().reset();
usePdfToImageStore.getState().reset();
useQrStore.getState().reset();
useSplitStore.getState().reset();
setPreviewTransform(null);
setPreviewFilter("");
setImageWrapperStyle(null);
setBgPreview(null);
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 }); setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
setCropAspect(undefined);
setCropShowGrid(true);
setCropImgDimensions(null); setCropImgDimensions(null);
}, []); setEraserHasStrokes(false);
setEraserBrushSize(30);
setEraserSliderInitPos(null);
setMobileSettingsOpen(true);
}, [toolId]);
const handleFiles = useCallback( const handleFiles = useCallback(
(newFiles: File[]) => { (newFiles: File[]) => {
+3
View File
@@ -297,6 +297,9 @@ export const useFileStore = create<FileState>((set, get) => ({
})); }));
set({ set({
entries: resetEntries, entries: resetEntries,
batchZipBlob: null,
batchZipFilename: null,
processing: false,
error: null, error: null,
files: deriveFiles(resetEntries), files: deriveFiles(resetEntries),
...deriveSelected(resetEntries, selectedIndex), ...deriveSelected(resetEntries, selectedIndex),
+11
View File
@@ -3,6 +3,17 @@ import sys
import json import json
import os import os
# Prevent PaddlePaddle C++ runtime from probing for CUDA on CPU-only systems.
# Without these, paddlepaddle-gpu can segfault during import on machines without
# a GPU, because the C++ layer attempts GPU initialization before Python-level
# device routing takes effect. Must run before any PaddleOCR import.
from gpu import gpu_available as _gpu_available
if not _gpu_available():
if not os.environ.get("FLAGS_use_cuda"):
os.environ["FLAGS_use_cuda"] = "0"
if not os.environ.get("FLAGS_use_cudnn"):
os.environ["FLAGS_use_cudnn"] = "0"
# Lazy-loaded VLM instance (stays resident in dispatcher process) # Lazy-loaded VLM instance (stays resident in dispatcher process)
_paddleocr_vl_instance = None _paddleocr_vl_instance = None
+1
View File
@@ -70,6 +70,7 @@ const FORMAT_MAP: Record<string, string> = {
png: "png", png: "png",
webp: "webp", webp: "webp",
avif: "avif", avif: "avif",
heif: "avif",
tiff: "tiff", tiff: "tiff",
gif: "gif", gif: "gif",
}; };
@@ -6,6 +6,7 @@ const FORMAT_MAP: Record<string, string> = {
png: "png", png: "png",
webp: "webp", webp: "webp",
avif: "avif", avif: "avif",
heif: "avif",
tiff: "tiff", tiff: "tiff",
gif: "gif", gif: "gif",
}; };
+243
View File
@@ -0,0 +1,243 @@
import { expect, test, uploadTestImage, waitForProcessing } from "./helpers";
// ---------------------------------------------------------------------------
// State-bleed audit: verify that processed state from one tool does NOT
// leak into another tool after navigation.
//
// Bug: navigating from a tool with processed results to a different tool
// shows stale processed state (download buttons, blob: URLs, before/after
// comparisons) in the right pane instead of a clean dropzone.
//
// Root cause hypothesis: the global Zustand file-store is not reset when
// the user navigates between tool routes — the previous tool's entries,
// processedUrl, and blob URLs persist into the next tool's view.
// ---------------------------------------------------------------------------
test.describe("State bleed between tools", () => {
// ── Core scenario: resize -> rotate ──────────────────────────────────
test("processed state from resize does NOT appear in rotate", async ({ loggedInPage: page }) => {
// Step 1: Navigate to resize and process an image
await page.goto("/resize");
await uploadTestImage(page);
await page.locator("input[placeholder='Auto']").first().fill("50");
await page.getByRole("button", { name: "Resize" }).click();
await waitForProcessing(page);
// Confirm processed state is present (download link visible)
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
});
// Step 2: Navigate to the rotate tool via direct URL
await page.goto("/rotate");
await page.waitForLoadState("networkidle");
// Step 3: The right pane should be in a clean state — dropzone visible
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
// Step 4: No stale download link from resize should be present
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
// Step 5: No blob: URLs in image elements (would indicate stale processed images)
const blobImages = page.locator("img[src^='blob:']");
await expect(blobImages).toHaveCount(0);
// Step 6: No before/after comparison slider should be visible
await expect(page.locator("[class*='before-after'], [class*='BeforeAfter']")).not.toBeVisible();
});
// ── Reverse direction: rotate -> resize ──────────────────────────────
test("processed state from rotate does NOT appear in resize", async ({ loggedInPage: page }) => {
// Process an image in rotate
await page.goto("/rotate");
await uploadTestImage(page);
await page.getByTestId("rotate-right").click();
await expect(page.locator("input[inputmode='numeric']")).toHaveValue("90", { timeout: 2000 });
await page.getByTestId("rotate-submit").click();
await waitForProcessing(page);
await expect(
page
.getByRole("button", { name: /^download$/i })
.or(page.getByRole("link", { name: /download/i }))
.first(),
).toBeVisible({ timeout: 15_000 });
// Navigate to resize
await page.goto("/resize");
await page.waitForLoadState("networkidle");
// Should show clean dropzone
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
});
// ── Navigate from dropzone tool to no-dropzone tool (QR Generate) ────
test("processed state from compress does NOT appear in QR Generate", async ({
loggedInPage: page,
}) => {
// Process an image in compress
await page.goto("/compress");
await uploadTestImage(page);
await page.getByRole("button", { name: "Compress" }).click();
await waitForProcessing(page);
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
});
// Navigate to QR Generate (no-dropzone tool)
await page.goto("/qr-generate");
await page.waitForLoadState("networkidle");
// QR Generate should NOT show any file upload state or stale results
await expect(page.getByText("Upload from computer")).not.toBeVisible();
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
// The QR input should be clean and functional
await expect(page.getByTestId("qr-input-url")).toBeVisible();
// No stale processed images from compress should be visible
const blobImages = page.locator("img[src^='blob:']");
await expect(blobImages).toHaveCount(0);
});
// ── Navigate from no-dropzone tool back to dropzone tool ─────────────
test("QR Generate state does NOT bleed into resize", async ({ loggedInPage: page }) => {
// Use QR Generate first
await page.goto("/qr-generate");
await page.getByTestId("qr-input-url").fill("https://example.com");
await expect(page.locator("canvas, svg").first()).toBeVisible({ timeout: 5000 });
// Navigate to resize
await page.goto("/resize");
await page.waitForLoadState("networkidle");
// Resize should show a clean dropzone
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("QR")).not.toBeVisible();
});
// ── Multiple images: upload several files, process, navigate ─────────
test("multi-file processed state does NOT bleed across tools", async ({ loggedInPage: page }) => {
// Upload and process in resize
await page.goto("/resize");
await uploadTestImage(page);
// Add a second file by uploading again via the "+ Add more" button
const addMoreBtn = page.getByText("+ Add more");
if (await addMoreBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
const fileChooserPromise = page.waitForEvent("filechooser");
await addMoreBtn.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
require("node:path").join(process.cwd(), "tests", "fixtures", "test-50x50.webp"),
);
await page.waitForTimeout(500);
}
// Process all files
await page.locator("input[placeholder='Auto']").first().fill("25");
await page.getByRole("button", { name: "Resize" }).click();
await waitForProcessing(page);
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
});
// Navigate to convert
await page.goto("/convert");
await page.waitForLoadState("networkidle");
// Should show clean state — no leftover files or processed results
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
// No thumbnail strip from previous multi-file upload
const thumbnails = page.locator("[aria-label='Previous image'], [aria-label='Next image']");
await expect(thumbnails).toHaveCount(0);
});
// ── Sidebar navigation (not just goto) ───────────────────────────────
test("sidebar navigation clears processed state", async ({ loggedInPage: page }) => {
// Process an image in resize
await page.goto("/resize");
await uploadTestImage(page);
await page.locator("input[placeholder='Auto']").first().fill("50");
await page.getByRole("button", { name: "Resize" }).click();
await waitForProcessing(page);
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
});
// Navigate home via sidebar
await page.locator("aside").getByText("Tools").click();
await expect(page).toHaveURL("/");
// Home page should show clean dropzone, not stale resize results
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
});
// ── Rapid navigation: process -> navigate -> navigate again ──────────
test("rapid sequential navigation does not accumulate stale state", async ({
loggedInPage: page,
}) => {
// Process in resize
await page.goto("/resize");
await uploadTestImage(page);
await page.locator("input[placeholder='Auto']").first().fill("50");
await page.getByRole("button", { name: "Resize" }).click();
await waitForProcessing(page);
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
});
// Navigate to rotate, then immediately to compress, then to convert
await page.goto("/rotate");
await page.goto("/compress");
await page.goto("/convert");
await page.waitForLoadState("networkidle");
// The final destination (convert) should have a completely clean state
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
const blobImages = page.locator("img[src^='blob:']");
await expect(blobImages).toHaveCount(0);
});
// ── Verify processed status text is not carried over ─────────────────
test("completion indicators from one tool do not appear in another", async ({
loggedInPage: page,
}) => {
// Process in compress (shows size comparison after processing)
await page.goto("/compress");
await uploadTestImage(page);
await page.getByRole("button", { name: "Compress" }).click();
await waitForProcessing(page);
await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({
timeout: 15_000,
});
// Navigate to crop
await page.goto("/crop");
await page.waitForLoadState("networkidle");
// Crop should show clean dropzone, no processed-state UI
await expect(page.getByText("Upload from computer")).toBeVisible({ timeout: 5_000 });
// No "Conversion complete" or review-panel artifacts
await expect(page.getByText("Conversion complete")).not.toBeVisible();
// No download/undo buttons from compress should remain
await expect(page.getByRole("link", { name: /download/i })).not.toBeVisible();
await expect(page.getByRole("button", { name: /undo/i })).not.toBeVisible();
});
});
+51 -2
View File
@@ -821,6 +821,46 @@ function sanitizeFilename(raw: string): string {
if (!name || name === "." || name === "..") { if (!name || name === "." || name === "..") {
name = "upload"; name = "upload";
} }
// Guard against double-extension attacks (e.g. "image.png.php").
const dotIndex = name.indexOf(".");
if (dotIndex !== -1) {
const parts = name.split(".");
const SAFE_IMAGE_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".avif",
".svg",
".pdf",
]);
for (let i = 1; i < parts.length; i++) {
const ext = `.${parts[i].toLowerCase()}`;
if (SAFE_IMAGE_EXTENSIONS.has(ext)) {
name = parts.slice(0, i + 1).join(".");
break;
}
}
}
// Truncate to filesystem-safe length (255 byte NAME_MAX minus margin for _toolId suffix)
const MAX_NAME_BYTES = 200;
const enc = new TextEncoder();
if (enc.encode(name).length > MAX_NAME_BYTES) {
const dotIdx = name.lastIndexOf(".");
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
while (enc.encode(base + ext).length > MAX_NAME_BYTES) {
base = base.slice(0, -1);
}
name = base + ext;
}
return name; return name;
} }
@@ -961,9 +1001,18 @@ describe("sanitizeFilename", () => {
expect(sanitizeFilename("my..file..name.png")).toBe("myfilename.png"); expect(sanitizeFilename("my..file..name.png")).toBe("myfilename.png");
}); });
it("handles very long filenames", () => { it("handles very long filenames by truncating to filesystem-safe length", () => {
const longName = "a".repeat(500) + ".png"; const longName = "a".repeat(500) + ".png";
expect(sanitizeFilename(longName)).toBe(longName); const result = sanitizeFilename(longName);
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
expect(result).toMatch(/\.png$/);
});
it("truncates very long filenames to filesystem-safe length", () => {
const longName = "a".repeat(300) + ".jpg";
const result = sanitizeFilename(longName);
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
expect(result).toMatch(/\.jpg$/);
}); });
it("handles filename with only extension", () => { it("handles filename with only extension", () => {