mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
+46
-22
@@ -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 () => ({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) : {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) : {};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
Reference in New Issue
Block a user