mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #80 from ashim-hq/feat/unlimited-by-default
feat: Unlimited by Default — remove all artificial limits
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
|
||||
import { sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { computeTimeout } from "../lib/timeout.js";
|
||||
import { getWorkerPool } from "../lib/worker-pool.js";
|
||||
import { createWorkspace } from "../lib/workspace.js";
|
||||
|
||||
@@ -227,8 +228,11 @@ export function createToolRoute<T>(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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, [number, number]> = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}$/)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user