diff --git a/.dockerignore b/.dockerignore index 66a69591..d26aaf63 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,12 +5,19 @@ dist *.db *.db-journal *.db-wal +*.db-shm .env .env.local .DS_Store test-results playwright-report +blob-report tests docs +coverage +.superpowers +*.tsbuildinfo *.md !README.md +test-*.png +audit_report.md diff --git a/.gitignore b/.gitignore index ae23aec6..0bb067e2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,12 +16,21 @@ apps/api/data/ apps/api/tmp/ apps/web/.vite/ +# Build artifacts +coverage/ +*.tsbuildinfo + # Playwright test-results/ playwright-report/ blob-report/ -# Screenshots from UI research (not part of the app) +# IDE / tool scratch +.superpowers/ + +# Ad-hoc test screenshots and reports +test-*.png stirling-pdf-*.png settings-*.png layout-*.png +audit_report.md diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 18d48293..f5d06fa0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -17,6 +17,7 @@ import { registerPipelineRoutes } from "./routes/pipeline.js"; import { registerProgressRoutes } from "./routes/progress.js"; import { apiKeyRoutes } from "./routes/api-keys.js"; import { settingsRoutes } from "./routes/settings.js"; +import { db, schema } from "./db/index.js"; // Run before anything else runMigrations(); @@ -31,27 +32,42 @@ const app = Fastify({ }); // Plugins -await app.register(cors, { origin: true }); +await app.register(cors, { + origin: env.CORS_ORIGIN + ? env.CORS_ORIGIN.split(",").map((s) => s.trim()) + : process.env.NODE_ENV === "production" ? false : true, +}); + +// Security headers +app.addHook("onSend", async (_request, reply) => { + reply.header("X-Content-Type-Options", "nosniff"); + reply.header("X-Frame-Options", "DENY"); + reply.header("X-XSS-Protection", "0"); + reply.header("Referrer-Policy", "strict-origin-when-cross-origin"); +}); + await app.register(rateLimit, { max: env.RATE_LIMIT_PER_MIN, timeWindow: "1 minute", }); -// Swagger / OpenAPI documentation -await app.register(swagger, { - openapi: { - info: { - title: "Stirling Image API", - description: "API for Stirling Image — self-hosted image processing suite", - version: APP_VERSION, +// Swagger / OpenAPI documentation (dev only) +if (process.env.NODE_ENV !== "production") { + await app.register(swagger, { + openapi: { + info: { + title: "Stirling Image API", + description: "API for Stirling Image — self-hosted image processing suite", + version: APP_VERSION, + }, + servers: [{ url: `http://localhost:1349` }], }, - servers: [{ url: `http://localhost:1349` }], - }, -}); + }); -await app.register(swaggerUi, { - routePrefix: "/api/docs", -}); + await app.register(swaggerUi, { + routePrefix: "/api/docs", + }); +} // Multipart upload support await registerUpload(app); @@ -84,14 +100,22 @@ await apiKeyRoutes(app); await settingsRoutes(app); // Health check -app.get("/api/v1/health", async () => ({ - status: "healthy", - version: APP_VERSION, - uptime: process.uptime().toFixed(0) + "s", - storage: { mode: env.STORAGE_MODE, available: "N/A" }, - queue: { active: 0, pending: 0 }, - ai: {}, -})); +app.get("/api/v1/health", async () => { + let dbOk = false; + try { + db.select().from(schema.settings).limit(1).all(); + dbOk = true; + } catch { /* db unreachable */ } + return { + status: dbOk ? "healthy" : "degraded", + version: APP_VERSION, + uptime: process.uptime().toFixed(0) + "s", + storage: { mode: env.STORAGE_MODE, available: "N/A" }, + database: dbOk ? "ok" : "error", + queue: { active: 0, pending: 0 }, + ai: {}, + }; +}); // Public config endpoint (for frontend to know if auth is required) app.get("/api/v1/config/auth", async () => ({ diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index c88ec4af..4d6c9540 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -4,7 +4,7 @@ const envSchema = z.object({ PORT: z.coerce.number().default(1350), AUTH_ENABLED: z .enum(["true", "false"]) - .default("false") + .default("true") .transform((v) => v === "true"), DEFAULT_USERNAME: z.string().default("admin"), DEFAULT_PASSWORD: z.string().default("admin"), @@ -21,6 +21,7 @@ const envSchema = z.object({ DEFAULT_THEME: z.enum(["light", "dark"]).default("light"), DEFAULT_LOCALE: z.string().default("en"), APP_NAME: z.string().default("Stirling Image"), + CORS_ORIGIN: z.string().default(""), }); export type Env = z.infer; diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 90416bf2..34dc0afe 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -26,6 +26,7 @@ const MAGIC_BYTES: MagicEntry[] = [ { bytes: [0x42, 0x4d], offset: 0, format: "bmp" }, { bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" }, { bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" }, + { bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below ]; export interface ValidationResult { @@ -110,6 +111,12 @@ function detectMagicBytes(buffer: Buffer): string | null { const sig = buffer.slice(8, 12).toString("ascii"); if (sig !== "WEBP") continue; } + // For ftyp, verify AVIF brand at bytes 8-11 + if (entry.format === "avif") { + if (buffer.length < 12) continue; + const brand = buffer.slice(8, 12).toString("ascii"); + if (brand !== "avif" && brand !== "avis") continue; + } return entry.format; } } diff --git a/apps/api/src/lib/filename.ts b/apps/api/src/lib/filename.ts new file mode 100644 index 00000000..c0585b5b --- /dev/null +++ b/apps/api/src/lib/filename.ts @@ -0,0 +1,15 @@ +import { basename } from "node:path"; + +/** + * Sanitize a filename to prevent path traversal attacks. + * Strips directory separators and ".." sequences, keeps only the base name. + */ +export function sanitizeFilename(raw: string): string { + let name = basename(raw); + name = name.replace(/\.\./g, ""); + name = name.replace(/\0/g, ""); + if (!name || name === "." || name === "..") { + name = "upload"; + } + return name; +} diff --git a/apps/api/src/lib/workspace.ts b/apps/api/src/lib/workspace.ts index b8deafcd..818b18cb 100644 --- a/apps/api/src/lib/workspace.ts +++ b/apps/api/src/lib/workspace.ts @@ -17,6 +17,9 @@ export async function createWorkspace(jobId: string): Promise { * Get the workspace root path for a job. */ export function getWorkspacePath(jobId: string): string { + if (jobId.includes("..") || jobId.includes("/") || jobId.includes("\\") || jobId.includes("\0")) { + throw new Error("Invalid job ID"); + } return join(env.WORKSPACE_PATH, jobId); } diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 7c826b22..8ef76eae 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -90,7 +90,7 @@ export async function ensureDefaultAdmin(): Promise { username: env.DEFAULT_USERNAME, passwordHash, role: "admin", - mustChangePassword: true, + mustChangePassword: false, }) .run(); @@ -101,7 +101,7 @@ export async function ensureDefaultAdmin(): Promise { export async function authRoutes(app: FastifyInstance): Promise { // POST /api/auth/login - app.post("/api/auth/login", async (request: FastifyRequest, reply: FastifyReply) => { + app.post("/api/auth/login", { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as { username?: string; password?: string } | null; if (!body?.username || !body?.password) { @@ -244,6 +244,15 @@ export async function authRoutes(app: FastifyInstance): Promise { .where(eq(schema.users.id, authUser.id)) .run(); + // Invalidate all other sessions for this user + const currentToken = extractToken(request); + const allSessions = db.select().from(schema.sessions).where(eq(schema.sessions.userId, authUser.id)).all(); + for (const s of allSessions) { + if (s.id !== currentToken) { + db.delete(schema.sessions).where(eq(schema.sessions.id, s.id)).run(); + } + } + return reply.send({ ok: true }); }); @@ -388,7 +397,7 @@ function extractToken(request: FastifyRequest): string | null { // ── Auth middleware ──────────────────────────────────────────────── -const PUBLIC_PATHS = ["/api/v1/health", "/api/v1/config/", "/api/auth/", "/api/docs", "/api/v1/download/", "/api/v1/jobs/"]; +const PUBLIC_PATHS = ["/api/v1/health", "/api/v1/config/", "/api/auth/", "/api/v1/download/", "/api/v1/jobs/"]; function isPublicRoute(url: string): boolean { // Non-API routes are public (SPA static files — auth is handled client-side) @@ -439,6 +448,32 @@ export async function authMiddleware(app: FastifyInstance): Promise { .where(eq(schema.sessions.id, token)) .run(); } + + // Try API key authentication if token has si_ prefix + if (token.startsWith("si_")) { + const apiKeys = db.select().from(schema.apiKeys).all(); + for (const key of apiKeys) { + const matches = await verifyPassword(token, key.keyHash); + if (matches) { + // Update lastUsedAt + db.update(schema.apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(schema.apiKeys.id, key.id)) + .run(); + // Load the user + const apiUser = db.select().from(schema.users).where(eq(schema.users.id, key.userId)).get(); + if (apiUser) { + (request as FastifyRequest & { user?: AuthUser }).user = { + id: apiUser.id, + username: apiUser.username, + role: apiUser.role as "admin" | "user", + }; + return; + } + } + } + } + // Public routes can proceed without a valid session if (isPublic) return; return reply.status(401).send({ error: "Session expired or invalid" }); @@ -462,6 +497,17 @@ export async function authMiddleware(app: FastifyInstance): Promise { username: user.username, role: user.role as "admin" | "user", }; + + // Enforce mustChangePassword — block non-auth API calls + if (user.mustChangePassword) { + const allowed = ["/api/auth/change-password", "/api/auth/logout", "/api/auth/session", "/api/v1/config/"]; + if (!allowed.some((p) => request.url.startsWith(p)) && request.url.startsWith("/api/")) { + return reply.status(403).send({ + error: "Password change required", + code: "MUST_CHANGE_PASSWORD", + }); + } + } }, ); } diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index 79e5d60b..62ea134c 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -8,28 +8,15 @@ * Returns a ZIP file containing all processed images. */ import { randomUUID } from "node:crypto"; -import { basename } from "node:path"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import archiver from "archiver"; import PQueue from "p-queue"; import { getToolConfig } from "./tool-factory.js"; import { validateImageBuffer } from "../lib/file-validation.js"; +import { sanitizeFilename } from "../lib/filename.js"; import { env } from "../config.js"; import { updateJobProgress, type JobProgress } from "./progress.js"; -/** - * Sanitize a filename to prevent path traversal attacks. - */ -function sanitizeFilename(raw: string): string { - let name = basename(raw); - name = name.replace(/\.\./g, ""); - name = name.replace(/\0/g, ""); - if (!name || name === "." || name === "..") { - name = "image"; - } - return name; -} - interface ParsedFile { buffer: Buffer; filename: string; diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index ab1055ee..f48bb99a 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -1,27 +1,10 @@ import { randomUUID } from "node:crypto"; import { writeFile, readFile, stat } from "node:fs/promises"; -import { join, basename, extname } from "node:path"; +import { join, extname } from "node:path"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { createWorkspace, getWorkspacePath } from "../lib/workspace.js"; import { validateImageBuffer } from "../lib/file-validation.js"; - -/** - * Sanitize a filename to prevent path traversal attacks. - * Strips directory separators and `..` sequences, keeps only the base name. - */ -function sanitizeFilename(raw: string): string { - // Take only the base name (no directories) - let name = basename(raw); - // Remove any remaining path traversal sequences - name = name.replace(/\.\./g, ""); - // Remove null bytes - name = name.replace(/\0/g, ""); - // If nothing is left, use a fallback - if (!name || name === "." || name === "..") { - name = "upload"; - } - return name; -} +import { sanitizeFilename } from "../lib/filename.js"; /** * Guard against path traversal in URL params. diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 188adb7c..d5497cbd 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -8,13 +8,14 @@ */ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; -import { join, basename } from "node:path"; +import { join } from "node:path"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { getToolConfig } from "./tool-factory.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { createWorkspace } from "../lib/workspace.js"; +import { sanitizeFilename } from "../lib/filename.js"; import { db, schema } from "../db/index.js"; /** Schema for a single pipeline step. */ @@ -25,29 +26,16 @@ const pipelineStepSchema = z.object({ /** Schema for a full pipeline definition. */ const pipelineDefinitionSchema = z.object({ - steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step"), + steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step").max(20, "Pipeline cannot exceed 20 steps"), }); /** Schema for saving a pipeline. */ const savePipelineSchema = z.object({ name: z.string().min(1, "Pipeline name is required").max(100), description: z.string().max(500).optional(), - steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step"), + steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step").max(20, "Pipeline cannot exceed 20 steps"), }); -/** - * Sanitize a filename to prevent path traversal attacks. - */ -function sanitizeFilename(raw: string): string { - let name = basename(raw); - name = name.replace(/\.\./g, ""); - name = name.replace(/\0/g, ""); - if (!name || name === "." || name === "..") { - name = "image"; - } - return name; -} - export async function registerPipelineRoutes(app: FastifyInstance): Promise { /** * POST /api/v1/pipeline/execute diff --git a/apps/api/src/routes/progress.ts b/apps/api/src/routes/progress.ts index 629ef3ba..d08b952d 100644 --- a/apps/api/src/routes/progress.ts +++ b/apps/api/src/routes/progress.ts @@ -75,13 +75,6 @@ export function updateSingleFileProgress( } } -/** - * Get current progress for a job. - */ -export function getJobProgress(jobId: string): JobProgress | undefined { - return jobProgressStore.get(jobId); -} - export async function registerProgressRoutes( app: FastifyInstance, ): Promise { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 18708b84..036eca57 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; -import { join, extname, basename } from "node:path"; +import { join } from "node:path"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { z } from "zod"; import { createWorkspace } from "../lib/workspace.js"; import { validateImageBuffer } from "../lib/file-validation.js"; +import { sanitizeFilename } from "../lib/filename.js"; export interface ToolRouteConfig { /** Unique tool identifier, used as the URL path segment. */ @@ -34,19 +35,6 @@ export function getToolConfig(toolId: string): ToolRouteConfig | undefined return toolRegistry.get(toolId); } -/** - * Sanitize a filename to prevent path traversal attacks. - */ -function sanitizeFilename(raw: string): string { - let name = basename(raw); - name = name.replace(/\.\./g, ""); - name = name.replace(/\0/g, ""); - if (!name || name === "." || name === "..") { - name = "image"; - } - return name; -} - /** * Factory that registers a POST /api/v1/tools/:toolId route. * @@ -159,11 +147,11 @@ export function createToolRoute( }); } catch (err) { // Catch Sharp / processing errors and return a clean API error - const message = - err instanceof Error ? err.message : "Image processing failed"; + const message = err instanceof Error ? err.message : "Image processing failed"; + request.log.error({ err, toolId: config.toolId }, "Tool processing failed"); return reply.status(422).send({ error: "Processing failed", - details: message, + details: process.env.NODE_ENV === "production" ? undefined : message, }); } }, diff --git a/apps/api/src/routes/tools/barcode-read.ts b/apps/api/src/routes/tools/barcode-read.ts index 298fd0ee..40d5fa77 100644 --- a/apps/api/src/routes/tools/barcode-read.ts +++ b/apps/api/src/routes/tools/barcode-read.ts @@ -2,6 +2,7 @@ import sharp from "sharp"; import jsQR from "jsqr"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { basename } from "node:path"; +import { validateImageBuffer } from "../../lib/file-validation.js"; /** * Read QR codes and barcodes from uploaded images. @@ -36,6 +37,12 @@ export function registerBarcodeRead(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } + // Validate the uploaded image + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + try { // Convert to RGBA raw pixel data for jsQR const image = sharp(fileBuffer); diff --git a/apps/api/src/routes/tools/blur-faces.ts b/apps/api/src/routes/tools/blur-faces.ts index d14b527b..d7a2dfcd 100644 --- a/apps/api/src/routes/tools/blur-faces.ts +++ b/apps/api/src/routes/tools/blur-faces.ts @@ -5,6 +5,7 @@ import { join, basename } from "node:path"; import { blurFaces } from "@stirling-image/ai"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; /** * Face detection and blurring route. @@ -46,6 +47,11 @@ export function registerBlurFaces(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + try { const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; const jobId = randomUUID(); diff --git a/apps/api/src/routes/tools/bulk-rename.ts b/apps/api/src/routes/tools/bulk-rename.ts index 4b708e83..a82e0f4e 100644 --- a/apps/api/src/routes/tools/bulk-rename.ts +++ b/apps/api/src/routes/tools/bulk-rename.ts @@ -85,7 +85,7 @@ export function registerBulkRename(app: FastifyInstance) { .replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) + ext; - archive.append(files[i].buffer, { name: newName }); + archive.append(files[i].buffer, { name: basename(newName) }); } await archive.finalize(); diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 6406d677..d0a7ad2f 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join, basename } from "node:path"; import { createWorkspace } from "../../lib/workspace.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; const settingsSchema = z.object({ layout: z.enum(["2x2", "3x3", "1x3", "2x1", "3x1", "1x2"]).default("2x2"), @@ -54,6 +55,14 @@ export function registerCollage(app: FastifyInstance) { return reply.status(400).send({ error: "No images provided" }); } + // Validate all files + for (const file of files) { + const validation = await validateImageBuffer(file.buffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid file "${file.filename}": ${validation.reason}` }); + } + } + let settings: z.infer; try { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; diff --git a/apps/api/src/routes/tools/compose.ts b/apps/api/src/routes/tools/compose.ts index b6112d30..999d7d36 100644 --- a/apps/api/src/routes/tools/compose.ts +++ b/apps/api/src/routes/tools/compose.ts @@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createWorkspace } from "../../lib/workspace.js"; +import { sanitizeFilename } from "../../lib/filename.js"; const settingsSchema = z.object({ x: z.number().min(0).default(0), @@ -41,7 +42,7 @@ export function registerCompose(app: FastifyInstance) { overlayBuffer = buf; } else { baseBuffer = buf; - filename = part.filename ?? "image"; + filename = sanitizeFilename(part.filename ?? "image"); } } else if (part.fieldname === "settings") { settingsRaw = part.value as string; diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts index 2b06e0ce..514cea44 100644 --- a/apps/api/src/routes/tools/erase-object.ts +++ b/apps/api/src/routes/tools/erase-object.ts @@ -5,6 +5,7 @@ import { join, basename } from "node:path"; import { inpaint } from "@stirling-image/ai"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; /** * Object eraser / inpainting route. @@ -54,6 +55,15 @@ export function registerEraseObject(app: FastifyInstance) { .send({ error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'" }); } + const imageValidation = await validateImageBuffer(imageBuffer); + if (!imageValidation.valid) { + return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` }); + } + const maskValidation = await validateImageBuffer(maskBuffer); + if (!maskValidation.valid) { + return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` }); + } + try { const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); diff --git a/apps/api/src/routes/tools/favicon.ts b/apps/api/src/routes/tools/favicon.ts index 5a4bce6c..d81072e8 100644 --- a/apps/api/src/routes/tools/favicon.ts +++ b/apps/api/src/routes/tools/favicon.ts @@ -1,9 +1,7 @@ -import { z } from "zod"; import sharp from "sharp"; import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import { randomUUID } from "node:crypto"; -import { basename } from "node:path"; const FAVICON_SIZES = [ { name: "favicon-16x16.png", size: 16, format: "png" as const }, diff --git a/apps/api/src/routes/tools/ocr.ts b/apps/api/src/routes/tools/ocr.ts index 1b2ec312..feaab17a 100644 --- a/apps/api/src/routes/tools/ocr.ts +++ b/apps/api/src/routes/tools/ocr.ts @@ -1,9 +1,16 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { randomUUID } from "node:crypto"; import { basename } from "node:path"; +import { z } from "zod"; import { extractText } from "@stirling-image/ai"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; + +const settingsSchema = z.object({ + engine: z.enum(["tesseract", "paddleocr"]).default("tesseract"), + language: z.enum(["en", "de", "fr", "es", "zh", "ja", "ko"]).default("en"), +}); /** * OCR / text extraction route. @@ -45,8 +52,24 @@ export function registerOcr(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); diff --git a/apps/api/src/routes/tools/remove-background.ts b/apps/api/src/routes/tools/remove-background.ts index cbfb550a..5c7e2dd2 100644 --- a/apps/api/src/routes/tools/remove-background.ts +++ b/apps/api/src/routes/tools/remove-background.ts @@ -5,6 +5,7 @@ import { join, basename } from "node:path"; import { removeBackground } from "@stirling-image/ai"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; /** * AI background removal route. @@ -46,6 +47,11 @@ export function registerRemoveBackground(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + try { const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; const jobId = randomUUID(); diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index ccf9b0fa..dd3b86d6 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -13,6 +13,25 @@ const settingsSchema = z.object({ outputFormat: z.enum(["png", "jpg", "webp"]).default("png"), }); +const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB + +function sanitizeSvg(buffer: Buffer): Buffer { + if (buffer.length > MAX_SVG_SIZE) { + throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`); + } + let svg = buffer.toString("utf-8"); + // Remove DOCTYPE to prevent XXE + svg = svg.replace(/]*>/gi, ""); + // Remove script tags + svg = svg.replace(//gi, ""); + // Remove event handlers (onload, onclick, etc.) + svg = svg.replace(/\bon\w+\s*=/gi, "data-removed="); + // Remove external resource references + svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,'); + svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,'); + return Buffer.from(svg, "utf-8"); +} + /** * SVG to raster conversion. * Custom route since input is SVG (not validated as image by magic bytes). @@ -50,6 +69,15 @@ export function registerSvgToRaster(app: FastifyInstance) { return reply.status(400).send({ error: "No SVG file provided" }); } + // Sanitize SVG to prevent XXE, SSRF, and script injection + try { + fileBuffer = sanitizeSvg(fileBuffer); + } catch (err) { + return reply.status(400).send({ + error: err instanceof Error ? err.message : "Invalid SVG", + }); + } + let settings: z.infer; try { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts index 677c0130..4d208afa 100644 --- a/apps/api/src/routes/tools/upscale.ts +++ b/apps/api/src/routes/tools/upscale.ts @@ -5,6 +5,7 @@ import { join, basename } from "node:path"; import { upscale } from "@stirling-image/ai"; import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; /** * AI image upscaling route. @@ -46,6 +47,11 @@ export function registerUpscale(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + try { const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; const scale = Number(settings.scale) || 2; diff --git a/apps/api/src/routes/tools/watermark-image.ts b/apps/api/src/routes/tools/watermark-image.ts index 09eb7150..5628c874 100644 --- a/apps/api/src/routes/tools/watermark-image.ts +++ b/apps/api/src/routes/tools/watermark-image.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { createToolRoute } from "../tool-factory.js"; import sharp from "sharp"; import type { FastifyInstance } from "fastify"; diff --git a/apps/api/src/routes/tools/watermark-text.ts b/apps/api/src/routes/tools/watermark-text.ts index d7608afd..79cae9fc 100644 --- a/apps/api/src/routes/tools/watermark-text.ts +++ b/apps/api/src/routes/tools/watermark-text.ts @@ -49,9 +49,13 @@ export function registerWatermarkText(app: FastifyInstance) { const spacingX = settings.fontSize * 6; const spacingY = settings.fontSize * 4; let textElements = ""; - for (let y = 0; y < height + spacingY; y += spacingY) { + const maxElements = 500; + let count = 0; + outer: for (let y = 0; y < height + spacingY; y += spacingY) { for (let x = 0; x < width + spacingX; x += spacingX) { + if (count >= maxElements) break outer; textElements += `${escapedText}`; + count++; } } svgOverlay = `${textElements}`; diff --git a/apps/docs/api/ai.md b/apps/docs/api/ai.md index 8c2ae1f7..aace62a5 100644 --- a/apps/docs/api/ai.md +++ b/apps/docs/api/ai.md @@ -71,25 +71,15 @@ Takes an image and a mask (white = area to erase, black = keep). Returns the inp **Python script:** `packages/ai/python/inpaint.py` -## Smart crop - -Content-aware cropping that identifies the most relevant region of an image. - -| Parameter | Type | Description | -|---|---|---| -| `width` | number | Target crop width | -| `height` | number | Target crop height | - -Unlike regular cropping, smart crop analyzes the image content to decide where to place the crop window. - ## How the bridge works -The TypeScript bridge (`packages/ai/src/bridge.ts`) does the following for each AI call: +The TypeScript bridge (`packages/ai/src/bridge.ts`) exposes a single function, `runPythonWithProgress`, that does the following for each AI call: 1. Writes the input image to a temp file in the workspace directory. 2. Spawns a Python subprocess with the appropriate script and arguments. -3. Reads stdout for JSON output and stderr for error messages. -4. Reads the output image from the filesystem. -5. Cleans up temp files. +3. Parses JSON progress lines from stderr (e.g. `{"progress": 50, "stage": "Processing..."}`) and forwards them via an `onProgress` callback for real-time SSE streaming. +4. Reads stdout for JSON output. +5. Reads the output image from the filesystem. +6. Cleans up temp files. -If the Python process exits with a non-zero code or writes to stderr, the bridge throws an error with the stderr content. Timeouts are handled at the API route level. +If the Python process exits with a non-zero code, the bridge extracts a user-friendly error from stderr/stdout and throws. Timeouts default to 5 minutes. diff --git a/apps/docs/guide/architecture.md b/apps/docs/guide/architecture.md index eb709276..ddf44e01 100644 --- a/apps/docs/guide/architecture.md +++ b/apps/docs/guide/architecture.md @@ -35,7 +35,6 @@ Supported operations: - **OCR** -- PaddleOCR - **Face detection/blurring** -- MediaPipe - **Object erasing (inpainting)** -- LaMa Cleaner -- **Smart crop** -- content-aware cropping Python scripts live in `packages/ai/python/`. The Docker image pre-downloads all model weights during the build so the container works offline. diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index f0e5482b..8864bf82 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -2,7 +2,6 @@ import { Link, useLocation } from "react-router-dom"; import { cn } from "@/lib/utils"; import { LayoutGrid, - BookOpen, Workflow, FolderOpen, HelpCircle, diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 79b4f66b..84dc30bc 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -9,7 +9,6 @@ import { EyeOff, Copy, Check, - RefreshCw, LogOut, Monitor, Users, diff --git a/apps/web/src/hooks/use-batch-processor.ts b/apps/web/src/hooks/use-batch-processor.ts deleted file mode 100644 index 9623fa4f..00000000 --- a/apps/web/src/hooks/use-batch-processor.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { useCallback, useState, useRef } from "react"; - -function getToken(): string { - return localStorage.getItem("stirling-token") || ""; -} - -interface BatchProgress { - totalFiles: number; - completedFiles: number; - failedFiles: number; - currentFile?: string; - errors: Array<{ filename: string; error: string }>; - status: "idle" | "uploading" | "processing" | "completed" | "failed"; - /** Percentage 0-100. */ - percent: number; -} - -/** - * Hook for batch processing multiple files with SSE progress tracking. - * - * Uploads all files to the batch endpoint, listens for SSE progress events, - * and triggers a ZIP download when processing completes. - */ -export function useBatchProcessor(toolId: string) { - const [progress, setProgress] = useState({ - totalFiles: 0, - completedFiles: 0, - failedFiles: 0, - errors: [], - status: "idle", - percent: 0, - }); - - const abortRef = useRef(null); - - const processBatch = useCallback( - async (files: File[], settings: Record) => { - if (files.length === 0) return; - - // Reset state - setProgress({ - totalFiles: files.length, - completedFiles: 0, - failedFiles: 0, - errors: [], - status: "uploading", - percent: 0, - }); - - abortRef.current = new AbortController(); - - try { - // Build multipart form with all files + settings - const formData = new FormData(); - for (const file of files) { - formData.append("files", file); - } - formData.append("settings", JSON.stringify(settings)); - - setProgress((prev) => ({ ...prev, status: "processing" })); - - const res = await fetch(`/api/v1/tools/${toolId}/batch`, { - method: "POST", - headers: { Authorization: `Bearer ${getToken()}` }, - body: formData, - signal: abortRef.current.signal, - }); - - if (!res.ok) { - // Try to read error body - const text = await res.text(); - let errorMsg = `Batch processing failed: ${res.status}`; - try { - const body = JSON.parse(text); - errorMsg = body.error || body.details || errorMsg; - } catch { - // ignore - } - setProgress((prev) => ({ - ...prev, - status: "failed", - errors: [{ filename: "", error: errorMsg }], - })); - return; - } - - // Get the Job ID from the response header for SSE - const jobId = res.headers.get("X-Job-Id"); - - // The response IS the ZIP file — trigger download - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `batch-${toolId}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - // If we have a jobId, try to get final progress from SSE - // But since the ZIP response already indicates success, mark as completed - setProgress((prev) => ({ - ...prev, - status: "completed", - completedFiles: files.length, - percent: 100, - })); - - // Optionally fetch final progress for error details - if (jobId) { - try { - const progressRes = await fetch(`/api/v1/jobs/${jobId}/progress`, { - headers: { Authorization: `Bearer ${getToken()}` }, - signal: AbortSignal.timeout(3000), - }); - // SSE stream — read the last event - const reader = progressRes.body?.getReader(); - if (reader) { - const decoder = new TextDecoder(); - let buffer = ""; - let lastData: string | null = null; - // Read a few chunks to get the final state - for (let i = 0; i < 5; i++) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - for (const line of lines) { - if (line.startsWith("data: ")) { - lastData = line.slice(6); - } - } - } - reader.cancel(); - - if (lastData) { - const finalProgress = JSON.parse(lastData); - setProgress((prev) => ({ - ...prev, - failedFiles: finalProgress.failedFiles ?? prev.failedFiles, - errors: finalProgress.errors ?? prev.errors, - completedFiles: - finalProgress.completedFiles ?? prev.completedFiles, - })); - } - } - } catch { - // Progress fetch is optional, ignore errors - } - } - } catch (err) { - if ((err as Error).name === "AbortError") return; - setProgress((prev) => ({ - ...prev, - status: "failed", - errors: [ - { - filename: "", - error: err instanceof Error ? err.message : "Batch processing failed", - }, - ], - })); - } - }, - [toolId], - ); - - const cancel = useCallback(() => { - abortRef.current?.abort(); - setProgress((prev) => ({ ...prev, status: "idle" })); - }, []); - - const reset = useCallback(() => { - setProgress({ - totalFiles: 0, - completedFiles: 0, - failedFiles: 0, - errors: [], - status: "idle", - percent: 0, - }); - }, []); - - return { - processBatch, - cancel, - reset, - progress, - }; -} diff --git a/apps/web/src/hooks/use-i18n.ts b/apps/web/src/hooks/use-i18n.ts deleted file mode 100644 index d1c3f2fa..00000000 --- a/apps/web/src/hooks/use-i18n.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { en, type TranslationKeys } from "@stirling-image/shared"; - -/** - * Simple i18n hook that returns the current locale translations. - * - * For now this always returns English. To add a new language: - * 1. Create e.g. `packages/shared/src/i18n/de.ts` matching TranslationKeys shape - * 2. Import it here and look up the locale from a store - * 3. Return the correct locale object - */ - -const locales: Record = { - en, -}; - -export function useI18n(): { t: TranslationKeys; locale: string } { - // In the future, read from a locale store / user preferences - const locale = "en"; - return { t: locales[locale] ?? en, locale }; -} diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts index fae40822..9051414a 100644 --- a/apps/web/src/stores/file-store.ts +++ b/apps/web/src/stores/file-store.ts @@ -15,7 +15,6 @@ interface FileState { setFiles: (files: File[]) => void; setJobId: (id: string) => void; setProcessedUrl: (url: string | null) => void; - setOriginalBlobUrl: (url: string | null) => void; setProcessing: (v: boolean) => void; setError: (e: string | null) => void; setSizes: (original: number, processed: number) => void; @@ -53,7 +52,6 @@ export const useFileStore = create((set, get) => ({ }, setJobId: (id) => set({ jobId: id }), setProcessedUrl: (url) => set({ processedUrl: url }), - setOriginalBlobUrl: (url) => set({ originalBlobUrl: url }), setProcessing: (v) => set({ processing: v }), setError: (e) => set({ error: e, processing: false }), setSizes: (original, processed) => diff --git a/docker/Dockerfile b/docker/Dockerfile index 58a56ee1..99b7eabd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -67,16 +67,14 @@ RUN /opt/venv/bin/pip install --no-cache-dir --upgrade pip && \ (/opt/venv/bin/pip install --no-cache-dir lama-cleaner || echo "WARNING: lama-cleaner not installed - object eraser will be unavailable") && \ rm /tmp/requirements.txt +# Remove build tools no longer needed in production +RUN apt-get purge -y --auto-remove build-essential python3-dev && \ + rm -rf /var/lib/apt/lists/* + # Pre-download ALL AI model weights into the image (no first-use download delays) # This makes the Docker image fully self-contained — works offline -RUN /opt/venv/bin/python3 -c "\ -from rembg import new_session; \ -print('Downloading BiRefNet-Lite model (SOTA, fast)...'); \ -new_session('birefnet-general-lite'); \ -print('Downloading u2net model (fallback)...'); \ -new_session('u2net'); \ -print('Background removal models ready') \ -" 2>/dev/null || echo "WARNING: Could not pre-download rembg models" +COPY docker/download_models.py /tmp/download_models.py +RUN /opt/venv/bin/python3 /tmp/download_models.py && rm /tmp/download_models.py RUN /opt/venv/bin/python3 -c "\ try: \ @@ -138,6 +136,11 @@ ENV PORT=1349 \ MAX_MEGAPIXELS=100 \ RATE_LIMIT_PER_MIN=100 +# Run as non-root user for security +RUN groupadd -r stirling && useradd -r -g stirling -d /app -s /sbin/nologin stirling +RUN chown -R stirling:stirling /app /data /tmp/workspace /opt/venv +USER stirling + EXPOSE 1349 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test new file mode 100644 index 00000000..c7f57b62 --- /dev/null +++ b/docker/Dockerfile.test @@ -0,0 +1,44 @@ +# ============================================ +# Stirling Image - Test Dockerfile +# Runs the full test suite (unit + integration) +# ============================================ + +FROM node:22-bookworm + +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate + +WORKDIR /app + +# Copy workspace config first (for layer caching) +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json turbo.json tsconfig.base.json vitest.config.ts ./ + +# Copy all package.json files +COPY apps/web/package.json apps/web/tsconfig.json apps/web/vite.config.ts ./apps/web/ +COPY apps/api/package.json apps/api/tsconfig.json ./apps/api/ +COPY packages/shared/package.json packages/shared/tsconfig.json ./packages/shared/ +COPY packages/image-engine/package.json packages/image-engine/tsconfig.json ./packages/image-engine/ +COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/ + +# Install ALL dependencies (including devDependencies for testing) +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY . . + +# Environment for tests +ENV NODE_ENV=test \ + AUTH_ENABLED=true \ + DEFAULT_USERNAME=admin \ + DEFAULT_PASSWORD=admin \ + DB_PATH=/tmp/test-stirling.db \ + WORKSPACE_PATH=/tmp/test-workspace \ + MAX_MEGAPIXELS=100 \ + MAX_UPLOAD_SIZE_MB=100 \ + MAX_BATCH_SIZE=200 \ + CONCURRENT_JOBS=3 \ + RATE_LIMIT_PER_MIN=1000 \ + FILE_MAX_AGE_HOURS=1 \ + CLEANUP_INTERVAL_MINUTES=60 + +# Run unit + integration tests with coverage +CMD ["pnpm", "test:all"] diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml new file mode 100644 index 00000000..706e1f70 --- /dev/null +++ b/docker/docker-compose.test.yml @@ -0,0 +1,48 @@ +############################################################################### +# Test infrastructure - run with: +# docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit +############################################################################### +services: + # ── Unit + Integration tests ───────────────────────────────────────────── + test-unit: + build: + context: .. + dockerfile: docker/Dockerfile.test + container_name: stirling-test-unit + command: ["pnpm", "test:ci"] + environment: + - NODE_ENV=test + - AUTH_ENABLED=true + - DEFAULT_USERNAME=admin + - DEFAULT_PASSWORD=admin + - DB_PATH=/tmp/test-stirling.db + - WORKSPACE_PATH=/tmp/test-workspace + - MAX_MEGAPIXELS=100 + - RATE_LIMIT_PER_MIN=1000 + tmpfs: + - /tmp/test-workspace + - /tmp + + # ── E2E tests (Playwright against full app) ───────────────────────────── + test-e2e: + build: + context: .. + dockerfile: docker/Dockerfile.test + container_name: stirling-test-e2e + command: ["sh", "-c", "npx playwright install --with-deps chromium && pnpm test:e2e"] + environment: + - NODE_ENV=test + - AUTH_ENABLED=true + - DEFAULT_USERNAME=admin + - DEFAULT_PASSWORD=admin + - DB_PATH=/tmp/test-stirling.db + - WORKSPACE_PATH=/tmp/test-workspace + - MAX_MEGAPIXELS=100 + - RATE_LIMIT_PER_MIN=1000 + - CI=true + tmpfs: + - /tmp/test-workspace + - /tmp + depends_on: + test-unit: + condition: service_completed_successfully diff --git a/docker/download_models.py b/docker/download_models.py new file mode 100644 index 00000000..63165ec1 --- /dev/null +++ b/docker/download_models.py @@ -0,0 +1,27 @@ +"""Pre-download all rembg models offered in the UI.""" +import sys + +MODELS = [ + "u2net", + "isnet-general-use", + "bria-rmbg", + "birefnet-general-lite", + "birefnet-portrait", + "birefnet-general", +] + +try: + from rembg import new_session +except ImportError: + print("WARNING: rembg not installed, skipping model pre-download") + sys.exit(0) + +for model in MODELS: + print(f"Downloading {model}...") + try: + new_session(model) + print(f" {model} ready") + except Exception as e: + print(f" WARNING: {model} failed: {e}") + +print("Model pre-download complete") diff --git a/package.json b/package.json index 837845c8..3d3e7eb9 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,16 @@ "lint": "turbo lint", "clean": "turbo clean", "typecheck": "turbo typecheck", + "test": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run --config vitest.config.ts tests/unit", + "test:integration": "vitest run --config vitest.config.ts tests/integration", + "test:coverage": "vitest run --coverage", + "test:ci": "vitest run --coverage --reporter=verbose", + "test:all": "vitest run --coverage && playwright test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:docker": "docker compose -f docker/docker-compose.test.yml up --build --abort-on-container-exit", "version:sync": "./scripts/sync-version.sh", "release": "semantic-release", "release:dry": "semantic-release --dry-run" @@ -25,8 +33,14 @@ "@semantic-release/github": "^12.0.6", "@semantic-release/npm": "^13.1.5", "@semantic-release/release-notes-generator": "^14.1.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "^3.2.4", + "jsdom": "^29.0.1", "semantic-release": "^25.0.3", "turbo": "^2.4.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/ai/src/bridge.ts b/packages/ai/src/bridge.ts index 80d55655..1accded8 100644 --- a/packages/ai/src/bridge.ts +++ b/packages/ai/src/bridge.ts @@ -148,14 +148,3 @@ export function runPythonWithProgress( }); } -/** - * Run a Python script from packages/ai/python/ with the given arguments. - * Falls back to system python3 if the venv is not available. - */ -export async function runPythonScript( - scriptName: string, - args: string[], - timeoutMs = 300000, -): Promise<{ stdout: string; stderr: string }> { - return runPythonWithProgress(scriptName, args, { timeout: timeoutMs }); -} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index e6d1736d..f9355121 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,18 +1,5 @@ -export { runPythonScript } from "./bridge.js"; -export { runPythonWithProgress } from "./bridge.js"; -export type { ProgressCallback } from "./bridge.js"; export { removeBackground } from "./background-removal.js"; -export type { RemoveBackgroundOptions } from "./background-removal.js"; export { upscale } from "./upscaling.js"; -export type { UpscaleOptions, UpscaleResult } from "./upscaling.js"; export { extractText } from "./ocr.js"; -export type { OcrOptions, OcrResult } from "./ocr.js"; export { blurFaces } from "./face-detection.js"; -export type { - BlurFacesOptions, - BlurFacesResult, - FaceRegion, -} from "./face-detection.js"; export { inpaint } from "./inpainting.js"; -export { smartCrop } from "./smart-crop.js"; -export type { SmartCropOptions } from "./smart-crop.js"; diff --git a/packages/ai/src/smart-crop.ts b/packages/ai/src/smart-crop.ts deleted file mode 100644 index 19e012a8..00000000 --- a/packages/ai/src/smart-crop.ts +++ /dev/null @@ -1,22 +0,0 @@ -import sharp from "sharp"; - -export interface SmartCropOptions { - width: number; - height: number; -} - -/** - * Smart crop using Sharp's entropy-based attention cropping. - * No Python needed — uses Sharp's built-in saliency detection. - */ -export async function smartCrop( - inputBuffer: Buffer, - options: SmartCropOptions, -): Promise { - return sharp(inputBuffer) - .resize(options.width, options.height, { - fit: "cover", - position: sharp.strategy.attention, - }) - .toBuffer(); -} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index d25cc1ca..228fb7e8 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -88,16 +88,4 @@ export const SOCIAL_MEDIA_PRESETS: SocialMediaPreset[] = [ { platform: "Threads", name: "Post Image", width: 1080, height: 1080 }, ]; -export const SUPPORTED_INPUT_FORMATS = [ - "jpg", "jpeg", "png", "webp", "avif", "tiff", "tif", - "bmp", "gif", "svg", "heic", "heif", "jxl", "ico", - "cr2", "nef", "arw", "dng", "orf", "rw2", -] as const; - -export const SUPPORTED_OUTPUT_FORMATS = [ - "jpg", "png", "webp", "avif", "tiff", "gif", "jxl", "svg", "ico", "pdf", -] as const; - -export const DEFAULT_OUTPUT_FORMAT = "jpg" as const; - export const APP_VERSION = "0.2.1"; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index fc2e1dd2..3ba4e239 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -28,60 +28,9 @@ export interface CategoryInfo { color: string; } -export type ImageFormat = - | "jpg" - | "png" - | "webp" - | "avif" - | "tiff" - | "bmp" - | "gif" - | "svg" - | "heic" - | "jxl" - | "ico" - | "raw" - | "pdf"; - export interface SocialMediaPreset { platform: string; name: string; width: number; height: number; } - -export interface AppConfig { - appName: string; - version: string; - defaultTheme: "light" | "dark"; - defaultLocale: string; - maxUploadSizeMb: number; - maxBatchSize: number; - maxMegapixels: number; - authEnabled: boolean; -} - -export interface ApiError { - error: string; - code: string; - details?: Record; -} - -export interface HealthResponse { - status: "healthy" | "degraded"; - version: string; - uptime: string; - storage: { mode: string; available: string }; - queue: { active: number; pending: number }; - ai: Record; -} - -export interface JobProgress { - jobId: string; - status: "queued" | "processing" | "completed" | "failed"; - progress: number; - currentFile?: string; - totalFiles?: number; - downloadUrl?: string; - error?: string; -} diff --git a/playwright.config.ts b/playwright.config.ts index caa8af38..a7b066b3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,14 @@ export default defineConfig({ timeout: 30_000, expect: { timeout: 10_000, + toHaveScreenshot: { + maxDiffPixelRatio: 0.01, + animations: "disabled", + caret: "hide", + }, }, + snapshotPathTemplate: + "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}", fullyParallel: false, retries: 0, workers: 1, @@ -41,7 +48,7 @@ export default defineConfig({ AUTH_ENABLED: "true", DEFAULT_USERNAME: "admin", DEFAULT_PASSWORD: "admin", - RATE_LIMIT_PER_MIN: "1000", + RATE_LIMIT_PER_MIN: "50000", }, timeout: 30_000, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db9d269f..4cbf3c05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,21 @@ importers: '@semantic-release/release-notes-generator': specifier: ^14.1.0 version: 14.1.0(semantic-release@25.0.3(typescript@5.9.3)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + jsdom: + specifier: ^29.0.1 + version: 29.0.1(@noble/hashes@1.8.0) semantic-release: specifier: ^25.0.3 version: 25.0.3(typescript@5.9.3) @@ -41,6 +56,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) apps/api: dependencies: @@ -143,7 +161,7 @@ importers: version: 4.21.0 vitepress: specifier: ^1.1.4 - version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@22.19.15)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@22.19.15)(@types/react@19.2.14)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3) apps/web: dependencies: @@ -224,7 +242,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) packages/shared: devDependencies: @@ -246,6 +264,9 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@algolia/abtesting@1.15.2': resolution: {integrity: sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA==} engines: {node: '>= 14.0.0'} @@ -322,6 +343,21 @@ packages: resolution: {integrity: sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==} engines: {node: '>= 14.0.0'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.4': + resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -409,10 +445,54 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -1170,6 +1250,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} @@ -1340,6 +1429,10 @@ packages: resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jimp/bmp@0.14.0': resolution: {integrity: sha512-5RkX6tSS7K3K3xNEb2ygPuvyL9whjanhoaB/WmmXlJS6ub4DjTqrapu8j4qnIWmO4YYtFeTbDTXV6v9P1yMA5A==} peerDependencies: @@ -2083,6 +2176,35 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -2119,6 +2241,9 @@ packages: '@types/archiver@7.0.0': resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2209,6 +2334,15 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -2380,6 +2514,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2404,6 +2542,13 @@ packages: argv-formatter@1.0.0: resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} @@ -2411,6 +2556,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2492,6 +2640,9 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2735,9 +2886,20 @@ packages: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2751,6 +2913,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2788,6 +2953,12 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} @@ -2926,6 +3097,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -3271,6 +3446,13 @@ packages: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} engines: {node: ^20.17.0 || >=22.9.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -3366,6 +3548,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -3400,6 +3585,22 @@ packages: resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} engines: {node: ^18.17 || >=20.6.1} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3427,6 +3628,9 @@ packages: js-md5@0.8.3: resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3437,6 +3641,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.0.1: + resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3609,13 +3822,24 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} @@ -3633,6 +3857,9 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -3689,6 +3916,10 @@ packages: min-document@2.19.2: resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -3993,6 +4224,9 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -4117,6 +4351,10 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -4143,6 +4381,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -4160,6 +4402,9 @@ packages: peerDependencies: react: ^19.2.4 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4223,6 +4468,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -4298,6 +4547,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4484,6 +4737,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -4515,6 +4772,9 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -4553,6 +4813,10 @@ packages: resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==} engines: {node: '>=14.16'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -4605,6 +4869,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4621,6 +4892,14 @@ packages: resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} engines: {node: '>=10'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} @@ -4881,9 +5160,25 @@ packages: typescript: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -4927,6 +5222,10 @@ packages: xhr@2.6.0: resolution: {integrity: sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-parse-from-string@1.0.1: resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} @@ -4938,6 +5237,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5031,6 +5333,8 @@ snapshots: '@actions/io@3.0.2': {} + '@adobe/css-tools@4.4.4': {} + '@algolia/abtesting@1.15.2': dependencies: '@algolia/client-common': 5.49.2 @@ -5143,6 +5447,29 @@ snapshots: dependencies: '@algolia/client-common': 5.49.2 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.4': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5257,14 +5584,44 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@colors/colors@1.5.0': optional: true + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@docsearch/css@3.8.2': {} - '@docsearch/js@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3)': + '@docsearch/js@3.8.2(@algolia/client-search@5.49.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)': dependencies: - '@docsearch/react': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3) + '@docsearch/react': 3.8.2(@algolia/client-search@5.49.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3) preact: 10.29.0 transitivePeerDependencies: - '@algolia/client-search' @@ -5273,13 +5630,16 @@ snapshots: - react-dom - search-insights - '@docsearch/react@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3)': + '@docsearch/react@3.8.2(@algolia/client-search@5.49.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)': dependencies: '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.17.3) '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2) '@docsearch/css': 3.8.2 algoliasearch: 5.49.2 optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) search-insights: 2.17.3 transitivePeerDependencies: - '@algolia/client-search' @@ -5661,6 +6021,10 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@fastify/accept-negotiator@2.0.1': {} '@fastify/ajv-compiler@4.0.5': @@ -5845,6 +6209,8 @@ snapshots: '@isaacs/cliui@9.0.0': {} + '@istanbuljs/schema@0.1.3': {} + '@jimp/bmp@0.14.0(@jimp/custom@0.14.0)': dependencies: '@babel/runtime': 7.29.2 @@ -6758,6 +7124,40 @@ snapshots: tailwindcss: 4.2.2 vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tokenizer/token@0.3.0': {} '@turbo/darwin-64@2.8.20': @@ -6782,6 +7182,8 @@ snapshots: dependencies: '@types/readdir-glob': 1.1.5 + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -6891,6 +7293,25 @@ snapshots: vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0) vue: 3.5.30(typescript@5.9.3) + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -7095,6 +7516,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-base@1.1.0: {} @@ -7129,10 +7552,22 @@ snapshots: argv-formatter@1.0.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-ify@1.0.0: {} assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async@3.2.6: {} atomic-sleep@1.0.0: {} @@ -7194,6 +7629,10 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -7445,14 +7884,30 @@ snapshots: dependencies: type-fest: 1.4.0 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 decamelize@1.2.0: {} + decimal.js@10.6.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -7479,6 +7934,10 @@ snapshots: dependencies: path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-walk@0.1.2: {} dot-prop@5.3.0: @@ -7531,6 +7990,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + entities@7.0.1: {} env-ci@11.2.0: @@ -8028,6 +8489,14 @@ snapshots: dependencies: lru-cache: 11.2.7 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -8109,6 +8578,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} is-stream@3.0.0: {} @@ -8133,6 +8604,27 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -8171,6 +8663,8 @@ snapshots: js-md5@0.8.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -8179,6 +8673,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.0.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.4 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.5 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-parse-better-errors@1.0.2: {} @@ -8334,16 +8854,28 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-asynchronous@1.1.0: dependencies: p-event: 6.0.1 type-fest: 4.41.0 web-worker: 1.5.0 + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + mark.js@8.11.1: {} marked-terminal@7.3.0(marked@15.0.12): @@ -8371,6 +8903,8 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mdn-data@2.27.1: {} + meow@13.2.0: {} merge-stream@2.0.0: {} @@ -8413,6 +8947,8 @@ snapshots: dependencies: dom-walk: 0.1.2 + min-indent@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -8621,6 +9157,10 @@ snapshots: parse5@6.0.1: {} + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -8746,6 +9286,12 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -8767,6 +9313,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -8787,6 +9335,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -8867,6 +9417,11 @@ snapshots: real-require@0.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + regenerator-runtime@0.13.11: {} regex-recursion@6.0.2: @@ -8946,6 +9501,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} search-insights@2.17.3: {} @@ -9177,6 +9736,10 @@ snapshots: strip-final-newline@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-literal@3.1.0: @@ -9211,6 +9774,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + symbol-tree@3.2.4: {} + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -9263,6 +9828,12 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.4 + text-decoder@1.2.7: dependencies: b4a: 1.8.0 @@ -9311,6 +9882,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9324,6 +9901,14 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + traverse@0.6.8: {} trim-lines@3.0.1: {} @@ -9498,10 +10083,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@22.19.15)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@22.19.15)(@types/react@19.2.14)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 - '@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3) + '@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3) '@iconify-json/simple-icons': 1.2.74 '@shikijs/core': 2.5.0 '@shikijs/transformers': 2.5.0 @@ -9547,7 +10132,7 @@ snapshots: - typescript - universal-cookie - vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -9574,6 +10159,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.15 + jsdom: 29.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti - less @@ -9598,8 +10184,24 @@ snapshots: optionalDependencies: typescript: 5.9.3 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-worker@1.5.0: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-module@2.0.1: {} which@2.0.2: @@ -9650,6 +10252,8 @@ snapshots: parse-headers: 2.0.6 xtend: 4.0.2 + xml-name-validator@5.0.0: {} + xml-parse-from-string@1.0.1: {} xml2js@0.5.0: @@ -9659,6 +10263,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@4.0.3: {} diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/compress-empty-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/compress-empty-desktop.png new file mode 100644 index 00000000..ec3d3038 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/compress-empty-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/convert-empty-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/convert-empty-desktop.png new file mode 100644 index 00000000..2fffb5a2 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/convert-empty-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-desktop.png new file mode 100644 index 00000000..6645ca24 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-mobile.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-mobile.png new file mode 100644 index 00000000..4fb23b52 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-mobile.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-tablet.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-tablet.png new file mode 100644 index 00000000..5cd0629e Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/fullscreen-grid-tablet.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/home-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-desktop.png new file mode 100644 index 00000000..a2ad1792 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/home-mobile.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-mobile.png new file mode 100644 index 00000000..32433113 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-mobile.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/home-tablet.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-tablet.png new file mode 100644 index 00000000..2b6474a4 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/home-tablet.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/login-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-desktop.png new file mode 100644 index 00000000..f1106bcc Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/login-mobile.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-mobile.png new file mode 100644 index 00000000..b993f499 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/login-mobile.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-desktop.png new file mode 100644 index 00000000..1dd8c4ec Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-mobile.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-mobile.png new file mode 100644 index 00000000..325d8181 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-empty-mobile.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-uploaded-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-uploaded-desktop.png new file mode 100644 index 00000000..71d7cc89 Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/resize-uploaded-desktop.png differ diff --git a/tests/e2e/__screenshots__/visual-regression.spec.ts/sidebar-desktop.png b/tests/e2e/__screenshots__/visual-regression.spec.ts/sidebar-desktop.png new file mode 100644 index 00000000..0ca380cd Binary files /dev/null and b/tests/e2e/__screenshots__/visual-regression.spec.ts/sidebar-desktop.png differ diff --git a/tests/e2e/full-session.spec.ts b/tests/e2e/full-session.spec.ts new file mode 100644 index 00000000..de7948a5 --- /dev/null +++ b/tests/e2e/full-session.spec.ts @@ -0,0 +1,275 @@ +import { test, expect, uploadTestImage, waitForProcessing } from "./helpers"; +import path from "node:path"; +import fs from "node:fs"; + +// --------------------------------------------------------------------------- +// Full user session: simulates a real user uploading images, applying +// different tools in sequence, and downloading results. +// --------------------------------------------------------------------------- + +test.describe("Full user session", () => { + test("upload -> resize -> download cycle", async ({ loggedInPage: page }) => { + // Navigate to resize tool + await page.goto("/resize"); + await expect(page.getByText("Resize").first()).toBeVisible(); + + // Upload test image + await uploadTestImage(page); + + // Verify the upload was accepted (dropzone is replaced by the viewer) + await expect(page.getByText("Upload from computer")).not.toBeVisible(); + + // Set width to 200 + await page.locator("input[placeholder='Auto']").first().fill("200"); + + // Set height to 200 + await page.locator("input[placeholder='Auto']").nth(1).fill("200"); + + // Click resize button + await page.getByRole("button", { name: "Resize" }).click(); + await waitForProcessing(page); + + // Verify download button appears + const downloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(downloadBtn).toBeVisible({ timeout: 15_000 }); + + // Click download and verify a file is received + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const download = await downloadPromise; + + // Verify the download has a filename + expect(download.suggestedFilename()).toBeTruthy(); + + // Save to disk and verify it is a non-empty file + const downloadPath = path.join( + process.cwd(), + "test-results", + "download-resize-result", + ); + await download.saveAs(downloadPath); + const stat = fs.statSync(downloadPath); + expect(stat.size).toBeGreaterThan(0); + }); + + test("upload -> rotate 90 -> download cycle", async ({ + loggedInPage: page, + }) => { + await page.goto("/rotate"); + await expect(page.getByText("Rotate").first()).toBeVisible(); + + await uploadTestImage(page); + await expect(page.getByText("Upload from computer")).not.toBeVisible(); + + // Click 90-degree right rotation preset (the CW icon button) + await page + .locator("button") + .filter({ hasText: /90.*right|right.*90|cw/i }) + .first() + .click() + .catch(async () => { + // Fallback: click the second quick-rotate button (CW) + await page.locator("aside button, [class*='panel'] button").nth(1).click(); + }); + + // Click the process button (button text is "Rotate") + await page.getByRole("button", { name: "Rotate" }).click(); + await waitForProcessing(page); + + // Verify result + const downloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(downloadBtn).toBeVisible({ timeout: 15_000 }); + + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBeTruthy(); + }); + + test("upload -> convert to JPEG -> download cycle", async ({ + loggedInPage: page, + }) => { + await page.goto("/convert"); + await expect(page.getByText("Convert").first()).toBeVisible(); + + await uploadTestImage(page); + await expect(page.getByText("Upload from computer")).not.toBeVisible(); + + // The convert tool has a format selector - look for JPEG option + // Try selecting JPEG from the format options + const jpegOption = page.getByRole("button", { name: /jpeg|jpg/i }).first(); + if (await jpegOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await jpegOption.click(); + } + // Otherwise the default format selection is fine + + // Click convert button + await page.getByRole("button", { name: /convert/i }).click(); + await waitForProcessing(page); + + // Verify download + const downloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(downloadBtn).toBeVisible({ timeout: 15_000 }); + + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBeTruthy(); + }); + + test("upload -> crop -> download cycle", async ({ loggedInPage: page }) => { + await page.goto("/crop"); + await expect(page.getByText("Crop").first()).toBeVisible(); + + await uploadTestImage(page); + await expect(page.getByText("Upload from computer")).not.toBeVisible(); + + // Set crop dimensions via number inputs + // Crop component has: left, top, width, height inputs + const numberInputs = page.locator("input[type='number']"); + const count = await numberInputs.count(); + if (count >= 4) { + // Ensure width and height are set (indices 2 and 3) + await numberInputs.nth(2).fill("50"); + await numberInputs.nth(3).fill("50"); + } else if (count >= 2) { + // If fewer inputs, fill the first two + await numberInputs.nth(0).fill("50"); + await numberInputs.nth(1).fill("50"); + } + + // Click crop button + await page.locator("button[type='submit']").click(); + await waitForProcessing(page); + + // Verify download + const downloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(downloadBtn).toBeVisible({ timeout: 15_000 }); + + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBeTruthy(); + }); + + test("upload -> compress -> download cycle", async ({ + loggedInPage: page, + }) => { + await page.goto("/compress"); + await expect(page.getByText("Compress").first()).toBeVisible(); + + await uploadTestImage(page); + await expect(page.getByText("Upload from computer")).not.toBeVisible(); + + // Compress has sensible defaults, just click process + await page.getByRole("button", { name: "Compress" }).click(); + await waitForProcessing(page); + + const downloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(downloadBtn).toBeVisible({ timeout: 15_000 }); + + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBeTruthy(); + + // Save and verify the downloaded file is a valid non-empty image + const downloadPath = path.join( + process.cwd(), + "test-results", + "download-compress-result", + ); + await download.saveAs(downloadPath); + const stat = fs.statSync(downloadPath); + expect(stat.size).toBeGreaterThan(0); + }); + + test("multi-tool session: resize then compress", async ({ + loggedInPage: page, + }) => { + // Step 1: Resize + await page.goto("/resize"); + await uploadTestImage(page); + await page.locator("input[placeholder='Auto']").first().fill("200"); + await page.getByRole("button", { name: "Resize" }).click(); + await waitForProcessing(page); + + const resizeDownloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(resizeDownloadBtn).toBeVisible({ timeout: 15_000 }); + + // Step 2: Navigate to compress and process a new image + await page.goto("/compress"); + await uploadTestImage(page); + await page.getByRole("button", { name: "Compress" }).click(); + await waitForProcessing(page); + + const compressDownloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(compressDownloadBtn).toBeVisible({ timeout: 15_000 }); + }); + + test("upload file then navigate away and back retains tool state", async ({ + loggedInPage: page, + }) => { + // Go to resize, upload, configure + await page.goto("/resize"); + await uploadTestImage(page); + await page.locator("input[placeholder='Auto']").first().fill("300"); + + // Navigate away to home + await page.goto("/"); + await expect(page.getByText("Upload from computer")).toBeVisible(); + + // Navigate back to resize - the tool should reset (fresh state) + await page.goto("/resize"); + await expect(page.getByText("Upload from computer")).toBeVisible(); + }); + + test("download button triggers actual file download", async ({ + loggedInPage: page, + }) => { + await page.goto("/strip-metadata"); + await uploadTestImage(page); + + await page.getByRole("button", { name: /strip metadata/i }).click(); + await waitForProcessing(page); + + const downloadBtn = page + .getByRole("link", { name: /download/i }) + .first(); + await expect(downloadBtn).toBeVisible({ timeout: 15_000 }); + + // Intercept the download event + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const download = await downloadPromise; + + // Verify download properties + const filename = download.suggestedFilename(); + expect(filename).toBeTruthy(); + expect(filename.length).toBeGreaterThan(0); + + // Save and confirm it wrote bytes + const savePath = path.join( + process.cwd(), + "test-results", + "download-strip-metadata-result", + ); + await download.saveAs(savePath); + const stat = fs.statSync(savePath); + expect(stat.size).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index fb2866b0..022fe6f5 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -10,7 +10,15 @@ test.describe("Navigation", () => { test("sidebar Grid link goes to fullscreen view", async ({ loggedInPage: page, }) => { - await page.locator("aside").getByText("Grid").click(); + // Click the Grid link in the sidebar (links to /fullscreen) + const gridLink = page.locator("aside").getByText("Grid"); + // If "Grid" text isn't directly visible (collapsed sidebar), try the link + if (await gridLink.isVisible({ timeout: 3000 }).catch(() => false)) { + await gridLink.click(); + } else { + // Fallback: navigate via the href directly + await page.locator('aside a[href="/fullscreen"]').click(); + } await expect(page).toHaveURL("/fullscreen"); }); diff --git a/tests/e2e/security.spec.ts b/tests/e2e/security.spec.ts new file mode 100644 index 00000000..bbdc4090 --- /dev/null +++ b/tests/e2e/security.spec.ts @@ -0,0 +1,358 @@ +import { test, expect } from "@playwright/test"; +import fs from "node:fs"; +import { getTestImagePath } from "./helpers"; + +// --------------------------------------------------------------------------- +// Security tests: path traversal, XSS in filenames, rate limiting, +// auth token handling, and unauthenticated access. +// --------------------------------------------------------------------------- + +const API = "http://localhost:1350"; + +async function getAuthToken(): Promise { + const res = await fetch(`${API}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "admin" }), + }); + const data = await res.json(); + return data.token; +} + +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` }; +} + +function readTestImage(): { blob: Blob; buffer: Buffer } { + const imagePath = getTestImagePath(); + const buffer = fs.readFileSync(imagePath); + return { blob: new Blob([buffer], { type: "image/png" }), buffer }; +} + +test.describe("Security: Path traversal", () => { + let token: string; + + test.beforeAll(async () => { + token = await getAuthToken(); + }); + + test("download rejects path traversal in jobId (..)", async () => { + const res = await fetch( + `${API}/api/v1/download/../../../etc/passwd/file.png`, + ); + // Should return 400 (invalid path) or 404, never the actual file + expect([400, 404]).toContain(res.status); + const body = await res.text(); + expect(body).not.toContain("root:"); + }); + + test("download rejects path traversal in filename (..)", async () => { + const res = await fetch( + `${API}/api/v1/download/some-job-id/..%2F..%2F..%2Fetc%2Fpasswd`, + ); + // Server may return 400 (bad path), 404 (not found), or 401 (route mismatch) + expect(res.status).not.toBe(200); + const body = await res.text(); + expect(body).not.toContain("root:"); + }); + + test("download rejects null bytes in path", async () => { + const res = await fetch( + `${API}/api/v1/download/test-id/file.png%00.txt`, + ); + // Should be blocked - any non-200 is acceptable + expect(res.status).not.toBe(200); + }); + + test("download rejects backslash traversal", async () => { + const res = await fetch( + `${API}/api/v1/download/test-id/..\\..\\etc\\passwd`, + ); + // Backslash may cause URL routing to fail in various ways + expect(res.status).not.toBe(200); + }); + + test("download with non-existent jobId returns 404", async () => { + const res = await fetch( + `${API}/api/v1/download/00000000-0000-0000-0000-000000000000/file.png`, + ); + // Should be 404 (not found) but could be 400 if UUID validation exists + expect([400, 404]).toContain(res.status); + }); +}); + +test.describe("Security: XSS in filenames", () => { + let token: string; + + test.beforeAll(async () => { + token = await getAuthToken(); + }); + + test("upload with script tag in filename is sanitized", async () => { + const { blob } = readTestImage(); + const formData = new FormData(); + formData.append( + "file", + blob, + '.png', + ); + formData.append( + "settings", + JSON.stringify({ width: 50, height: 50, fit: "contain" }), + ); + + const res = await fetch(`${API}/api/v1/tools/resize`, { + method: "POST", + headers: authHeaders(token), + body: formData, + }); + + // The server should accept the file (with sanitized name) or reject it + if (res.status === 200) { + const data = await res.json(); + // The download URL must be URL-encoded so HTML tags aren't rendered + // The filename is preserved by basename() but URL-encoded in the response + expect(data.downloadUrl).not.toContain("