diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a8bac475..8e2891f2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -41,7 +41,7 @@ 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, }); app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { @@ -79,12 +79,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 +196,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/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/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/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 f5d6f3d6..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,7 +40,9 @@ 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. */ @@ -49,7 +52,9 @@ const savePipelineSchema = 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", + }), }); 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 0 ? Math.min(pageCount, env.MAX_PDF_PAGES) : pageCount; const thumbnails: Array<{ page: number; dataUrl: string; diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 98cc40d9..1d916c42 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -4,14 +4,13 @@ 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(100).default(2), @@ -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 f1814022..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"; @@ -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/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/packages/ai/src/seam-carving.ts b/packages/ai/src/seam-carving.ts index 5f58ff30..b11035fb 100644 --- a/packages/ai/src/seam-carving.ts +++ b/packages/ai/src/seam-carving.ts @@ -47,17 +47,11 @@ async function findCaire(): Promise { ); } -/** Max pixels on the longest edge before downscaling for caire. */ -const MAX_CAIRE_DIMENSION = 1200; - /** * Content-aware resize using caire (Go seam carving engine). * Supports both shrinking and enlarging via seam removal/insertion. - * - * Large images (>1200px longest edge) are downscaled first because - * seam carving is O(width * height * seams) and becomes impractical - * on high-resolution inputs. JPEG intermediate is used because Go's - * JPEG decoder is significantly faster than PNG for large images. + * Processes at native resolution -- JPEG intermediate is used because + * Go's JPEG decoder is significantly faster than PNG for large images. */ export async function seamCarve( inputBuffer: Buffer, @@ -66,35 +60,18 @@ export async function seamCarve( ): Promise { const cairePath = await findCaire(); const id = randomUUID(); - // Use JPEG for input (fast decode in Go) and PNG for output (lossless) const inputPath = join(outputDir, `caire-in-${id}.jpg`); const outputPath = join(outputDir, `caire-out-${id}.png`); try { - // Downscale large images and convert to JPEG for fast caire processing const meta = await sharp(inputBuffer).metadata(); - const origWidth = meta.width ?? 0; - const origHeight = meta.height ?? 0; - const longest = Math.max(origWidth, origHeight); + const width = meta.width ?? 0; + const height = meta.height ?? 0; - let width = origWidth; - let height = origHeight; - - if (longest > MAX_CAIRE_DIMENSION) { - const scale = MAX_CAIRE_DIMENSION / longest; - width = Math.round(origWidth * scale); - height = Math.round(origHeight * scale); - } - - // Always output JPEG for caire input (Go decodes JPEG 3-5x faster than PNG) - const processBuffer = await sharp(inputBuffer) - .resize(width, height, { fit: "fill" }) - .jpeg({ quality: 95 }) - .toBuffer(); + const processBuffer = await sharp(inputBuffer).jpeg({ quality: 95 }).toBuffer(); await writeFile(inputPath, processBuffer); - // Build caire arguments const args = ["-in", inputPath, "-out", outputPath, "-preview=false"]; if (options.square) { @@ -102,19 +79,10 @@ export async function seamCarve( args.push("-square", "-width", String(shortest), "-height", String(shortest)); } else { if (options.width) { - // Scale user-specified dimensions proportionally if image was downscaled - const targetW = - longest > MAX_CAIRE_DIMENSION - ? Math.round(options.width * (MAX_CAIRE_DIMENSION / longest)) - : options.width; - args.push("-width", String(targetW)); + args.push("-width", String(options.width)); } if (options.height) { - const targetH = - longest > MAX_CAIRE_DIMENSION - ? Math.round(options.height * (MAX_CAIRE_DIMENSION / longest)) - : options.height; - args.push("-height", String(targetH)); + args.push("-height", String(options.height)); } } @@ -122,7 +90,7 @@ export async function seamCarve( if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius)); if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold)); - const megapixels = (origWidth * origHeight) / 1_000_000; + const megapixels = (width * height) / 1_000_000; const timeoutMs = Math.max(120_000, megapixels * 10 * 1000); await execFileAsync(cairePath, args, { timeout: timeoutMs });