mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Custom-route tools (split, compare, collage, find-duplicates, etc.) only handled HEIC via ensureSharpCompat, failing on BMP, PSD, RAW, TGA, EXR, HDR, JXL, and other formats Sharp cannot decode natively. Added the full decode pipeline from createToolRoute to all 16 affected routes: format validation via validateImageBuffer, HEIC decoding with actionable error messages, CLI-based exotic format decoding with nested fallback, and SVG sanitization to prevent XXE/SSRF/script injection.
172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import sharp from "sharp";
|
|
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";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* Image info route - read-only, returns JSON metadata.
|
|
* Does NOT use createToolRoute since it doesn't produce a processed file.
|
|
*/
|
|
export function registerInfo(app: FastifyInstance) {
|
|
app.post("/api/v1/tools/info", async (request: FastifyRequest, reply: FastifyReply) => {
|
|
let fileBuffer: Buffer | null = null;
|
|
let filename = "image";
|
|
|
|
try {
|
|
const parts = request.parts();
|
|
for await (const part of parts) {
|
|
if (part.type === "file") {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of part.file) {
|
|
chunks.push(chunk);
|
|
}
|
|
fileBuffer = Buffer.concat(chunks);
|
|
filename = sanitizeFilename(part.filename ?? "image");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return reply.status(400).send({
|
|
error: "Failed to parse multipart request",
|
|
details: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
|
|
if (!fileBuffer || fileBuffer.length === 0) {
|
|
return reply.status(400).send({ error: "No image file provided" });
|
|
}
|
|
|
|
try {
|
|
// Detect format for CLI-decoded formats (PSD, TGA, EXR, HDR, ICO, RAW)
|
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
const detectedFormat = validation.valid ? validation.format : null;
|
|
|
|
// For metadata reading, try Sharp on the raw buffer first -- it handles
|
|
// TIFF-based formats (DNG, CR2, NEF) without needing a full decode via
|
|
// ImageMagick/darktable. Only fall back to the decode pipeline for
|
|
// formats Sharp can't open at all (PSD, ICO, TGA, etc.).
|
|
let metaBuffer = fileBuffer;
|
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
let sharpDirectFailed = false;
|
|
try {
|
|
await sharp(fileBuffer).metadata();
|
|
} catch {
|
|
sharpDirectFailed = true;
|
|
}
|
|
|
|
if (sharpDirectFailed) {
|
|
if (detectedFormat && needsCliDecode(detectedFormat)) {
|
|
metaBuffer = await decodeToSharpCompat(fileBuffer, detectedFormat, ext);
|
|
} else {
|
|
metaBuffer = await decodeHeic(fileBuffer);
|
|
}
|
|
}
|
|
|
|
const metadata = await sharp(metaBuffer).metadata();
|
|
const stats = await sharp(metaBuffer).stats();
|
|
|
|
// Build histogram data from stats
|
|
const histogram = stats.channels.map((ch, i) => ({
|
|
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
|
|
min: ch.min,
|
|
max: ch.max,
|
|
mean: Math.round(ch.mean * 100) / 100,
|
|
stdev: Math.round(ch.stdev * 100) / 100,
|
|
}));
|
|
|
|
// For RAW formats, Sharp reads the embedded thumbnail -- enrich with
|
|
// ExifTool to get the real sensor dimensions and EXIF/ICC/XMP flags.
|
|
const exif = detectedFormat === "raw" ? await readExifToolMeta(fileBuffer, ext) : null;
|
|
|
|
return reply.send({
|
|
filename,
|
|
fileSize: fileBuffer.length,
|
|
width: exif?.width ?? metadata.width ?? 0,
|
|
height: exif?.height ?? metadata.height ?? 0,
|
|
format: exif?.format ?? metadata.format ?? "unknown",
|
|
channels: metadata.channels ?? 0,
|
|
hasAlpha: metadata.hasAlpha ?? false,
|
|
colorSpace: exif?.colorSpace ?? metadata.space ?? "unknown",
|
|
density: metadata.density ?? exif?.density ?? null,
|
|
isProgressive: metadata.isProgressive ?? false,
|
|
orientation: metadata.orientation ?? null,
|
|
hasProfile: metadata.hasProfile ?? false,
|
|
hasExif: exif?.hasExif ?? !!metadata.exif,
|
|
hasIcc: exif?.hasIcc ?? !!metadata.icc,
|
|
hasXmp: exif?.hasXmp ?? !!metadata.xmp,
|
|
bitDepth: exif?.bitDepth ?? metadata.depth ?? null,
|
|
pages: metadata.pages ?? 1,
|
|
histogram,
|
|
});
|
|
} catch (err) {
|
|
return reply.status(422).send({
|
|
error: "Failed to read image metadata",
|
|
details: err instanceof Error ? err.message : "Unknown error",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
interface ExifMeta {
|
|
width: number;
|
|
height: number;
|
|
format: string;
|
|
colorSpace: string | null;
|
|
density: number | null;
|
|
bitDepth: string | null;
|
|
hasExif: boolean;
|
|
hasIcc: boolean;
|
|
hasXmp: boolean;
|
|
}
|
|
|
|
async function readExifToolMeta(buffer: Buffer, ext?: string): Promise<ExifMeta | null> {
|
|
const suffix = ext ? `.${ext}` : ".dng";
|
|
const tmpPath = join(tmpdir(), `info-exif-${Date.now()}${suffix}`);
|
|
try {
|
|
await writeFile(tmpPath, buffer);
|
|
const { stdout } = await execFileAsync(
|
|
"exiftool",
|
|
[
|
|
"-j",
|
|
"-ImageWidth",
|
|
"-ImageHeight",
|
|
"-FileType",
|
|
"-BitsPerSample",
|
|
"-ColorSpace",
|
|
"-XResolution",
|
|
"-ICCProfileName",
|
|
"-EXIF:all",
|
|
"-XMP:XMPToolkit",
|
|
tmpPath,
|
|
],
|
|
{ timeout: 10_000 },
|
|
);
|
|
const [data] = JSON.parse(stdout);
|
|
if (!data) return null;
|
|
|
|
return {
|
|
width: data.ImageWidth ?? 0,
|
|
height: data.ImageHeight ?? 0,
|
|
format: (data.FileType ?? "raw").toLowerCase(),
|
|
colorSpace: data.ColorSpace ?? null,
|
|
density: data.XResolution ?? null,
|
|
bitDepth: data.BitsPerSample ? String(data.BitsPerSample) : null,
|
|
hasExif: Object.keys(data).some((k) => k.startsWith("EXIF:") || k === "ExifVersion"),
|
|
hasIcc: !!data.ICCProfileName,
|
|
hasXmp: !!data.XMPToolkit,
|
|
};
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
await rm(tmpPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|