diff --git a/.env.example b/.env.example index fb69e6d2..0ccb9811 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,38 @@ -# Server port (used in production / Docker) -# In dev, the API auto-starts on an internal port; you always access localhost:1349 +# Server PORT=1349 AUTH_ENABLED=true DEFAULT_USERNAME=admin DEFAULT_PASSWORD=admin STORAGE_MODE=local -FILE_MAX_AGE_HOURS=24 -CLEANUP_INTERVAL_MINUTES=30 -MAX_UPLOAD_SIZE_MB=100 -MAX_BATCH_SIZE=200 -CONCURRENT_JOBS=3 -MAX_MEGAPIXELS=100 -RATE_LIMIT_PER_MIN=100 + +# Cleanup +FILE_MAX_AGE_HOURS=72 +CLEANUP_INTERVAL_MINUTES=60 + +# Upload & Batch (0 = unlimited) +MAX_UPLOAD_SIZE_MB=0 +MAX_BATCH_SIZE=0 +CONCURRENT_JOBS=0 +MAX_MEGAPIXELS=0 + +# Rate limiting (0 = disabled) +RATE_LIMIT_PER_MIN=0 + +# Users (0 = unlimited) +MAX_USERS=0 + +# Processing (0 = auto/unlimited) +MAX_WORKER_THREADS=0 +PROCESSING_TIMEOUT_S=0 +MAX_PIPELINE_STEPS=0 +MAX_CANVAS_PIXELS=0 +MAX_SVG_SIZE_MB=0 +MAX_LOGO_SIZE_KB=2048 +MAX_SPLIT_GRID=100 +MAX_PDF_PAGES=0 +SESSION_DURATION_HOURS=168 +LOGIN_ATTEMPT_LIMIT=10 + # Set to true in CI/dev to skip the forced password-change on the default admin # SKIP_MUST_CHANGE_PASSWORD=false DB_PATH=./data/ashim.db diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 33631ea4..977b37fd 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -13,7 +13,7 @@ const sqlite: DatabaseType = new Database(env.DB_PATH); // Critical SQLite pragmas for reliability. // busy_timeout must be set first so journal_mode = WAL can retry // if another connection holds the lock (e.g. parallel test files). -sqlite.pragma("busy_timeout = 5000"); +sqlite.pragma("busy_timeout = 10000"); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("foreign_keys = ON"); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a8bac475..8b8e4a6e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -41,7 +41,8 @@ recoverInterruptedInstalls(); const app = Fastify({ logger: { level: env.LOG_LEVEL }, - bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, + bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824, + maxParamLength: 500, }); app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { @@ -79,12 +80,13 @@ app.addHook("onSend", async (_request, reply) => { } }); -await app.register(rateLimit, { - max: env.RATE_LIMIT_PER_MIN, - timeWindow: "1 minute", - // Only rate-limit API endpoints — static files and the SPA fallback must never be throttled - allowList: (request) => !request.url.startsWith("/api/"), -}); +if (env.RATE_LIMIT_PER_MIN > 0) { + await app.register(rateLimit, { + max: env.RATE_LIMIT_PER_MIN, + timeWindow: "1 minute", + allowList: (request) => !request.url.startsWith("/api/"), + }); +} // Multipart upload support await registerUpload(app); @@ -195,7 +197,7 @@ try { } // Graceful shutdown -const SHUTDOWN_TIMEOUT_MS = 8000; +const SHUTDOWN_TIMEOUT_MS = 30000; let shuttingDown = false; async function shutdown(signal: string) { if (shuttingDown) return; diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 3d7fafc1..fcb56352 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -1,3 +1,4 @@ +import { availableParallelism } from "node:os"; import { z } from "zod"; const envSchema = z.object({ @@ -13,13 +14,13 @@ const envSchema = z.object({ .default("false") .transform((v) => v === "true"), STORAGE_MODE: z.enum(["local", "s3"]).default("local"), - FILE_MAX_AGE_HOURS: z.coerce.number().default(24), - CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(30), - MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100), - MAX_BATCH_SIZE: z.coerce.number().default(200), - CONCURRENT_JOBS: z.coerce.number().default(3), - MAX_MEGAPIXELS: z.coerce.number().default(100), - RATE_LIMIT_PER_MIN: z.coerce.number().default(100), + FILE_MAX_AGE_HOURS: z.coerce.number().default(72), + CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60), + MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0), + MAX_BATCH_SIZE: z.coerce.number().default(0), + CONCURRENT_JOBS: z.coerce.number().default(0), + MAX_MEGAPIXELS: z.coerce.number().default(0), + RATE_LIMIT_PER_MIN: z.coerce.number().default(0), DB_PATH: z.string().default("./data/ashim.db"), FILES_STORAGE_PATH: z.string().default("./data/files"), WORKSPACE_PATH: z.string().default("./tmp/workspace"), @@ -27,8 +28,18 @@ const envSchema = z.object({ DEFAULT_LOCALE: z.string().default("en"), APP_NAME: z.string().default("ashim"), CORS_ORIGIN: z.string().default(""), - MAX_USERS: z.coerce.number().default(5), + MAX_USERS: z.coerce.number().default(0), LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), + MAX_WORKER_THREADS: z.coerce.number().default(0), + PROCESSING_TIMEOUT_S: z.coerce.number().default(0), + MAX_PIPELINE_STEPS: z.coerce.number().default(0), + MAX_CANVAS_PIXELS: z.coerce.number().default(0), + MAX_SVG_SIZE_MB: z.coerce.number().default(0), + MAX_LOGO_SIZE_KB: z.coerce.number().default(2048), + MAX_SPLIT_GRID: z.coerce.number().default(100), + MAX_PDF_PAGES: z.coerce.number().default(0), + SESSION_DURATION_HOURS: z.coerce.number().default(168), + LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(10), }); export type Env = z.infer; @@ -36,3 +47,13 @@ export type Env = z.infer; export function loadEnv(): Env { return envSchema.parse(process.env); } + +export function resolveConcurrency(env: Env): number { + if (env.CONCURRENT_JOBS > 0) return env.CONCURRENT_JOBS; + return Math.max(2, availableParallelism() - 1); +} + +export function resolveWorkerThreads(env: Env): number { + if (env.MAX_WORKER_THREADS > 0) return env.MAX_WORKER_THREADS; + return Math.max(2, availableParallelism() - 1); +} diff --git a/apps/api/src/lib/exiftool.ts b/apps/api/src/lib/exiftool.ts index 788c2b75..737a1b2e 100644 --- a/apps/api/src/lib/exiftool.ts +++ b/apps/api/src/lib/exiftool.ts @@ -51,7 +51,7 @@ export async function inspectMetadata(buffer: Buffer, filename: string): Promise try { await writeFile(tempPath, buffer); const { stdout } = await execFileAsync(bin, ["-json", "-G", "-struct", "-n", tempPath], { - timeout: 30_000, + timeout: 60_000, maxBuffer: 10 * 1024 * 1024, }); @@ -126,7 +126,7 @@ export async function writeMetadata( try { await writeFile(tempPath, buffer); await execFileAsync(bin, ["-overwrite_original", ...tags, tempPath], { - timeout: 30_000, + timeout: 60_000, maxBuffer: 10 * 1024 * 1024, }); return await readFile(tempPath); diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 18f94718..6b72e9d2 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -90,7 +90,7 @@ export async function validateImageBuffer( const height = metadata.height ?? 0; const megapixels = (width * height) / 1_000_000; - if (megapixels > env.MAX_MEGAPIXELS) { + if (env.MAX_MEGAPIXELS > 0 && megapixels > env.MAX_MEGAPIXELS) { return { valid: false, reason: `Image exceeds maximum size: ${megapixels.toFixed(1)}MP (limit: ${env.MAX_MEGAPIXELS}MP)`, diff --git a/apps/api/src/lib/heic-converter.ts b/apps/api/src/lib/heic-converter.ts index 7d4a4b75..86df09eb 100644 --- a/apps/api/src/lib/heic-converter.ts +++ b/apps/api/src/lib/heic-converter.ts @@ -45,7 +45,7 @@ export async function decodeHeic(buffer: Buffer): Promise { try { await writeFile(inputPath, buffer); - await execFileAsync(cmd, [inputPath, outputPath], { timeout: 30_000 }); + await execFileAsync(cmd, [inputPath, outputPath], { timeout: 120_000 }); // Single-image HEIF: exact filename. Multi-image: -1 suffix on first image. try { @@ -94,7 +94,7 @@ export async function encodeHeic(buffer: Buffer, quality = 80): Promise try { await writeFile(inputPath, buffer); await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], { - timeout: 30_000, + timeout: 120_000, }); return await readFile(outputPath); } finally { diff --git a/apps/api/src/lib/svg-sanitize.ts b/apps/api/src/lib/svg-sanitize.ts index f2436d37..841a7908 100644 --- a/apps/api/src/lib/svg-sanitize.ts +++ b/apps/api/src/lib/svg-sanitize.ts @@ -1,12 +1,13 @@ -const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB +import { env } from "../config.js"; /** * Sanitize an SVG buffer to prevent XXE, SSRF, and script injection. * Throws if the SVG exceeds the maximum allowed size. */ export 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`); + const maxSvgSize = env.MAX_SVG_SIZE_MB > 0 ? env.MAX_SVG_SIZE_MB * 1024 * 1024 : Infinity; + if (buffer.length > maxSvgSize) { + throw new Error(`SVG exceeds maximum size of ${env.MAX_SVG_SIZE_MB}MB`); } let svg = buffer.toString("utf-8"); // Remove DOCTYPE (XXE prevention, including internal subsets) diff --git a/apps/api/src/lib/timeout.ts b/apps/api/src/lib/timeout.ts new file mode 100644 index 00000000..5806710b --- /dev/null +++ b/apps/api/src/lib/timeout.ts @@ -0,0 +1,26 @@ +import { env } from "../config.js"; + +type ToolCategory = "sharp" | "ai_cpu" | "ai_gpu" | "external" | "python"; + +const TIMEOUT_RATES: Record = { + sharp: 2, + ai_cpu: 30, + ai_gpu: 5, + external: 10, + python: 15, +}; + +export function computeTimeout(megapixels: number, category: ToolCategory, fileCount = 1): number { + if (env.PROCESSING_TIMEOUT_S > 0) { + return env.PROCESSING_TIMEOUT_S * 1000; + } + const perFile = Math.max(60_000, megapixels * TIMEOUT_RATES[category] * 1000); + return perFile * fileCount; +} + +export function computeExternalToolTimeout(megapixels: number): number { + if (env.PROCESSING_TIMEOUT_S > 0) { + return env.PROCESSING_TIMEOUT_S * 1000; + } + return Math.max(60_000, megapixels * TIMEOUT_RATES.external * 1000); +} diff --git a/apps/api/src/lib/worker-pool.ts b/apps/api/src/lib/worker-pool.ts index 3eba2e75..351a482b 100644 --- a/apps/api/src/lib/worker-pool.ts +++ b/apps/api/src/lib/worker-pool.ts @@ -4,15 +4,14 @@ * Uses Piscina (backed by worker_threads) so Sharp operations don't block * HTTP request handling, SSE streams, or health checks. */ -import { availableParallelism } from "node:os"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import Piscina from "piscina"; +import { loadEnv, resolveWorkerThreads } from "./env.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Size the pool: leave 1 thread for the event loop, min 1 worker -const maxThreads = Math.max(1, Math.min(availableParallelism() - 1, 4)); +const maxThreads = resolveWorkerThreads(loadEnv()); let pool: Piscina | null = null; diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 8b14065e..5e0bb056 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -98,7 +98,7 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply): Auth // ── Session helpers ──────────────────────────────────────────────── -const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours +const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000; function createSessionToken(): string { return randomUUID(); @@ -137,10 +137,7 @@ export async function ensureDefaultAdmin(): Promise { // ── Login attempt limit ────────────────────────────────────────── -const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10; - function getLoginAttemptLimit(): number { - // Allow override via RATE_LIMIT_PER_MIN for test environments if (env.RATE_LIMIT_PER_MIN > 1000) return env.RATE_LIMIT_PER_MIN; const row = db .select() @@ -151,7 +148,7 @@ function getLoginAttemptLimit(): number { const parsed = parseInt(row.value, 10); if (!Number.isNaN(parsed) && parsed > 0) return parsed; } - return DEFAULT_LOGIN_ATTEMPT_LIMIT; + return env.LOGIN_ATTEMPT_LIMIT; } // ── Auth routes ──────────────────────────────────────────────────── diff --git a/apps/api/src/plugins/upload.ts b/apps/api/src/plugins/upload.ts index e39e1ae5..bd9150ca 100644 --- a/apps/api/src/plugins/upload.ts +++ b/apps/api/src/plugins/upload.ts @@ -5,8 +5,8 @@ import { env } from "../config.js"; export async function registerUpload(app: FastifyInstance): Promise { await app.register(multipart, { limits: { - fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, - files: env.MAX_BATCH_SIZE, + fileSize: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined, + files: env.MAX_BATCH_SIZE > 0 ? env.MAX_BATCH_SIZE : undefined, }, }); } diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index 0946b48a..b22076d8 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -15,6 +15,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import PQueue from "p-queue"; import { env } from "../config.js"; import { autoOrient } from "../lib/auto-orient.js"; +import { resolveConcurrency } from "../lib/env.js"; import { formatZodErrors } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { validateImageBuffer } from "../lib/file-validation.js"; @@ -90,7 +91,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { } // Enforce batch size limit - if (files.length > env.MAX_BATCH_SIZE) { + if (env.MAX_BATCH_SIZE > 0 && files.length > env.MAX_BATCH_SIZE) { return reply.status(400).send({ error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`, }); @@ -126,7 +127,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { updateJobProgress({ ...progress }); // Use p-queue for concurrency control - const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS }); + const queue = new PQueue({ concurrency: resolveConcurrency(env) }); // All processed buffers are held in memory until ZIP streaming begins. // Peak memory scales with files.length * avg output size. MAX_BATCH_SIZE bounds this. diff --git a/apps/api/src/routes/branding.ts b/apps/api/src/routes/branding.ts index 37a0a277..b6b1cd81 100644 --- a/apps/api/src/routes/branding.ts +++ b/apps/api/src/routes/branding.ts @@ -11,13 +11,14 @@ import { join } from "node:path"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; +import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { ensureSharpCompat } from "../lib/heic-converter.js"; import { requireAdmin } from "../plugins/auth.js"; const BRANDING_DIR = join(process.cwd(), "data", "branding"); const LOGO_PATH = join(BRANDING_DIR, "logo.png"); -const MAX_LOGO_SIZE = 500 * 1024; // 500 KB +const maxLogoSize = env.MAX_LOGO_SIZE_KB * 1024; function upsertSetting(key: string, value: string): void { const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); @@ -51,10 +52,11 @@ export async function brandingRoutes(app: FastifyInstance): Promise { const buffer = await file.toBuffer(); // Validate size - if (buffer.length > MAX_LOGO_SIZE) { - return reply - .status(400) - .send({ error: "Logo must be 500KB or smaller", code: "VALIDATION_ERROR" }); + if (buffer.length > maxLogoSize) { + return reply.status(400).send({ + error: `Logo must be ${env.MAX_LOGO_SIZE_KB}KB or smaller`, + code: "VALIDATION_ERROR", + }); } // Decode HEIC/HEIF if needed, then convert to PNG, resize to max 128x128 diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index a7650ada..7ba3526d 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -18,6 +18,7 @@ import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { autoOrient } from "../lib/auto-orient.js"; +import { resolveConcurrency } from "../lib/env.js"; import { formatZodErrors } from "../lib/errors.js"; import { isToolInstalled } from "../lib/feature-status.js"; import { validateImageBuffer } from "../lib/file-validation.js"; @@ -39,17 +40,21 @@ const pipelineDefinitionSchema = z.object({ steps: z .array(pipelineStepSchema) .min(1, "Pipeline must have at least one step") - .max(20, "Pipeline cannot exceed 20 steps"), + .refine((steps) => env.MAX_PIPELINE_STEPS === 0 || steps.length <= env.MAX_PIPELINE_STEPS, { + message: "Pipeline exceeds maximum 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(), + name: z.string().min(1, "Pipeline name is required").max(255), + description: z.string().max(2000).optional(), steps: z .array(pipelineStepSchema) .min(1, "Pipeline must have at least one step") - .max(20, "Pipeline cannot exceed 20 steps"), + .refine((steps) => env.MAX_PIPELINE_STEPS === 0 || steps.length <= env.MAX_PIPELINE_STEPS, { + message: "Pipeline exceeds maximum steps", + }), }); export async function registerPipelineRoutes(app: FastifyInstance): Promise { @@ -424,7 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise env.MAX_BATCH_SIZE) { + if (env.MAX_BATCH_SIZE > 0 && files.length > env.MAX_BATCH_SIZE) { return reply.status(400).send({ error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`, }); @@ -499,7 +504,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise(app: FastifyInstance, config: ToolRouteConfig filename, inputFormat: validation.format, }; + const meta = await sharp(fileBuffer).metadata(); + const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000; + const timeoutMs = computeTimeout(megapixels, "sharp"); const workerResult: WorkerOutput = await pool.run(workerInput, { - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(timeoutMs), }); result = { buffer: Buffer.from(workerResult.buffer), diff --git a/apps/api/src/routes/tools/border.ts b/apps/api/src/routes/tools/border.ts index f1b327d9..1bb8ced5 100644 --- a/apps/api/src/routes/tools/border.ts +++ b/apps/api/src/routes/tools/border.ts @@ -6,13 +6,13 @@ import { createToolRoute } from "../tool-factory.js"; const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/); const settingsSchema = z.object({ - borderWidth: z.number().min(0).max(200).default(10), + borderWidth: z.number().min(0).max(2000).default(10), borderColor: hexColor.default("#000000"), padding: z.number().min(0).max(200).default(0), paddingColor: hexColor.default("#FFFFFF"), - cornerRadius: z.number().min(0).max(500).default(0), + cornerRadius: z.number().min(0).max(2000).default(0), shadow: z.boolean().default(false), - shadowBlur: z.number().min(1).max(50).default(15), + shadowBlur: z.number().min(1).max(200).default(15), shadowOffsetX: z.number().min(-50).max(50).default(0), shadowOffsetY: z.number().min(-50).max(50).default(5), shadowColor: hexColor.default("#000000"), diff --git a/apps/api/src/routes/tools/bulk-rename.ts b/apps/api/src/routes/tools/bulk-rename.ts index fc092c3b..aebb3615 100644 --- a/apps/api/src/routes/tools/bulk-rename.ts +++ b/apps/api/src/routes/tools/bulk-rename.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { formatZodErrors } from "../../lib/errors.js"; const settingsSchema = z.object({ - pattern: z.string().min(1).max(200).default("image-{{index}}"), + pattern: z.string().min(1).max(1000).default("image-{{index}}"), startIndex: z.number().min(0).default(1), }); diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 9127448d..feebe3cd 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -321,15 +321,15 @@ const cellSchema = z.object({ imageIndex: z.number().int().min(0), panX: z.number().min(-100).max(100).default(0), panY: z.number().min(-100).max(100).default(0), - zoom: z.number().min(1).max(3).default(1), + zoom: z.number().min(1).max(10).default(1), objectFit: z.enum(["cover", "contain"]).default("cover"), }); const settingsSchema = z.object({ templateId: z.string(), cells: z.array(cellSchema).optional(), - gap: z.number().min(0).max(50).default(8), - cornerRadius: z.number().min(0).max(30).default(0), + gap: z.number().min(0).max(500).default(8), + cornerRadius: z.number().min(0).max(500).default(0), backgroundColor: z.string().default("#FFFFFF"), aspectRatio: z.string().default("free"), outputFormat: z.enum(["png", "jpeg", "webp"]).default("png"), diff --git a/apps/api/src/routes/tools/gif-tools.ts b/apps/api/src/routes/tools/gif-tools.ts index 3054bae7..e5d21b47 100644 --- a/apps/api/src/routes/tools/gif-tools.ts +++ b/apps/api/src/routes/tools/gif-tools.ts @@ -65,8 +65,8 @@ const settingsSchema = z.object({ mode: z.enum(["resize", "optimize", "speed", "reverse", "extract", "rotate"]).default("resize"), // Resize - width: z.number().min(1).max(4096).optional(), - height: z.number().min(1).max(4096).optional(), + width: z.number().min(1).max(16384).optional(), + height: z.number().min(1).max(16384).optional(), percentage: z.number().min(1).max(500).optional(), // Optimize diff --git a/apps/api/src/routes/tools/image-to-pdf.ts b/apps/api/src/routes/tools/image-to-pdf.ts index c2b820fa..8bae3f5d 100644 --- a/apps/api/src/routes/tools/image-to-pdf.ts +++ b/apps/api/src/routes/tools/image-to-pdf.ts @@ -13,7 +13,7 @@ import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ pageSize: z.enum(["A4", "Letter", "A3", "A5"]).default("A4"), orientation: z.enum(["portrait", "landscape"]).default("portrait"), - margin: z.number().min(0).max(100).default(20), + margin: z.number().min(0).max(500).default(20), }); const PAGE_SIZES: Record = { diff --git a/apps/api/src/routes/tools/passport-photo.ts b/apps/api/src/routes/tools/passport-photo.ts index efca7257..404ed8c4 100644 --- a/apps/api/src/routes/tools/passport-photo.ts +++ b/apps/api/src/routes/tools/passport-photo.ts @@ -36,7 +36,7 @@ const generateSettingsSchema = z.object({ bgColor: z.string().default("#FFFFFF"), printLayout: z.string().default("none"), maxFileSizeKb: z.number().default(0), - dpi: z.number().min(72).max(600).default(300), + dpi: z.number().min(72).max(1200).default(300), customWidthMm: z.number().optional(), customHeightMm: z.number().optional(), zoom: z.number().min(0.5).max(3).default(1), diff --git a/apps/api/src/routes/tools/pdf-to-image.ts b/apps/api/src/routes/tools/pdf-to-image.ts index aa80ae55..43180b65 100644 --- a/apps/api/src/routes/tools/pdf-to-image.ts +++ b/apps/api/src/routes/tools/pdf-to-image.ts @@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify"; import * as mupdf from "mupdf"; import sharp from "sharp"; import { z } from "zod"; +import { env } from "../../config.js"; import { formatZodErrors } from "../../lib/errors.js"; import { encodeHeic } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -14,7 +15,7 @@ import { createWorkspace } from "../../lib/workspace.js"; // ── Settings schema ────────────────────────────────────────────── const settingsSchema = z.object({ format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"), - dpi: z.number().min(36).max(1200).default(150), + dpi: z.number().min(36).max(2400).default(150), quality: z.number().min(1).max(100).default(85), colorMode: z.enum(["color", "grayscale", "bw"]).default("color"), pages: z.string().default("all"), @@ -229,7 +230,7 @@ export function registerPdfToImage(app: FastifyInstance) { return reply.status(400).send({ error: "Password-protected PDFs are not supported" }); } const pageCount = doc.countPages(); - const maxPages = Math.min(pageCount, 200); + const maxPages = env.MAX_PDF_PAGES > 0 ? Math.min(pageCount, env.MAX_PDF_PAGES) : pageCount; const thumbnails: Array<{ page: number; dataUrl: string; diff --git a/apps/api/src/routes/tools/qr-generate.ts b/apps/api/src/routes/tools/qr-generate.ts index b42245a4..eaf0a797 100644 --- a/apps/api/src/routes/tools/qr-generate.ts +++ b/apps/api/src/routes/tools/qr-generate.ts @@ -9,7 +9,7 @@ import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ text: z.string().min(1).max(2000), - size: z.number().min(100).max(2000).default(400), + size: z.number().min(100).max(10000).default(400), errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"), foreground: z .string() diff --git a/apps/api/src/routes/tools/sharpening.ts b/apps/api/src/routes/tools/sharpening.ts index ddbbb113..ff044f06 100644 --- a/apps/api/src/routes/tools/sharpening.ts +++ b/apps/api/src/routes/tools/sharpening.ts @@ -15,7 +15,7 @@ const settingsSchema = z.object({ y2: z.number().min(0).max(50).default(12), y3: z.number().min(0).max(50).default(20), // Unsharp Mask - amount: z.number().min(0).max(500).default(100), + amount: z.number().min(0).max(1000).default(100), radius: z.number().min(0.1).max(5).default(1.0), threshold: z.number().min(0).max(255).default(0), // High-Pass diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index e76fa40b..4d0d70a3 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -9,8 +9,8 @@ import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ - columns: z.number().min(1).max(20).default(3), - rows: z.number().min(1).max(20).default(3), + columns: z.number().min(1).max(100).default(3), + rows: z.number().min(1).max(100).default(3), tileWidth: z.number().min(10).optional(), tileHeight: z.number().min(10).optional(), outputFormat: z.enum(["original", "png", "jpg", "webp"]).default("original"), @@ -89,8 +89,8 @@ export function registerSplit(app: FastifyInstance) { cols = Math.max(1, Math.ceil(fullW / settings.tileWidth)); rows = Math.max(1, Math.ceil(fullH / settings.tileHeight)); } - cols = Math.min(cols, 20); - rows = Math.min(rows, 20); + cols = Math.min(cols, 100); + rows = Math.min(rows, 100); const cellW = Math.floor(fullW / cols); const cellH = Math.floor(fullH / rows); diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 788ccab0..1d916c42 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -4,22 +4,21 @@ import { basename, join } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { env } from "../../config.js"; import { autoOrient } from "../../lib/auto-orient.js"; import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; -const MAX_CANVAS_PIXELS = 100_000_000; - const settingsSchema = z.object({ direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"), - gridColumns: z.number().int().min(2).max(10).default(2), + gridColumns: z.number().int().min(2).max(100).default(2), resizeMode: z.enum(["fit", "original", "stretch", "crop"]).default("fit"), alignment: z.enum(["start", "center", "end"]).default("center"), - gap: z.number().min(0).max(200).default(0), - border: z.number().min(0).max(50).default(0), - cornerRadius: z.number().min(0).max(50).default(0), + gap: z.number().min(0).max(1000).default(0), + border: z.number().min(0).max(500).default(0), + cornerRadius: z.number().min(0).max(500).default(0), backgroundColor: z .string() .regex(/^#[0-9a-fA-F]{6}$/) @@ -180,9 +179,10 @@ export function registerStitch(app: FastifyInstance) { } } - if (canvasWidth * canvasHeight > MAX_CANVAS_PIXELS) { + const maxCanvasPixels = env.MAX_CANVAS_PIXELS > 0 ? env.MAX_CANVAS_PIXELS : Infinity; + if (canvasWidth * canvasHeight > maxCanvasPixels) { return reply.status(422).send({ - error: `Canvas too large: ${canvasWidth}x${canvasHeight} (${Math.round((canvasWidth * canvasHeight) / 1_000_000)}MP exceeds 100MP limit)`, + error: `Canvas too large: ${canvasWidth}x${canvasHeight} (${Math.round((canvasWidth * canvasHeight) / 1_000_000)}MP exceeds ${Math.round(maxCanvasPixels / 1_000_000)}MP limit)`, }); } diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index c4ae0cd1..7e3ac4b3 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -7,6 +7,7 @@ import PQueue from "p-queue"; import sharp from "sharp"; import { z } from "zod"; import { env } from "../../config.js"; +import { resolveConcurrency } from "../../lib/env.js"; import { formatZodErrors } from "../../lib/errors.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; @@ -17,9 +18,9 @@ import { updateJobProgress } from "../progress.js"; const NON_PREVIEWABLE = new Set(["tiff", "heif"]); const settingsSchema = z.object({ - width: z.number().min(1).max(16384).optional(), - height: z.number().min(1).max(16384).optional(), - dpi: z.number().min(36).max(1200).default(300), + width: z.number().min(1).max(65536).optional(), + height: z.number().min(1).max(65536).optional(), + dpi: z.number().min(36).max(2400).default(300), quality: z.number().min(1).max(100).default(90), backgroundColor: z .string() @@ -135,7 +136,7 @@ export function registerSvgToRaster(app: FastifyInstance) { return reply.status(400).send({ error: "No SVG files provided" }); } - if (files.length > env.MAX_BATCH_SIZE) { + if (env.MAX_BATCH_SIZE > 0 && files.length > env.MAX_BATCH_SIZE) { return reply.status(400).send({ error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`, }); @@ -157,7 +158,7 @@ export function registerSvgToRaster(app: FastifyInstance) { } const jobId = clientJobId || randomUUID(); - const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS }); + const queue = new PQueue({ concurrency: resolveConcurrency(env) }); const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill( null, ); diff --git a/apps/api/src/routes/tools/vectorize.ts b/apps/api/src/routes/tools/vectorize.ts index 581344bf..18939389 100644 --- a/apps/api/src/routes/tools/vectorize.ts +++ b/apps/api/src/routes/tools/vectorize.ts @@ -14,9 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ colorMode: z.enum(["bw", "color"]).default("bw"), threshold: z.number().min(0).max(255).default(128), - colorPrecision: z.number().min(1).max(8).default(6), - layerDifference: z.number().min(1).max(64).default(6), - filterSpeckle: z.number().min(1).max(128).default(4), + colorPrecision: z.number().min(1).max(16).default(6), + layerDifference: z.number().min(1).max(128).default(6), + filterSpeckle: z.number().min(1).max(256).default(4), pathMode: z.enum(["none", "polygon", "spline"]).default("spline"), cornerThreshold: z.number().min(0).max(180).default(60), invert: z.boolean().default(false), diff --git a/apps/api/src/routes/tools/watermark-text.ts b/apps/api/src/routes/tools/watermark-text.ts index b8c33916..a98fa014 100644 --- a/apps/api/src/routes/tools/watermark-text.ts +++ b/apps/api/src/routes/tools/watermark-text.ts @@ -5,7 +5,7 @@ import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ text: z.string().min(1).max(500), - fontSize: z.number().min(8).max(200).default(48), + fontSize: z.number().min(8).max(1000).default(48), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/) diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 3a89c995..7c19ace8 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -102,7 +102,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const user = requireAuth(request, reply); if (!user) return; - const limit = Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 200); + const limit = parseInt(request.query.limit ?? "50", 10) || 50; const offset = parseInt(request.query.offset ?? "0", 10) || 0; const search = request.query.search?.trim(); diff --git a/apps/web/src/components/common/image-viewer.tsx b/apps/web/src/components/common/image-viewer.tsx index bb2dd4c6..83ffee95 100644 --- a/apps/web/src/components/common/image-viewer.tsx +++ b/apps/web/src/components/common/image-viewer.tsx @@ -27,7 +27,7 @@ interface ImageViewerProps { imageWrapperStyle?: React.CSSProperties; } -const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200, 300]; +const ZOOM_STEPS = [10, 25, 50, 75, 100, 150, 200, 300, 500, 1000]; const DEFAULT_ZOOM = 100; export function ImageViewer({ diff --git a/apps/web/src/components/tools/barcode-read-settings.tsx b/apps/web/src/components/tools/barcode-read-settings.tsx index 9502c24d..589dfb60 100644 --- a/apps/web/src/components/tools/barcode-read-settings.tsx +++ b/apps/web/src/components/tools/barcode-read-settings.tsx @@ -72,7 +72,7 @@ function scanOneFile( formData.append("settings", JSON.stringify({ tryHarder })); const xhr = new XMLHttpRequest(); - xhr.timeout = 60_000; + xhr.timeout = 300_000; xhr.upload.onprogress = (e) => { if (e.lengthComputable) onUploadProgress((e.loaded / e.total) * 100); diff --git a/apps/web/src/components/tools/collage-preview.tsx b/apps/web/src/components/tools/collage-preview.tsx index 41bfe2bd..084151eb 100644 --- a/apps/web/src/components/tools/collage-preview.tsx +++ b/apps/web/src/components/tools/collage-preview.tsx @@ -349,8 +349,8 @@ function CollageCell({ if (first) memo = { panX: transform.panX, panY: transform.panY }; const rect = cellRef.current?.getBoundingClientRect(); if (!rect || !memo) return memo; - const panX = Math.max(-100, Math.min(100, memo.panX + (mx / rect.width) * 100)); - const panY = Math.max(-100, Math.min(100, memo.panY + (my / rect.height) * 100)); + const panX = Math.max(-200, Math.min(200, memo.panX + (mx / rect.width) * 100)); + const panY = Math.max(-200, Math.min(200, memo.panY + (my / rect.height) * 100)); store.setCellTransform(cellIndex, { panX, panY }); return memo; }, @@ -360,11 +360,11 @@ function CollageCell({ const bindPinch = usePinch( ({ offset: [scale] }) => { if (!image || !isSelected) return; - const zoom = Math.max(1, Math.min(3, scale)); + const zoom = Math.max(1, Math.min(10, scale)); store.setCellTransform(cellIndex, { zoom }); }, { - scaleBounds: { min: 1, max: 3 }, + scaleBounds: { min: 1, max: 10 }, from: () => [transform.zoom, 0], }, ); @@ -378,7 +378,7 @@ function CollageCell({ const handleWheel = (e: WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; - const zoom = Math.max(1, Math.min(3, zoomRef.current + delta)); + const zoom = Math.max(1, Math.min(10, zoomRef.current + delta)); store.setCellTransform(cellIndex, { zoom }); }; el.addEventListener("wheel", handleWheel, { passive: false }); @@ -555,7 +555,7 @@ function CollageCell({ { if (event.lengthComputable) { diff --git a/apps/web/src/components/tools/image-to-pdf-settings.tsx b/apps/web/src/components/tools/image-to-pdf-settings.tsx index 82c4842b..0df9391d 100644 --- a/apps/web/src/components/tools/image-to-pdf-settings.tsx +++ b/apps/web/src/components/tools/image-to-pdf-settings.tsx @@ -167,7 +167,7 @@ export function ImageToPdfSettings() { const xhr = new XMLHttpRequest(); xhrRef.current = xhr; - xhr.timeout = 180_000; + xhr.timeout = 300_000; xhr.upload.onprogress = (event) => { if (event.lengthComputable) { diff --git a/apps/web/src/components/tools/passport-photo-settings.tsx b/apps/web/src/components/tools/passport-photo-settings.tsx index 9159e560..be79d456 100644 --- a/apps/web/src/components/tools/passport-photo-settings.tsx +++ b/apps/web/src/components/tools/passport-photo-settings.tsx @@ -1062,8 +1062,8 @@ export function PassportPhotoPreview() { if (!dragStartRef.current) return; const dx = (e.clientX - dragStartRef.current.x) * 0.001; const dy = (e.clientY - dragStartRef.current.y) * 0.001; - setAdjustX(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ax - dx))); - setAdjustY(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ay - dy))); + setAdjustX(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ax - dx))); + setAdjustY(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ay - dy))); } function handleMouseUp() { @@ -1083,7 +1083,7 @@ export function PassportPhotoPreview() { const handleWheel = useCallback( (e: React.WheelEvent) => { e.preventDefault(); - setZoom(Math.max(0.5, Math.min(3, zoom + (e.deltaY > 0 ? -0.1 : 0.1)))); + setZoom(Math.max(0.5, Math.min(5, zoom + (e.deltaY > 0 ? -0.1 : 0.1)))); }, [zoom, setZoom], ); @@ -1138,7 +1138,7 @@ export function PassportPhotoPreview() {