diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index ddd220a5..ebf8bf6f 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -20,6 +20,17 @@ const SUPPORTED_INPUT_FORMATS = new Set([ "psd", "exr", "hdr", + "jp2", + "qoi", + "eps", + "dds", + "cur", + "dpx", + "fits", + "ppm", + "pgm", + "pbm", + "pfm", ]); interface MagicEntry { @@ -62,6 +73,47 @@ const MAGIC_BYTES: MagicEntry[] = [ // OpenEXR { bytes: [0x76, 0x2f, 0x31, 0x01], offset: 0, format: "exr" }, // TGA has no reliable magic bytes — detected by extension only + // JPEG 2000 JP2 box signature (NOT ISOBMFF) + { + bytes: [0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a], + offset: 0, + format: "jp2", + }, + // JPEG 2000 raw codestream (J2K/J2C) + { bytes: [0xff, 0x4f, 0xff, 0x51], offset: 0, format: "jp2" }, + // QOI: "qoif" at offset 0 + { bytes: [0x71, 0x6f, 0x69, 0x66], offset: 0, format: "qoi" }, + // DDS: "DDS " at offset 0 + { bytes: [0x44, 0x44, 0x53, 0x20], offset: 0, format: "dds" }, + // CUR: Windows cursor (ICO variant, byte 3 = 0x02 vs ICO's 0x01) + { bytes: [0x00, 0x00, 0x02, 0x00], offset: 0, format: "cur" }, + // DPX forward: "SDPX" + { bytes: [0x53, 0x44, 0x50, 0x58], offset: 0, format: "dpx" }, + // DPX reverse: "XPDS" + { bytes: [0x58, 0x50, 0x44, 0x53], offset: 0, format: "dpx" }, + // Cineon + { bytes: [0x80, 0x2a, 0x5f, 0xd7], offset: 0, format: "dpx" }, + // FITS: "SIMPLE" at offset 0 + { bytes: [0x53, 0x49, 0x4d, 0x50, 0x4c, 0x45], offset: 0, format: "fits" }, + // EPS ASCII header: "%!PS-Adobe" + { + bytes: [0x25, 0x21, 0x50, 0x53, 0x2d, 0x41, 0x64, 0x6f, 0x62, 0x65], + offset: 0, + format: "eps", + }, + // EPS binary (DOS EPS) + { bytes: [0xc5, 0xd0, 0xd3, 0xc6], offset: 0, format: "eps" }, + // Netpbm: P1-P7 headers (these MUST go AFTER the PNG entry to avoid false matches on 0x50) + { bytes: [0x50, 0x31], offset: 0, format: "pbm" }, + { bytes: [0x50, 0x34], offset: 0, format: "pbm" }, + { bytes: [0x50, 0x32], offset: 0, format: "pgm" }, + { bytes: [0x50, 0x35], offset: 0, format: "pgm" }, + { bytes: [0x50, 0x33], offset: 0, format: "ppm" }, + { bytes: [0x50, 0x36], offset: 0, format: "ppm" }, + { bytes: [0x50, 0x37], offset: 0, format: "ppm" }, + // PFM (Portable FloatMap) + { bytes: [0x50, 0x46], offset: 0, format: "pfm" }, + { bytes: [0x50, 0x66], offset: 0, format: "pfm" }, ]; export interface ValidationResult { @@ -104,7 +156,23 @@ const RAW_EXTENSIONS = new Set([ ]); /** Formats that Sharp cannot decode natively — skip dimension check. */ -const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]); +const CLI_DECODED_FORMATS = new Set([ + "raw", + "ico", + "tga", + "psd", + "exr", + "hdr", + "bmp", + "jxl", + "jp2", + "qoi", + "eps", + "dds", + "cur", + "dpx", + "fits", +]); /** * Check whether a file extension corresponds to a Camera RAW format. @@ -158,6 +226,18 @@ export async function validateImageBuffer( detectedFormat = "tga"; } + // SVGZ: gzip-compressed SVG, detected by extension + gzip magic + if (!detectedFormat && ext === "svgz") { + if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) { + detectedFormat = "svg"; + } + } + + // APNG: Sharp handles as PNG first frame. Accept .apng extension. + if (!detectedFormat && ext === "apng") { + detectedFormat = "png"; + } + if (!detectedFormat) { return { valid: false, reason: "Unrecognized image format" }; } diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index c691ca82..1da71cf5 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -8,7 +8,23 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); /** Formats that need external CLI tools (not decodable by Sharp). */ -const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]); +const CLI_DECODED_FORMATS = new Set([ + "raw", + "ico", + "tga", + "psd", + "exr", + "hdr", + "bmp", + "jxl", + "jp2", + "qoi", + "eps", + "dds", + "cur", + "dpx", + "fits", +]); export function needsCliDecode(format: string): boolean { return CLI_DECODED_FORMATS.has(format); @@ -47,6 +63,20 @@ export async function decodeToSharpCompat( return decodeBmp(buffer); case "jxl": return decodeJxl(buffer); + case "jp2": + return decodeJp2(buffer); + case "eps": + return decodeEps(buffer); + case "dds": + return decodeDds(buffer); + case "cur": + return decodeIco(buffer); // CUR is structurally identical to ICO + case "dpx": + return decodeDpx(buffer); + case "fits": + return decodeFits(buffer); + case "qoi": + return decodeQoi(buffer); default: return buffer; } @@ -284,3 +314,134 @@ async function decodeJxl(buffer: Buffer): Promise { await rm(outputPath, { force: true }).catch(() => {}); } } + +// ── JPEG 2000 decoder (opj_decompress-first, ImageMagick fallback) ── + +async function decodeJp2(buffer: Buffer): Promise { + const id = randomUUID(); + const inputPath = join(tmpdir(), `jp2-in-${id}.jp2`); + const outputPath = join(tmpdir(), `jp2-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + try { + await execFileAsync("opj_decompress", ["-i", inputPath, "-o", outputPath], { + timeout: 60_000, + }); + return await readFile(outputPath); + } catch { + // opj_decompress not available, fall back to ImageMagick + } + const cmd = await findMagickCmd(); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), { + timeout: 120_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── EPS decoder (ImageMagick + Ghostscript delegate) ── + +const MAX_EPS_SIZE = 50 * 1024 * 1024; + +async function decodeEps(buffer: Buffer): Promise { + if (buffer.length > MAX_EPS_SIZE) { + throw new Error( + `EPS file too large (${(buffer.length / 1024 / 1024).toFixed(1)}MB, limit: 50MB)`, + ); + } + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `eps-in-${id}.eps`); + const outputPath = join(tmpdir(), `eps-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync( + cmd, + magickArgs(cmd, [ + "-density", + "300", + "-define", + "gs:MaxBitmap=500000000", + inputPath, + "-colorspace", + "sRGB", + `png:${outputPath}`, + ]), + { timeout: 30_000 }, + ); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── DDS decoder ── + +async function decodeDds(buffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `dds-in-${id}.dds`); + const outputPath = join(tmpdir(), `dds-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), { + timeout: 120_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── DPX / Cineon decoder ── + +async function decodeDpx(buffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `dpx-in-${id}.dpx`); + const outputPath = join(tmpdir(), `dpx-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync( + cmd, + magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]), + { timeout: 120_000 }, + ); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── FITS decoder ── + +async function decodeFits(buffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `fits-in-${id}.fits`); + const outputPath = join(tmpdir(), `fits-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync( + cmd, + magickArgs(cmd, [inputPath, "-normalize", "-colorspace", "sRGB", `png:${outputPath}`]), + { timeout: 120_000 }, + ); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + +// ── QOI decoder (stub -- real codec comes in Task 4) ── + +async function decodeQoi(_buffer: Buffer): Promise { + throw new Error("QOI decode not yet implemented"); +} diff --git a/apps/api/src/lib/svg-sanitize.ts b/apps/api/src/lib/svg-sanitize.ts index 841a7908..c7fe95c8 100644 --- a/apps/api/src/lib/svg-sanitize.ts +++ b/apps/api/src/lib/svg-sanitize.ts @@ -1,3 +1,4 @@ +import { gunzipSync } from "node:zlib"; import { env } from "../config.js"; /** @@ -36,6 +37,24 @@ export function sanitizeSvg(buffer: Buffer): Buffer { return Buffer.from(svg, "utf-8"); } +const MAX_SVGZ_DECOMPRESSED_SIZE = 50 * 1024 * 1024; + +/** + * Decompress an SVGZ (gzip-compressed SVG) buffer. + * Returns the buffer unchanged if it is not gzip-compressed. + * Throws on decompression bomb or invalid SVG content. + */ +export function decompressSvgz(buffer: Buffer): Buffer { + if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) { + return buffer; + } + const decompressed = gunzipSync(buffer, { maxOutputLength: MAX_SVGZ_DECOMPRESSED_SIZE }); + if (!isSvgBuffer(decompressed)) { + throw new Error("SVGZ file does not contain valid SVG content after decompression"); + } + return decompressed; +} + /** * Check whether a buffer looks like SVG content. * Examines the first 4KB for an (app: FastifyInstance, config: ToolRouteConfig const isSvg = validation.format === "svg"; if (isSvg) { try { + fileBuffer = decompressSvgz(fileBuffer); fileBuffer = sanitizeSvg(fileBuffer); } catch (err) { return reply.status(400).send({ @@ -323,6 +324,12 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig "image/x-exr": ".exr", "image/vnd.radiance": ".hdr", "image/x-targa": ".tga", + "image/jp2": ".jp2", + "image/qoi": ".qoi", + "application/postscript": ".eps", + "image/vnd.ms-dds": ".dds", + "image/x-dpx": ".dpx", + "image/fits": ".fits", }; const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType]; if (expectedExt) { diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx index 02d4bb1b..efd05c91 100644 --- a/apps/web/src/components/common/dropzone.tsx +++ b/apps/web/src/components/common/dropzone.tsx @@ -16,7 +16,7 @@ interface DropzoneProps { // Append explicit extensions so they are selectable. function expandAccept(accept?: string): string | undefined { if (!accept?.includes("image/*")) return accept; - return `${accept},.heic,.heif,.hif,.jxl,.ico,.dng,.cr2,.nef,.arw,.orf,.rw2,.tga,.psd,.exr,.hdr`; + return `${accept},.heic,.heif,.hif,.jxl,.ico,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.j2c,.jpc,.jpf,.jpx,.qoi,.eps,.epsf,.dds,.cur,.apng,.dpx,.cin,.fits,.fit,.fts,.ppm,.pgm,.pbm,.pnm,.pam,.pfm`; } export function Dropzone({ diff --git a/apps/web/src/components/editor/common/welcome-screen.tsx b/apps/web/src/components/editor/common/welcome-screen.tsx index 2c7ffe68..1046630f 100644 --- a/apps/web/src/components/editor/common/welcome-screen.tsx +++ b/apps/web/src/components/editor/common/welcome-screen.tsx @@ -5,7 +5,7 @@ import { useCallback, useState } from "react"; import { useEditorStore } from "@/stores/editor-store"; import { NewDocumentDialog } from "./new-document-dialog"; -const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg"; +const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg,.avif,.svgz"; export function WelcomeScreen() { const [showNewDoc, setShowNewDoc] = useState(false); diff --git a/apps/web/src/components/files/file-upload-area.tsx b/apps/web/src/components/files/file-upload-area.tsx index 4053e36c..a8038b9c 100644 --- a/apps/web/src/components/files/file-upload-area.tsx +++ b/apps/web/src/components/files/file-upload-area.tsx @@ -57,7 +57,7 @@ export function FileUploadArea() {