chore: remove dead code, add test infrastructure, update docs

- Delete 3 dead files: use-batch-processor.ts, use-i18n.ts, smart-crop.ts (AI package)
- Remove dead getJobProgress function and unused runPythonScript wrapper
- Remove 6 unused imports across API and web apps
- Remove unused shared types (ImageFormat, AppConfig, ApiError, HealthResponse, JobProgress)
  and constants (SUPPORTED_INPUT_FORMATS/OUTPUT_FORMATS, DEFAULT_OUTPUT_FORMAT)
- Remove unused store method (setOriginalBlobUrl) and clean AI package re-exports
- Add test infrastructure: vitest config, unit/integration/e2e tests, fixtures, screenshots
- Add Docker test infrastructure: Dockerfile.test, docker-compose.test.yml
- Add download_models.py for pre-baking AI model weights in Docker
- Add filename sanitization utility (apps/api/src/lib/filename.ts)
- Update .gitignore to exclude coverage/, *.tsbuildinfo, .superpowers/, test artifacts
- Update .dockerignore to exclude test/coverage/IDE artifacts from builds
- Update docs: remove smart crop from AI docs (uses Sharp directly), update bridge docs
This commit is contained in:
Siddharth Kumar Sah
2026-03-23 11:46:45 +08:00
parent 8db84a753c
commit 80e536bcf8
74 changed files with 7247 additions and 487 deletions
+46 -22
View File
@@ -17,6 +17,7 @@ import { registerPipelineRoutes } from "./routes/pipeline.js";
import { registerProgressRoutes } from "./routes/progress.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
import { settingsRoutes } from "./routes/settings.js";
import { db, schema } from "./db/index.js";
// Run before anything else
runMigrations();
@@ -31,27 +32,42 @@ const app = Fastify({
});
// Plugins
await app.register(cors, { origin: true });
await app.register(cors, {
origin: env.CORS_ORIGIN
? env.CORS_ORIGIN.split(",").map((s) => s.trim())
: process.env.NODE_ENV === "production" ? false : true,
});
// Security headers
app.addHook("onSend", async (_request, reply) => {
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "DENY");
reply.header("X-XSS-Protection", "0");
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
});
await app.register(rateLimit, {
max: env.RATE_LIMIT_PER_MIN,
timeWindow: "1 minute",
});
// Swagger / OpenAPI documentation
await app.register(swagger, {
openapi: {
info: {
title: "Stirling Image API",
description: "API for Stirling Image — self-hosted image processing suite",
version: APP_VERSION,
// Swagger / OpenAPI documentation (dev only)
if (process.env.NODE_ENV !== "production") {
await app.register(swagger, {
openapi: {
info: {
title: "Stirling Image API",
description: "API for Stirling Image — self-hosted image processing suite",
version: APP_VERSION,
},
servers: [{ url: `http://localhost:1349` }],
},
servers: [{ url: `http://localhost:1349` }],
},
});
});
await app.register(swaggerUi, {
routePrefix: "/api/docs",
});
await app.register(swaggerUi, {
routePrefix: "/api/docs",
});
}
// Multipart upload support
await registerUpload(app);
@@ -84,14 +100,22 @@ await apiKeyRoutes(app);
await settingsRoutes(app);
// Health check
app.get("/api/v1/health", async () => ({
status: "healthy",
version: APP_VERSION,
uptime: process.uptime().toFixed(0) + "s",
storage: { mode: env.STORAGE_MODE, available: "N/A" },
queue: { active: 0, pending: 0 },
ai: {},
}));
app.get("/api/v1/health", async () => {
let dbOk = false;
try {
db.select().from(schema.settings).limit(1).all();
dbOk = true;
} catch { /* db unreachable */ }
return {
status: dbOk ? "healthy" : "degraded",
version: APP_VERSION,
uptime: process.uptime().toFixed(0) + "s",
storage: { mode: env.STORAGE_MODE, available: "N/A" },
database: dbOk ? "ok" : "error",
queue: { active: 0, pending: 0 },
ai: {},
};
});
// Public config endpoint (for frontend to know if auth is required)
app.get("/api/v1/config/auth", async () => ({
+2 -1
View File
@@ -4,7 +4,7 @@ const envSchema = z.object({
PORT: z.coerce.number().default(1350),
AUTH_ENABLED: z
.enum(["true", "false"])
.default("false")
.default("true")
.transform((v) => v === "true"),
DEFAULT_USERNAME: z.string().default("admin"),
DEFAULT_PASSWORD: z.string().default("admin"),
@@ -21,6 +21,7 @@ const envSchema = z.object({
DEFAULT_THEME: z.enum(["light", "dark"]).default("light"),
DEFAULT_LOCALE: z.string().default("en"),
APP_NAME: z.string().default("Stirling Image"),
CORS_ORIGIN: z.string().default(""),
});
export type Env = z.infer<typeof envSchema>;
+7
View File
@@ -26,6 +26,7 @@ const MAGIC_BYTES: MagicEntry[] = [
{ bytes: [0x42, 0x4d], offset: 0, format: "bmp" },
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" },
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" },
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below
];
export interface ValidationResult {
@@ -110,6 +111,12 @@ function detectMagicBytes(buffer: Buffer): string | null {
const sig = buffer.slice(8, 12).toString("ascii");
if (sig !== "WEBP") continue;
}
// For ftyp, verify AVIF brand at bytes 8-11
if (entry.format === "avif") {
if (buffer.length < 12) continue;
const brand = buffer.slice(8, 12).toString("ascii");
if (brand !== "avif" && brand !== "avis") continue;
}
return entry.format;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { basename } from "node:path";
/**
* Sanitize a filename to prevent path traversal attacks.
* Strips directory separators and ".." sequences, keeps only the base name.
*/
export function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "upload";
}
return name;
}
+3
View File
@@ -17,6 +17,9 @@ export async function createWorkspace(jobId: string): Promise<string> {
* Get the workspace root path for a job.
*/
export function getWorkspacePath(jobId: string): string {
if (jobId.includes("..") || jobId.includes("/") || jobId.includes("\\") || jobId.includes("\0")) {
throw new Error("Invalid job ID");
}
return join(env.WORKSPACE_PATH, jobId);
}
+49 -3
View File
@@ -90,7 +90,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
username: env.DEFAULT_USERNAME,
passwordHash,
role: "admin",
mustChangePassword: true,
mustChangePassword: false,
})
.run();
@@ -101,7 +101,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/login
app.post("/api/auth/login", async (request: FastifyRequest, reply: FastifyReply) => {
app.post("/api/auth/login", { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as { username?: string; password?: string } | null;
if (!body?.username || !body?.password) {
@@ -244,6 +244,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
.where(eq(schema.users.id, authUser.id))
.run();
// Invalidate all other sessions for this user
const currentToken = extractToken(request);
const allSessions = db.select().from(schema.sessions).where(eq(schema.sessions.userId, authUser.id)).all();
for (const s of allSessions) {
if (s.id !== currentToken) {
db.delete(schema.sessions).where(eq(schema.sessions.id, s.id)).run();
}
}
return reply.send({ ok: true });
});
@@ -388,7 +397,7 @@ function extractToken(request: FastifyRequest): string | null {
// ── Auth middleware ────────────────────────────────────────────────
const PUBLIC_PATHS = ["/api/v1/health", "/api/v1/config/", "/api/auth/", "/api/docs", "/api/v1/download/", "/api/v1/jobs/"];
const PUBLIC_PATHS = ["/api/v1/health", "/api/v1/config/", "/api/auth/", "/api/v1/download/", "/api/v1/jobs/"];
function isPublicRoute(url: string): boolean {
// Non-API routes are public (SPA static files — auth is handled client-side)
@@ -439,6 +448,32 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
.where(eq(schema.sessions.id, token))
.run();
}
// Try API key authentication if token has si_ prefix
if (token.startsWith("si_")) {
const apiKeys = db.select().from(schema.apiKeys).all();
for (const key of apiKeys) {
const matches = await verifyPassword(token, key.keyHash);
if (matches) {
// Update lastUsedAt
db.update(schema.apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(schema.apiKeys.id, key.id))
.run();
// Load the user
const apiUser = db.select().from(schema.users).where(eq(schema.users.id, key.userId)).get();
if (apiUser) {
(request as FastifyRequest & { user?: AuthUser }).user = {
id: apiUser.id,
username: apiUser.username,
role: apiUser.role as "admin" | "user",
};
return;
}
}
}
}
// Public routes can proceed without a valid session
if (isPublic) return;
return reply.status(401).send({ error: "Session expired or invalid" });
@@ -462,6 +497,17 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
username: user.username,
role: user.role as "admin" | "user",
};
// Enforce mustChangePassword — block non-auth API calls
if (user.mustChangePassword) {
const allowed = ["/api/auth/change-password", "/api/auth/logout", "/api/auth/session", "/api/v1/config/"];
if (!allowed.some((p) => request.url.startsWith(p)) && request.url.startsWith("/api/")) {
return reply.status(403).send({
error: "Password change required",
code: "MUST_CHANGE_PASSWORD",
});
}
}
},
);
}
+1 -14
View File
@@ -8,28 +8,15 @@
* Returns a ZIP file containing all processed images.
*/
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import archiver from "archiver";
import PQueue from "p-queue";
import { getToolConfig } from "./tool-factory.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { env } from "../config.js";
import { updateJobProgress, type JobProgress } from "./progress.js";
/**
* Sanitize a filename to prevent path traversal attacks.
*/
function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "image";
}
return name;
}
interface ParsedFile {
buffer: Buffer;
filename: string;
+2 -19
View File
@@ -1,27 +1,10 @@
import { randomUUID } from "node:crypto";
import { writeFile, readFile, stat } from "node:fs/promises";
import { join, basename, extname } from "node:path";
import { join, extname } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
import { validateImageBuffer } from "../lib/file-validation.js";
/**
* Sanitize a filename to prevent path traversal attacks.
* Strips directory separators and `..` sequences, keeps only the base name.
*/
function sanitizeFilename(raw: string): string {
// Take only the base name (no directories)
let name = basename(raw);
// Remove any remaining path traversal sequences
name = name.replace(/\.\./g, "");
// Remove null bytes
name = name.replace(/\0/g, "");
// If nothing is left, use a fallback
if (!name || name === "." || name === "..") {
name = "upload";
}
return name;
}
import { sanitizeFilename } from "../lib/filename.js";
/**
* Guard against path traversal in URL params.
+4 -16
View File
@@ -8,13 +8,14 @@
*/
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { join } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getToolConfig } from "./tool-factory.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { createWorkspace } from "../lib/workspace.js";
import { sanitizeFilename } from "../lib/filename.js";
import { db, schema } from "../db/index.js";
/** Schema for a single pipeline step. */
@@ -25,29 +26,16 @@ const pipelineStepSchema = z.object({
/** Schema for a full pipeline definition. */
const pipelineDefinitionSchema = z.object({
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step"),
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step").max(20, "Pipeline cannot exceed 20 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(),
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step"),
steps: z.array(pipelineStepSchema).min(1, "Pipeline must have at least one step").max(20, "Pipeline cannot exceed 20 steps"),
});
/**
* Sanitize a filename to prevent path traversal attacks.
*/
function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "image";
}
return name;
}
export async function registerPipelineRoutes(app: FastifyInstance): Promise<void> {
/**
* POST /api/v1/pipeline/execute
-7
View File
@@ -75,13 +75,6 @@ export function updateSingleFileProgress(
}
}
/**
* Get current progress for a job.
*/
export function getJobProgress(jobId: string): JobProgress | undefined {
return jobProgressStore.get(jobId);
}
export async function registerProgressRoutes(
app: FastifyInstance,
): Promise<void> {
+5 -17
View File
@@ -1,10 +1,11 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, extname, basename } from "node:path";
import { join } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
import { createWorkspace } from "../lib/workspace.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */
@@ -34,19 +35,6 @@ export function getToolConfig(toolId: string): ToolRouteConfig<any> | undefined
return toolRegistry.get(toolId);
}
/**
* Sanitize a filename to prevent path traversal attacks.
*/
function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "image";
}
return name;
}
/**
* Factory that registers a POST /api/v1/tools/:toolId route.
*
@@ -159,11 +147,11 @@ export function createToolRoute<T>(
});
} catch (err) {
// Catch Sharp / processing errors and return a clean API error
const message =
err instanceof Error ? err.message : "Image processing failed";
const message = err instanceof Error ? err.message : "Image processing failed";
request.log.error({ err, toolId: config.toolId }, "Tool processing failed");
return reply.status(422).send({
error: "Processing failed",
details: message,
details: process.env.NODE_ENV === "production" ? undefined : message,
});
}
},
@@ -2,6 +2,7 @@ import sharp from "sharp";
import jsQR from "jsqr";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Read QR codes and barcodes from uploaded images.
@@ -36,6 +37,12 @@ export function registerBarcodeRead(app: FastifyInstance) {
return reply.status(400).send({ error: "No image file provided" });
}
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
// Convert to RGBA raw pixel data for jsQR
const image = sharp(fileBuffer);
+6
View File
@@ -5,6 +5,7 @@ import { join, basename } from "node:path";
import { blurFaces } from "@stirling-image/ai";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Face detection and blurring route.
@@ -46,6 +47,11 @@ export function registerBlurFaces(app: FastifyInstance) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const jobId = randomUUID();
+1 -1
View File
@@ -85,7 +85,7 @@ export function registerBulkRename(app: FastifyInstance) {
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) +
ext;
archive.append(files[i].buffer, { name: newName });
archive.append(files[i].buffer, { name: basename(newName) });
}
await archive.finalize();
+9
View File
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
const settingsSchema = z.object({
layout: z.enum(["2x2", "3x3", "1x3", "2x1", "3x1", "1x2"]).default("2x2"),
@@ -54,6 +55,14 @@ export function registerCollage(app: FastifyInstance) {
return reply.status(400).send({ error: "No images provided" });
}
// Validate all files
for (const file of files) {
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
}
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
+2 -1
View File
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
import { sanitizeFilename } from "../../lib/filename.js";
const settingsSchema = z.object({
x: z.number().min(0).default(0),
@@ -41,7 +42,7 @@ export function registerCompose(app: FastifyInstance) {
overlayBuffer = buf;
} else {
baseBuffer = buf;
filename = part.filename ?? "image";
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
+10
View File
@@ -5,6 +5,7 @@ import { join, basename } from "node:path";
import { inpaint } from "@stirling-image/ai";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* Object eraser / inpainting route.
@@ -54,6 +55,15 @@ export function registerEraseObject(app: FastifyInstance) {
.send({ error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'" });
}
const imageValidation = await validateImageBuffer(imageBuffer);
if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
}
const maskValidation = await validateImageBuffer(maskBuffer);
if (!maskValidation.valid) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
try {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
-2
View File
@@ -1,9 +1,7 @@
import { z } from "zod";
import sharp from "sharp";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
const FAVICON_SIZES = [
{ name: "favicon-16x16.png", size: 16, format: "png" as const },
+24 -1
View File
@@ -1,9 +1,16 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { z } from "zod";
import { extractText } from "@stirling-image/ai";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
const settingsSchema = z.object({
engine: z.enum(["tesseract", "paddleocr"]).default("tesseract"),
language: z.enum(["en", "de", "fr", "es", "zh", "ja", "ko"]).default("en"),
});
/**
* OCR / text extraction route.
@@ -45,8 +52,24 @@ export function registerOcr(app: FastifyInstance) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
@@ -5,6 +5,7 @@ import { join, basename } from "node:path";
import { removeBackground } from "@stirling-image/ai";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* AI background removal route.
@@ -46,6 +47,11 @@ export function registerRemoveBackground(app: FastifyInstance) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const jobId = randomUUID();
@@ -13,6 +13,25 @@ const settingsSchema = z.object({
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
});
const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB
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`);
}
let svg = buffer.toString("utf-8");
// Remove DOCTYPE to prevent XXE
svg = svg.replace(/<!DOCTYPE[^>]*>/gi, "");
// Remove script tags
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
// Remove event handlers (onload, onclick, etc.)
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
// Remove external resource references
svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,');
svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,');
return Buffer.from(svg, "utf-8");
}
/**
* SVG to raster conversion.
* Custom route since input is SVG (not validated as image by magic bytes).
@@ -50,6 +69,15 @@ export function registerSvgToRaster(app: FastifyInstance) {
return reply.status(400).send({ error: "No SVG file provided" });
}
// Sanitize SVG to prevent XXE, SSRF, and script injection
try {
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
+6
View File
@@ -5,6 +5,7 @@ import { join, basename } from "node:path";
import { upscale } from "@stirling-image/ai";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
/**
* AI image upscaling route.
@@ -46,6 +47,11 @@ export function registerUpscale(app: FastifyInstance) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
try {
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
const scale = Number(settings.scale) || 2;
@@ -1,5 +1,4 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
+5 -1
View File
@@ -49,9 +49,13 @@ export function registerWatermarkText(app: FastifyInstance) {
const spacingX = settings.fontSize * 6;
const spacingY = settings.fontSize * 4;
let textElements = "";
for (let y = 0; y < height + spacingY; y += spacingY) {
const maxElements = 500;
let count = 0;
outer: for (let y = 0; y < height + spacingY; y += spacingY) {
for (let x = 0; x < width + spacingX; x += spacingX) {
if (count >= maxElements) break outer;
textElements += `<text x="${x}" y="${y}" font-size="${settings.fontSize}" fill="${rgba}" font-family="sans-serif" transform="rotate(${settings.rotation},${x},${y})">${escapedText}</text>`;
count++;
}
}
svgOverlay = `<svg width="${width}" height="${height}">${textElements}</svg>`;