diff --git a/package-lock.json b/package-lock.json index c27d204f..0c9246d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.8.5", + "heic-convert": "^2.1.0", "node-pty": "^1.1.0", "qrcode": "^1.5.4", "uuid": "^14.0.0", @@ -7023,6 +7024,41 @@ "node": ">= 0.4" } }, + "node_modules/heic-convert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz", + "integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==", + "license": "ISC", + "dependencies": { + "heic-decode": "^2.0.0", + "jpeg-js": "^0.4.4", + "pngjs": "^6.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/heic-convert/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/heic-decode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", + "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", + "license": "ISC", + "dependencies": { + "libheif-js": "^1.19.8" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -7481,6 +7517,12 @@ "node": ">=10" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -7664,6 +7706,15 @@ "node": ">= 0.8.0" } }, + "node_modules/libheif-js": { + "version": "1.19.8", + "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz", + "integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==", + "license": "LGPL-3.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", diff --git a/package.json b/package.json index d610ae58..d7e886f2 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "chokidar": "^3.6.0", "commander": "^12.1.0", "fastify": "^5.8.5", + "heic-convert": "^2.1.0", "node-pty": "^1.1.0", "qrcode": "^1.5.4", "uuid": "^14.0.0", diff --git a/src/types/heic-convert.d.ts b/src/types/heic-convert.d.ts new file mode 100644 index 00000000..132df724 --- /dev/null +++ b/src/types/heic-convert.d.ts @@ -0,0 +1,9 @@ +declare module 'heic-convert' { + export interface ConvertOptions { + buffer: Buffer; + format: 'JPEG' | 'PNG'; + quality?: number; + } + + export default function convert(options: ConvertOptions): Promise; +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 659731ad..fd7bd0d6 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -189,11 +189,32 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean { return u32be(0) === 0x52494646 && u32be(8) === 0x57454250; case '.bmp': return data[0] === 0x42 && data[1] === 0x4d; + case '.heic': + case '.heif': { + // ISO Base Media File Format: size + "ftyp" + major brand. + if (u32be(4) !== 0x66747970) return false; + const brand = data.subarray(8, 12).toString('ascii'); + return ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1'].includes(brand); + } default: return false; } } +async function convertHeicToJpeg(imageBytes: Buffer): Promise { + const { default: convert } = await import('heic-convert'); + const converted = await convert({ buffer: imageBytes, format: 'JPEG', quality: 0.92 }); + const jpegBytes = Buffer.isBuffer(converted) + ? converted + : converted instanceof ArrayBuffer + ? Buffer.from(converted) + : Buffer.from(converted.buffer, converted.byteOffset, converted.byteLength); + if (!imageMagicMatchesExt(jpegBytes, '.jpg')) { + throw new Error('HEIC conversion did not produce JPEG bytes'); + } + return jpegBytes; +} + // Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute. // Bucket map entries are pruned when they drift > 1h stale to bound memory // against a flood of unique IP keys. @@ -1753,7 +1774,7 @@ export function registerSessionRoutes( // Paste Image (clipboard / drag-drop upload) // ═══════════════════════════════════════════════════════════════ - const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']); + const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']); // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts). app.post('/api/sessions/:id/paste-image', async (req, reply) => { @@ -1845,7 +1866,7 @@ export function registerSessionRoutes( const origExt = extname(part.filename).toLowerCase(); if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt; } - const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/); + const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/); if (mimeMatch) { const map: Record = { png: '.png', @@ -1854,6 +1875,8 @@ export function registerSessionRoutes( webp: '.webp', gif: '.gif', bmp: '.bmp', + heic: '.heic', + heif: '.heif', }; ext = map[mimeMatch[1]] ?? ext; } @@ -1881,6 +1904,19 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`); } + if (ext === '.heic' || ext === '.heif') { + try { + imageBytes = await convertHeicToJpeg(imageBytes); + ext = '.jpg'; + } catch (err: unknown) { + console.warn( + `[paste-image] HEIC conversion failed: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} error=${getErrorMessage(err)}` + ); + reply.code(415); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG'); + } + } + // Save to {workingDir}/.claude-images/ // Refuse symlinks at imageDir — an agent or postinstall script could plant // `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir. diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 7f0d8a39..47f44490 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -16,16 +16,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; +import { join } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); +const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex'))); vi.mock('node:child_process', async (orig) => { const actual = await orig(); return { ...actual, execFile }; }); +vi.mock('heic-convert', () => ({ default: heicConvert })); import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; @@ -45,6 +51,9 @@ async function createEnvelopeHarness( ): Promise { const app = Fastify({ logger: false }); await app.register(fastifyCookie); + await app.register(fastifyMultipart, { + limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 }, + }); const ctx = createMockRouteContext(); registerFn(app, ctx); @@ -122,6 +131,49 @@ describe('session-routes', () => { }); }); + // ========== POST /api/sessions/:id/paste-image ========== + + describe('POST /api/sessions/:id/paste-image', () => { + function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer { + return Buffer.concat([ + Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="image"; filename="${filename}"\r\n` + + `Content-Type: ${mimetype}\r\n\r\n` + ), + imageBytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + } + + it('converts HEIC paste images to JPEG attachments when browser-side normalization falls back', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-')); + harness.ctx._session.workingDir = workDir; + heicConvert.mockClear(); + + const boundary = 'codeman-test-boundary'; + const heic = Buffer.from('00000034667479706865696300000000', 'hex'); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/paste-image`, + headers: { + host: 'codeman.test', + origin: 'http://codeman.test', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + payload: imageUploadBody(boundary, 'IMG_4996.HEIC', 'image/heic', heic), + }); + + await rm(workDir, { recursive: true }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/); + expect(heicConvert).toHaveBeenCalledWith({ buffer: heic, format: 'JPEG', quality: 0.92 }); + }); + }); + // ========== GET /api/sessions ========== describe('GET /api/sessions', () => {