mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -41,7 +41,7 @@ recoverInterruptedInstalls();
|
|||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: env.LOG_LEVEL },
|
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) => {
|
app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => {
|
||||||
@@ -79,12 +79,13 @@ app.addHook("onSend", async (_request, reply) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(rateLimit, {
|
if (env.RATE_LIMIT_PER_MIN > 0) {
|
||||||
max: env.RATE_LIMIT_PER_MIN,
|
await app.register(rateLimit, {
|
||||||
timeWindow: "1 minute",
|
max: env.RATE_LIMIT_PER_MIN,
|
||||||
// Only rate-limit API endpoints — static files and the SPA fallback must never be throttled
|
timeWindow: "1 minute",
|
||||||
allowList: (request) => !request.url.startsWith("/api/"),
|
allowList: (request) => !request.url.startsWith("/api/"),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Multipart upload support
|
// Multipart upload support
|
||||||
await registerUpload(app);
|
await registerUpload(app);
|
||||||
@@ -195,7 +196,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
const SHUTDOWN_TIMEOUT_MS = 8000;
|
const SHUTDOWN_TIMEOUT_MS = 30000;
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
async function shutdown(signal: string) {
|
async function shutdown(signal: string) {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function validateImageBuffer(
|
|||||||
const height = metadata.height ?? 0;
|
const height = metadata.height ?? 0;
|
||||||
const megapixels = (width * height) / 1_000_000;
|
const megapixels = (width * height) / 1_000_000;
|
||||||
|
|
||||||
if (megapixels > env.MAX_MEGAPIXELS) {
|
if (env.MAX_MEGAPIXELS > 0 && megapixels > env.MAX_MEGAPIXELS) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
reason: `Image exceeds maximum size: ${megapixels.toFixed(1)}MP (limit: ${env.MAX_MEGAPIXELS}MP)`,
|
reason: `Image exceeds maximum size: ${megapixels.toFixed(1)}MP (limit: ${env.MAX_MEGAPIXELS}MP)`,
|
||||||
|
|||||||
@@ -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.
|
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
|
||||||
* Throws if the SVG exceeds the maximum allowed size.
|
* Throws if the SVG exceeds the maximum allowed size.
|
||||||
*/
|
*/
|
||||||
export function sanitizeSvg(buffer: Buffer): Buffer {
|
export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||||
if (buffer.length > MAX_SVG_SIZE) {
|
const maxSvgSize = env.MAX_SVG_SIZE_MB > 0 ? env.MAX_SVG_SIZE_MB * 1024 * 1024 : Infinity;
|
||||||
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
|
if (buffer.length > maxSvgSize) {
|
||||||
|
throw new Error(`SVG exceeds maximum size of ${env.MAX_SVG_SIZE_MB}MB`);
|
||||||
}
|
}
|
||||||
let svg = buffer.toString("utf-8");
|
let svg = buffer.toString("utf-8");
|
||||||
// Remove DOCTYPE (XXE prevention, including internal subsets)
|
// Remove DOCTYPE (XXE prevention, including internal subsets)
|
||||||
|
|||||||
@@ -4,15 +4,14 @@
|
|||||||
* Uses Piscina (backed by worker_threads) so Sharp operations don't block
|
* Uses Piscina (backed by worker_threads) so Sharp operations don't block
|
||||||
* HTTP request handling, SSE streams, or health checks.
|
* HTTP request handling, SSE streams, or health checks.
|
||||||
*/
|
*/
|
||||||
import { availableParallelism } from "node:os";
|
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import Piscina from "piscina";
|
import Piscina from "piscina";
|
||||||
|
import { loadEnv, resolveWorkerThreads } from "./env.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// Size the pool: leave 1 thread for the event loop, min 1 worker
|
const maxThreads = resolveWorkerThreads(loadEnv());
|
||||||
const maxThreads = Math.max(1, Math.min(availableParallelism() - 1, 4));
|
|
||||||
|
|
||||||
let pool: Piscina | null = null;
|
let pool: Piscina | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply): Auth
|
|||||||
|
|
||||||
// ── Session helpers ────────────────────────────────────────────────
|
// ── 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 {
|
function createSessionToken(): string {
|
||||||
return randomUUID();
|
return randomUUID();
|
||||||
@@ -137,10 +137,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
|
|||||||
|
|
||||||
// ── Login attempt limit ──────────────────────────────────────────
|
// ── Login attempt limit ──────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
|
|
||||||
|
|
||||||
function getLoginAttemptLimit(): number {
|
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;
|
if (env.RATE_LIMIT_PER_MIN > 1000) return env.RATE_LIMIT_PER_MIN;
|
||||||
const row = db
|
const row = db
|
||||||
.select()
|
.select()
|
||||||
@@ -151,7 +148,7 @@ function getLoginAttemptLimit(): number {
|
|||||||
const parsed = parseInt(row.value, 10);
|
const parsed = parseInt(row.value, 10);
|
||||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
||||||
}
|
}
|
||||||
return DEFAULT_LOGIN_ATTEMPT_LIMIT;
|
return env.LOGIN_ATTEMPT_LIMIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth routes ────────────────────────────────────────────────────
|
// ── Auth routes ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { env } from "../config.js";
|
|||||||
export async function registerUpload(app: FastifyInstance): Promise<void> {
|
export async function registerUpload(app: FastifyInstance): Promise<void> {
|
||||||
await app.register(multipart, {
|
await app.register(multipart, {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
|
fileSize: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined,
|
||||||
files: env.MAX_BATCH_SIZE,
|
files: env.MAX_BATCH_SIZE > 0 ? env.MAX_BATCH_SIZE : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
|
import { resolveConcurrency } from "../lib/env.js";
|
||||||
import { formatZodErrors } from "../lib/errors.js";
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { isToolInstalled } from "../lib/feature-status.js";
|
import { isToolInstalled } from "../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
@@ -90,7 +91,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enforce batch size limit
|
// 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({
|
return reply.status(400).send({
|
||||||
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
|
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 });
|
updateJobProgress({ ...progress });
|
||||||
|
|
||||||
// Use p-queue for concurrency control
|
// 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.
|
// 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.
|
// 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 { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { ensureSharpCompat } from "../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../lib/heic-converter.js";
|
||||||
import { requireAdmin } from "../plugins/auth.js";
|
import { requireAdmin } from "../plugins/auth.js";
|
||||||
|
|
||||||
const BRANDING_DIR = join(process.cwd(), "data", "branding");
|
const BRANDING_DIR = join(process.cwd(), "data", "branding");
|
||||||
const LOGO_PATH = join(BRANDING_DIR, "logo.png");
|
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 {
|
function upsertSetting(key: string, value: string): void {
|
||||||
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
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();
|
const buffer = await file.toBuffer();
|
||||||
|
|
||||||
// Validate size
|
// Validate size
|
||||||
if (buffer.length > MAX_LOGO_SIZE) {
|
if (buffer.length > maxLogoSize) {
|
||||||
return reply
|
return reply.status(400).send({
|
||||||
.status(400)
|
error: `Logo must be ${env.MAX_LOGO_SIZE_KB}KB or smaller`,
|
||||||
.send({ error: "Logo must be 500KB or smaller", code: "VALIDATION_ERROR" });
|
code: "VALIDATION_ERROR",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode HEIC/HEIF if needed, then convert to PNG, resize to max 128x128
|
// 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 { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
|
import { resolveConcurrency } from "../lib/env.js";
|
||||||
import { formatZodErrors } from "../lib/errors.js";
|
import { formatZodErrors } from "../lib/errors.js";
|
||||||
import { isToolInstalled } from "../lib/feature-status.js";
|
import { isToolInstalled } from "../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
@@ -39,7 +40,9 @@ const pipelineDefinitionSchema = z.object({
|
|||||||
steps: z
|
steps: z
|
||||||
.array(pipelineStepSchema)
|
.array(pipelineStepSchema)
|
||||||
.min(1, "Pipeline must have at least one step")
|
.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. */
|
/** Schema for saving a pipeline. */
|
||||||
@@ -49,7 +52,9 @@ const savePipelineSchema = z.object({
|
|||||||
steps: z
|
steps: z
|
||||||
.array(pipelineStepSchema)
|
.array(pipelineStepSchema)
|
||||||
.min(1, "Pipeline must have at least one step")
|
.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> {
|
export async function registerPipelineRoutes(app: FastifyInstance): Promise<void> {
|
||||||
@@ -424,7 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enforce batch size limit
|
// 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({
|
return reply.status(400).send({
|
||||||
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
|
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 });
|
updateJobProgress({ ...progress });
|
||||||
|
|
||||||
// ── Process files through the pipeline with concurrency control ──
|
// ── 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(
|
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import * as mupdf from "mupdf";
|
import * as mupdf from "mupdf";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { env } from "../../config.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.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" });
|
return reply.status(400).send({ error: "Password-protected PDFs are not supported" });
|
||||||
}
|
}
|
||||||
const pageCount = doc.countPages();
|
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<{
|
const thumbnails: Array<{
|
||||||
page: number;
|
page: number;
|
||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { basename, join } from "node:path";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { env } from "../../config.js";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
const MAX_CANVAS_PIXELS = 100_000_000;
|
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"),
|
direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"),
|
||||||
gridColumns: z.number().int().min(2).max(100).default(2),
|
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({
|
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 sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
|
import { resolveConcurrency } from "../../lib/env.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.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" });
|
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({
|
return reply.status(400).send({
|
||||||
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
|
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 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(
|
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const user = requireAuth(request, reply);
|
const user = requireAuth(request, reply);
|
||||||
if (!user) return;
|
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 offset = parseInt(request.query.offset ?? "0", 10) || 0;
|
||||||
const search = request.query.search?.trim();
|
const search = request.query.search?.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -47,17 +47,11 @@ async function findCaire(): Promise<string> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Max pixels on the longest edge before downscaling for caire. */
|
|
||||||
const MAX_CAIRE_DIMENSION = 1200;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content-aware resize using caire (Go seam carving engine).
|
* Content-aware resize using caire (Go seam carving engine).
|
||||||
* Supports both shrinking and enlarging via seam removal/insertion.
|
* Supports both shrinking and enlarging via seam removal/insertion.
|
||||||
*
|
* Processes at native resolution -- JPEG intermediate is used because
|
||||||
* Large images (>1200px longest edge) are downscaled first because
|
* Go's JPEG decoder is significantly faster than PNG for large images.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function seamCarve(
|
export async function seamCarve(
|
||||||
inputBuffer: Buffer,
|
inputBuffer: Buffer,
|
||||||
@@ -66,35 +60,18 @@ export async function seamCarve(
|
|||||||
): Promise<SeamCarveResult> {
|
): Promise<SeamCarveResult> {
|
||||||
const cairePath = await findCaire();
|
const cairePath = await findCaire();
|
||||||
const id = randomUUID();
|
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 inputPath = join(outputDir, `caire-in-${id}.jpg`);
|
||||||
const outputPath = join(outputDir, `caire-out-${id}.png`);
|
const outputPath = join(outputDir, `caire-out-${id}.png`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Downscale large images and convert to JPEG for fast caire processing
|
|
||||||
const meta = await sharp(inputBuffer).metadata();
|
const meta = await sharp(inputBuffer).metadata();
|
||||||
const origWidth = meta.width ?? 0;
|
const width = meta.width ?? 0;
|
||||||
const origHeight = meta.height ?? 0;
|
const height = meta.height ?? 0;
|
||||||
const longest = Math.max(origWidth, origHeight);
|
|
||||||
|
|
||||||
let width = origWidth;
|
const processBuffer = await sharp(inputBuffer).jpeg({ quality: 95 }).toBuffer();
|
||||||
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();
|
|
||||||
|
|
||||||
await writeFile(inputPath, processBuffer);
|
await writeFile(inputPath, processBuffer);
|
||||||
|
|
||||||
// Build caire arguments
|
|
||||||
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
|
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
|
||||||
|
|
||||||
if (options.square) {
|
if (options.square) {
|
||||||
@@ -102,19 +79,10 @@ export async function seamCarve(
|
|||||||
args.push("-square", "-width", String(shortest), "-height", String(shortest));
|
args.push("-square", "-width", String(shortest), "-height", String(shortest));
|
||||||
} else {
|
} else {
|
||||||
if (options.width) {
|
if (options.width) {
|
||||||
// Scale user-specified dimensions proportionally if image was downscaled
|
args.push("-width", String(options.width));
|
||||||
const targetW =
|
|
||||||
longest > MAX_CAIRE_DIMENSION
|
|
||||||
? Math.round(options.width * (MAX_CAIRE_DIMENSION / longest))
|
|
||||||
: options.width;
|
|
||||||
args.push("-width", String(targetW));
|
|
||||||
}
|
}
|
||||||
if (options.height) {
|
if (options.height) {
|
||||||
const targetH =
|
args.push("-height", String(options.height));
|
||||||
longest > MAX_CAIRE_DIMENSION
|
|
||||||
? Math.round(options.height * (MAX_CAIRE_DIMENSION / longest))
|
|
||||||
: options.height;
|
|
||||||
args.push("-height", String(targetH));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +90,7 @@ export async function seamCarve(
|
|||||||
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
|
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
|
||||||
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
|
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);
|
const timeoutMs = Math.max(120_000, megapixels * 10 * 1000);
|
||||||
await execFileAsync(cairePath, args, { timeout: timeoutMs });
|
await execFileAsync(cairePath, args, { timeout: timeoutMs });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user