feat: make all hardcoded limits configurable via env vars

- bodyLimit: conditional on MAX_UPLOAD_SIZE_MB (0 = 1GB practical max)
- rate limiting: disabled when RATE_LIMIT_PER_MIN=0
- shutdown timeout: 8s → 30s
- upload plugin: no fileSize/files cap when env=0
- session duration: configurable via SESSION_DURATION_HOURS (default 168h)
- login attempts: configurable via LOGIN_ATTEMPT_LIMIT
- batch/pipeline/svg-to-raster: skip guard when MAX_BATCH_SIZE=0
- pipeline steps: configurable via MAX_PIPELINE_STEPS (0 = unlimited)
- user-files: remove 200 hard cap
- stitch canvas: configurable via MAX_CANVAS_PIXELS (0 = unlimited)
- PDF pages: configurable via MAX_PDF_PAGES (0 = unlimited)
- SVG size: configurable via MAX_SVG_SIZE_MB (0 = unlimited)
- logo size: configurable via MAX_LOGO_SIZE_KB (default 2048)
- worker threads: auto-detect via resolveWorkerThreads (0 = auto)
- megapixels: skip validation when MAX_MEGAPIXELS=0
- seam carving: remove 1200px dimension cap
- concurrency: auto-detect via resolveConcurrency (0 = auto)
This commit is contained in:
ashim-hq
2026-04-20 21:50:17 +08:00
parent be254f9ca6
commit 6746989aa1
14 changed files with 57 additions and 81 deletions
+9 -8
View File
@@ -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;
+1 -1
View File
@@ -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)`,
+4 -3
View File
@@ -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)
+2 -3
View File
@@ -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;
+2 -5
View File
@@ -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<void> {
// ── 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 ────────────────────────────────────────────────────
+2 -2
View File
@@ -5,8 +5,8 @@ import { env } from "../config.js";
export async function registerUpload(app: FastifyInstance): Promise<void> {
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,
},
});
}
+3 -2
View File
@@ -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<void> {
}
// 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<void> {
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.
+7 -5
View File
@@ -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<void> {
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
+9 -4
View File
@@ -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<void> {
@@ -424,7 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
}
// 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}`,
});
@@ -499,7 +504,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
updateJobProgress({ ...progress });
// ── Process files through the pipeline with concurrency control ──
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,
+2 -1
View File
@@ -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";
@@ -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;
+4 -4
View File
@@ -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)`,
});
}
+3 -2
View File
@@ -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,
);
+1 -1
View File
@@ -102,7 +102,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
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();