fix: harden all AI tools against proxy timeouts and filename attacks

Convert all 9 AI tool routes (colorize, restore-photo, remove-background,
enhance-faces, blur-faces, red-eye-removal, erase-object, noise-removal,
upscale) to async 202 processing so none are vulnerable to proxy
connection timeouts.

Also fixes:
- Replace basename() with sanitizeFilename() in all AI tool routes
  (prevents double-extension attacks and adds length truncation)
- Add UUID format validation for clientJobId field
- Fix missing filename sanitization in noise-removal (was using raw
  user-supplied filename with zero sanitization)
- Remove em dash from error message in use-tool-processor
This commit is contained in:
SnapOtter
2026-05-01 00:11:03 +08:00
parent 4900d8a4fe
commit d12b1c0fc6
18 changed files with 818 additions and 628 deletions
+87 -66
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { blurFaces } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -51,11 +52,14 @@ export function registerBlurFaces(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -74,21 +78,23 @@ export function registerBlurFaces(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
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: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { blurRadius, sensitivity } = settings;
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
@@ -98,39 +104,52 @@ export function registerBlurFaces(app: FastifyInstance) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
const { blurRadius, sensitivity } = settings;
request.log.info(
{
toolId: "blur-faces",
imageSize: fileBuffer.length,
blurRadius,
sensitivity,
},
"Starting face blur",
);
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Workspace creation failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "blur-faces", imageSize: originalSize, blurRadius, sensitivity },
"Starting face blur",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
@@ -155,32 +174,34 @@ export function registerBlurFaces(app: FastifyInstance) {
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: outputBuffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
log.info({ toolId: "blur-faces", jobId, downloadUrl }, "Face blur complete");
})().catch((err) => {
log.error({ err, toolId: "blur-faces" }, "Face blur failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Face blur failed",
});
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Face blur failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
// Register in the pipeline/batch registry so this tool can be used
+86 -61
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { colorize } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -55,11 +56,14 @@ export function registerColorize(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -78,28 +82,23 @@ export function registerColorize(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
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: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { intensity, model } = settings;
request.log.info(
{ toolId: "colorize", imageSize: fileBuffer.length, intensity, model },
"Starting colorization",
);
const { intensity, model } = settings;
try {
// Decode HEIC/HEIF input
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -112,27 +111,51 @@ export function registerColorize(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Input decoding failed");
return reply.status(422).send({
error: "Colorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Workspace creation failed");
return reply.status(422).send({
error: "Colorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "colorize", imageSize: originalSize, intensity, model },
"Starting colorization",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar
const result = await colorize(
fileBuffer,
@@ -172,38 +195,40 @@ export function registerColorize(app: FastifyInstance) {
}
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (model !== "auto" && result.method !== model) {
request.log.warn(
log.warn(
{ toolId: "colorize", requested: model, actual: result.method },
`Colorize model mismatch: requested ${model} but used ${result.method}`,
);
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
},
});
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Colorization failed");
return reply.status(422).send({
error: "Colorization failed",
details: err instanceof Error ? err.message : "Unknown error",
log.info({ toolId: "colorize", jobId, downloadUrl }, "Colorize complete");
})().catch((err) => {
log.error({ err, toolId: "colorize" }, "Colorization failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Colorization failed",
});
}
});
});
// Register in the pipeline/batch registry
+86 -60
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { enhanceFaces } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -52,11 +53,14 @@ export function registerEnhanceFaces(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -75,27 +79,23 @@ export function registerEnhanceFaces(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
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: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { model, strength, onlyCenterFace, sensitivity } = settings;
request.log.info(
{ toolId: "enhance-faces", imageSize: fileBuffer.length, model, strength },
"Starting face enhancement",
);
const { model, strength, onlyCenterFace, sensitivity } = settings;
try {
// Decode HEIC/HEIF input via system decoder
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -108,27 +108,51 @@ export function registerEnhanceFaces(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation before face detection
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Workspace creation failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "enhance-faces", imageSize: originalSize, model, strength },
"Starting face enhancement",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await enhanceFaces(
fileBuffer,
join(workspacePath, "output"),
@@ -152,38 +176,40 @@ export function registerEnhanceFaces(app: FastifyInstance) {
// Non-fatal - frontend will show fallback
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (model !== "auto" && result.model !== model) {
request.log.warn(
log.warn(
{ toolId: "enhance-faces", requested: model, actual: result.model },
`Face enhance model mismatch: requested ${model} but used ${result.model}`,
);
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
model: result.model,
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
model: result.model,
},
});
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
log.info({ toolId: "enhance-faces", jobId, downloadUrl }, "Face enhancement complete");
})().catch((err) => {
log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Face enhancement failed",
});
}
});
});
// Register in the pipeline/batch registry so this tool can be used
+93 -68
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { inpaint } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -9,6 +9,7 @@ import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -74,10 +75,13 @@ export function registerEraseObject(app: FastifyInstance) {
maskBuffer = buf;
} else {
imageBuffer = buf;
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
} else if (part.fieldname === "format") {
format = (part.value as string) || "png";
} else if (part.fieldname === "quality") {
@@ -109,36 +113,26 @@ export function registerEraseObject(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
try {
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
request.log.info(
{
toolId: "erase-object",
imageSize: imageBuffer.length,
maskSize: maskBuffer.length,
format,
},
"Starting object erasure",
);
// Decode HEIC/HEIF input via system decoder
if (imageValidation.format === "heif") {
imageBuffer = await decodeHeic(imageBuffer);
@@ -151,27 +145,56 @@ export function registerEraseObject(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
imageBuffer = await autoOrient(imageBuffer);
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Input decoding failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = imageBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, imageBuffer);
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Workspace creation failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{
toolId: "erase-object",
imageSize: originalSize,
maskSize: maskBuffer.length,
format,
},
"Starting object erasure",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const resultBuffer = await inpaint(
imageBuffer,
maskBuffer,
@@ -232,27 +255,29 @@ export function registerEraseObject(app: FastifyInstance) {
}
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: imageBuffer.length,
processedSize: outputBuffer.length,
log.info({ toolId: "erase-object", jobId, downloadUrl }, "Object erasure complete");
})().catch((err) => {
log.error({ err, toolId: "erase-object" }, "Object erasing failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Object erasing failed",
});
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Object erasing failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
}
+76 -57
View File
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -56,11 +57,14 @@ export function registerNoiseRemoval(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = part.filename ?? "image";
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -79,55 +83,68 @@ export function registerNoiseRemoval(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let parsed: z.infer<typeof settingsSchema>;
try {
let parsed: z.infer<typeof settingsSchema>;
try {
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(raw);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
parsed = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(raw);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
parsed = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
request.log.info(
{ toolId: "noise-removal", imageSize: fileBuffer.length, tier: parsed.tier },
"Starting noise removal",
);
// Decode HEIC/HEIF input via system decoder
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
// Auto-orient to fix EXIF rotation before processing
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Input decoding failed");
return reply.status(422).send({
error: "Noise removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Workspace creation failed");
return reply.status(422).send({
error: "Noise removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "noise-removal", imageSize: originalSize, tier: parsed.tier },
"Starting noise removal",
);
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
(async () => {
const result = await noiseRemoval(
fileBuffer,
join(workspacePath, "output"),
@@ -147,27 +164,29 @@ export function registerNoiseRemoval(app: FastifyInstance) {
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
log.info({ toolId: "noise-removal", jobId, downloadUrl }, "Noise removal complete");
})().catch((err) => {
log.error({ err, toolId: "noise-removal" }, "Noise removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Noise removal failed",
});
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Noise removal failed");
return reply.status(422).send({
error: "Noise removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
// Register in the pipeline/batch registry so this tool can be used
+84 -63
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { removeRedEye } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -53,11 +54,14 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -76,21 +80,23 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
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: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { sensitivity, strength, format: outputFormat, quality } = settings;
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
@@ -100,39 +106,52 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
const { sensitivity, strength, format: outputFormat, quality } = settings;
request.log.info(
{
toolId: "red-eye-removal",
imageSize: fileBuffer.length,
sensitivity,
strength,
},
"Starting red eye removal",
);
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Input decoding failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Workspace creation failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "red-eye-removal", imageSize: originalSize, sensitivity, strength },
"Starting red eye removal",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await removeRedEye(
fileBuffer,
join(workspacePath, "output"),
@@ -151,29 +170,31 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
eyesCorrected: result.eyesCorrected,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
eyesCorrected: result.eyesCorrected,
log.info({ toolId: "red-eye-removal", jobId, downloadUrl }, "Red eye removal complete");
})().catch((err) => {
log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Red eye removal failed",
});
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
},
);
+89 -59
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { removeBackground } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { applyEffects } from "../../lib/bg-effects.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
@@ -69,11 +70,14 @@ export function registerRemoveBackground(app: FastifyInstance) {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -92,21 +96,21 @@ export function registerRemoveBackground(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
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: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Decode HEIC/HEIF before processing
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -123,31 +127,51 @@ export function registerRemoveBackground(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Input decoding failed");
return reply.status(422).send({
error: "Background removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
request.log.info(
{ toolId: "remove-background", imageSize: fileBuffer.length, model: settings.model },
"Starting background removal",
);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Workspace creation failed");
return reply.status(422).send({
error: "Background removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "remove-background", imageSize: originalSize, model: settings.model },
"Starting background removal",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Phase 1: AI background removal -> transparent PNG
const transparentResult = await removeBackground(
fileBuffer,
@@ -162,33 +186,39 @@ export function registerRemoveBackground(app: FastifyInstance) {
await writeFile(join(workspacePath, "output", maskFilename), transparentResult);
await writeFile(join(workspacePath, "output", originalFilename), fileBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`;
const maskUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`;
const originalUrl = `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`;
return reply.send({
jobId,
// The mask (transparent PNG) is the main preview
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
// Separate URLs for frontend CSS preview compositing
maskUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
originalUrl: `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`,
originalSize: fileBuffer.length,
processedSize: transparentResult.length,
filename,
model: settings.model,
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
maskUrl,
originalUrl,
originalSize,
processedSize: transparentResult.length,
filename,
model: settings.model,
},
});
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Background removal failed");
return reply.status(422).send({
error: "Background removal failed",
details: err instanceof Error ? err.message : "Unknown error",
log.info(
{ toolId: "remove-background", jobId, downloadUrl },
"Background removal complete",
);
})().catch((err) => {
log.error({ err, toolId: "remove-background" }, "Background removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Background removal failed",
});
}
});
},
);
+88 -63
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { restorePhoto } from "@snapotter/ai";
import { getBundleForTool } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -59,11 +60,14 @@ export function registerRestorePhoto(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -82,26 +86,21 @@ export function registerRestorePhoto(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
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: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
request.log.info(
{ toolId: "restore-photo", imageSize: fileBuffer.length, mode: settings.mode },
"Starting photo restoration",
);
try {
// Decode HEIC/HEIF input
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -114,27 +113,51 @@ export function registerRestorePhoto(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Workspace creation failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "restore-photo", imageSize: originalSize, mode: settings.mode },
"Starting photo restoration",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar
const result = await restorePhoto(
fileBuffer,
@@ -182,35 +205,37 @@ export function registerRestorePhoto(app: FastifyInstance) {
}
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
log.info({ toolId: "restore-photo", jobId, downloadUrl }, "Photo restoration complete");
})().catch((err) => {
log.error({ err, toolId: "restore-photo" }, "Photo restoration failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Photo restoration failed",
});
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Photo restoration failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
// Register in the pipeline/batch registry
+7 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { upscale } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -58,11 +59,14 @@ export function registerUpscale(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {