mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(image): convert HEIC paste uploads to JPEG
When a browser pastes an HEIC file without normalising it first, the paste-image route now converts it to JPEG server-side via heic-convert before writing to .claude-images/. Magic-byte validation confirms the output is valid JPEG. Adds type declarations for the heic-convert package. Co-authored-by: Saqeb Akhter <saqeb.akhter@gmail.com>
This commit is contained in:
co-authored by
Saqeb Akhter
parent
1fa88cd187
commit
bb1d16e230
Generated
+51
@@ -28,6 +28,7 @@
|
|||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
@@ -7023,6 +7024,41 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||||
@@ -7481,6 +7517,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
@@ -7664,6 +7706,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
|
|||||||
Vendored
+9
@@ -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<Buffer | Uint8Array | ArrayBuffer>;
|
||||||
|
}
|
||||||
@@ -189,11 +189,32 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
|||||||
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
|
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
|
||||||
case '.bmp':
|
case '.bmp':
|
||||||
return data[0] === 0x42 && data[1] === 0x4d;
|
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:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
|
||||||
|
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.
|
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
||||||
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
||||||
// against a flood of unique IP keys.
|
// against a flood of unique IP keys.
|
||||||
@@ -1753,7 +1774,7 @@ export function registerSessionRoutes(
|
|||||||
// Paste Image (clipboard / drag-drop upload)
|
// 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).
|
// 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) => {
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||||
@@ -1845,7 +1866,7 @@ export function registerSessionRoutes(
|
|||||||
const origExt = extname(part.filename).toLowerCase();
|
const origExt = extname(part.filename).toLowerCase();
|
||||||
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
|
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) {
|
if (mimeMatch) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
png: '.png',
|
png: '.png',
|
||||||
@@ -1854,6 +1875,8 @@ export function registerSessionRoutes(
|
|||||||
webp: '.webp',
|
webp: '.webp',
|
||||||
gif: '.gif',
|
gif: '.gif',
|
||||||
bmp: '.bmp',
|
bmp: '.bmp',
|
||||||
|
heic: '.heic',
|
||||||
|
heif: '.heif',
|
||||||
};
|
};
|
||||||
ext = map[mimeMatch[1]] ?? ext;
|
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}`);
|
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/
|
// Save to {workingDir}/.claude-images/
|
||||||
// Refuse symlinks at imageDir — an agent or postinstall script could plant
|
// Refuse symlinks at imageDir — an agent or postinstall script could plant
|
||||||
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
|
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
|
||||||
|
|||||||
@@ -16,16 +16,22 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
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 { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
|
|
||||||
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
||||||
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
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) => {
|
vi.mock('node:child_process', async (orig) => {
|
||||||
const actual = await orig<typeof import('node:child_process')>();
|
const actual = await orig<typeof import('node:child_process')>();
|
||||||
return { ...actual, execFile };
|
return { ...actual, execFile };
|
||||||
});
|
});
|
||||||
|
vi.mock('heic-convert', () => ({ default: heicConvert }));
|
||||||
|
|
||||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
|
||||||
@@ -45,6 +51,9 @@ async function createEnvelopeHarness(
|
|||||||
): Promise<LocalHarness> {
|
): Promise<LocalHarness> {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
await app.register(fastifyCookie);
|
await app.register(fastifyCookie);
|
||||||
|
await app.register(fastifyMultipart, {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
const ctx = createMockRouteContext();
|
const ctx = createMockRouteContext();
|
||||||
registerFn(app, ctx);
|
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 ==========
|
// ========== GET /api/sessions ==========
|
||||||
|
|
||||||
describe('GET /api/sessions', () => {
|
describe('GET /api/sessions', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user