From 50b8f1d9a0b231dc95604b964e328e8e8117d98a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 23 Jun 2026 23:12:41 +0200 Subject: [PATCH] feat(mobile): large + multi-image uploads from the camera-roll picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile copy/paste overlay's "๐Ÿ–ผ Image" button (and drag-drop / paste) now handles real-world photo batches: - Up to 20 images per batch, uploaded with bounded concurrency (3) and a live "Uploading N/Mโ€ฆ" progress toast; a final summary reports successes, any failures, and whether the 20-cap trimmed the selection (no silent truncation). - Per-file upload limit raised 10MB โ†’ 50MB (MAX_PASTE_IMAGE_BYTES in buffer-limits.ts, env-overridable) so full-resolution phone photos and large screenshots aren't rejected. - Very large images are downscaled to <=4096px longest edge before upload: fixes iOS Safari's ~16.7M-px limit (which made huge photos fail to re-encode and fall back to an original that tripped the magic-byte check), and keeps batch uploads fast and small. - Fix a latent concurrency bug the batch path exposed: the first parallel uploads to a session raced on `mkdir(.claude-images)` and the EEXIST losers 500'd. mkdir now treats an existing real directory as success (re-verifying it isn't a planted symlink), so concurrent uploads succeed. Verified end-to-end in a real browser (Playwright): downscale, >10MB server acceptance, 20-cap, 20/20 concurrent uploads landing on disk. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/buffer-limits.ts | 15 +++++ src/web/public/image-input.js | 104 ++++++++++++++++++++++++------- src/web/routes/session-routes.ts | 32 +++++++--- src/web/server.ts | 5 +- 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/src/config/buffer-limits.ts b/src/config/buffer-limits.ts index 4a44ac6d..dd6fe5a7 100644 --- a/src/config/buffer-limits.ts +++ b/src/config/buffer-limits.ts @@ -96,3 +96,18 @@ export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB * which is enough to extract metadata from the first few JSONL lines. */ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset) + +// ============================================================================ +// Paste-Image Upload Limits +// ============================================================================ + +/** + * Maximum size (bytes) of a single image uploaded via POST + * /api/sessions/:id/paste-image. The mobile picker / drag-drop / paste paths + * send one file per request (the client uploads up to MAX_PASTE_IMAGES of them + * per batch), so this caps each individual file, not the batch. Generous enough + * for full-resolution phone photos and large screenshots; the client downscales + * very large images before upload, so legitimate uploads land well under this. + * Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes) + */ +export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index 0402d62d..550e30b3 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -104,34 +104,78 @@ Object.assign(CodemanApp.prototype, { document.execCommand('paste'); }, - async _uploadAndInsertImages(files) { + // Max images accepted in one batch (paste / drop / mobile picker). Each is + // uploaded as its own request, so 20 stays under the server's 30 uploads/min + // rate limit while covering "select a bunch of photos at once". + _maxBatchImages: 20, + // How many uploads to run concurrently. Small enough that decoding several + // large images through at once won't OOM a phone, large enough that + // 20 photos don't crawl through serially. + _uploadConcurrency: 3, + + async _uploadAndInsertImages(fileList) { const sessionId = this.activeSessionId; if (!sessionId) return; - this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info'); + let files = Array.from(fileList || []); + if (files.length === 0) return; - const paths = []; - for (const file of files) { - try { - // Re-encode to a standard JPEG/PNG before upload. Galleries on some - // phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and - // MIME claim "image/jpeg", which passes the server's extension allowlist - // but fails its magic-byte check ("bytes do not match declared type"). - // Decoding through the browser and re-encoding guarantees the bytes - // match the extension we send. - const normalized = await this._normalizeImageForUpload(file); - const path = await this._uploadPasteImage(sessionId, normalized); - paths.push(path); - } catch (err) { - this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error'); + // Cap the batch and tell the user what got dropped (no silent truncation). + let capped = false; + if (files.length > this._maxBatchImages) { + files = files.slice(0, this._maxBatchImages); + capped = true; + } + + const total = files.length; + let done = 0; + let failed = 0; + const results = new Array(total); // preserve selection order for insertion + const progress = () => + this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}โ€ฆ`, 'info'); + progress(); + + // Bounded-concurrency worker pool over the file list. + let next = 0; + const worker = async () => { + for (;;) { + const i = next++; + if (i >= total) return; + try { + // Re-encode to a standard JPEG/PNG (and downscale very large images) + // before upload. Galleries on some phones (notably Android/MIUI) hand + // back a WebP/HEIF whose filename and MIME claim "image/jpeg", which + // passes the server's extension allowlist but fails its magic-byte + // check. Decoding through the browser and re-encoding guarantees the + // bytes match the extension we send โ€” and shrinks huge photos so they + // fit the upload limit and iOS's area cap. + const normalized = await this._normalizeImageForUpload(files[i]); + results[i] = await this._uploadPasteImage(sessionId, normalized); + } catch (err) { + failed++; + console.warn('Image upload failed:', err); + results[i] = null; + } finally { + done++; + if (done < total) progress(); + } } + }; + await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker())); + + const paths = results.filter(Boolean); + if (paths.length > 0) { + // Insert all paths in one shot, space-separated, in selection order. + await this.sendInput(paths.join(' ')); } - if (paths.length > 0) { - const pathStr = paths.join(' '); - await this.sendInput(pathStr); - this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success'); - } + // Final status: successes, plus any failures / cap so nothing is silent. + const parts = []; + if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`); + if (failed > 0) parts.push(`${failed} failed`); + if (capped) parts.push(`max ${this._maxBatchImages} per batch`); + const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error'; + this.showToast(parts.join(' ยท ') || 'No images uploaded', tone); }, async _uploadPasteImage(sessionId, file) { @@ -176,12 +220,24 @@ Object.assign(CodemanApp.prototype, { const height = img.naturalHeight; if (!width || !height) return file; + // Downscale very large images. Two reasons: (1) iOS Safari refuses to + // render a larger than ~16.7M px (it returns a blank/null + // blob), so a 48MP photo would otherwise fail to re-encode and fall back + // to the original โ€” which then trips the server's magic-byte check for + // HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well + // under the size limit. Cap the longest edge so area stays safely below + // the canvas limit while still uploading a large, high-quality image. + const MAX_EDGE = 4096; + const scale = Math.min(1, MAX_EDGE / Math.max(width, height)); + const w = Math.max(1, Math.round(width * scale)); + const h = Math.max(1, Math.round(height * scale)); + const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; + canvas.width = w; + canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) return file; - ctx.drawImage(img, 0, 0); + ctx.drawImage(img, 0, 0, w, h); const mime = toPng ? 'image/png' : 'image/jpeg'; const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92)); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c3ec4e33..ded9321c 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -60,6 +60,7 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; import { RunSummaryTracker } from '../../run-summary.js'; import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; +import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js'; import { dataPath } from '../../config/instance.js'; // Path to linked-cases registry (same file used by case-routes resolveCasePath) @@ -1710,7 +1711,7 @@ export function registerSessionRoutes( // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']); - // The 10MB size cap 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) => { // CSRF defense: state-changing routes must come from same origin. @@ -1747,7 +1748,7 @@ export function registerSessionRoutes( const { id } = req.params as { id: string }; // Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS - // โ€” even an authenticated attacker can otherwise loop 10MB POSTs. + // โ€” even an authenticated attacker can otherwise loop large image POSTs. if (!consumePasteToken(`${req.ip}:${id}`)) { reply.code(429); reply.header('Retry-After', '60'); @@ -1761,8 +1762,9 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data'); } - // Read the single file part. @fastify/multipart enforces the 10MB size cap - // and the 1-file/4-field count limits (server.ts), replacing a hand-rolled + // Read the single file part. @fastify/multipart enforces the per-file size + // cap (MAX_PASTE_IMAGE_BYTES) and the 1-file/4-field count limits (server.ts), + // replacing a hand-rolled // boundary scanner with several bugs: literal boundary matches anywhere in // body, LF-only clients silently corrupted the last byte (hard-coded \r\n // offsets), no part-count cap. @@ -1786,7 +1788,8 @@ export function registerSessionRoutes( imageBytes = await part.toBuffer(); } catch (err: unknown) { reply.code(413); - return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)'); + const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024)); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || `File too large (max ${maxMb}MB)`); } if (imageBytes.length === 0) { reply.code(400); @@ -1850,9 +1853,22 @@ export function registerSessionRoutes( } } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - // Non-recursive mkdir: errors on EEXIST and does not follow symlinks for - // the leaf. session.workingDir is guaranteed to exist (live session). - await fs.mkdir(imageDir); + // Non-recursive mkdir: does not follow symlinks for the leaf. + // session.workingDir is guaranteed to exist (live session). + try { + await fs.mkdir(imageDir); + } catch (mkErr: unknown) { + // Concurrent uploads (a batch of photos) race to create .claude-images โ€” + // the losers get EEXIST. Treat an already-present REAL directory as + // success, but re-verify it isn't a symlink a racing actor planted + // (preserve the symlink-safety guarantee above). + if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr; + const raceStat = await fs.lstat(imageDir); + if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory'); + } + } } // Date.now() collides on same-ms uploads from two tabs (last-write wins // silently). Append 8 hex chars so concurrent pastes get distinct names. diff --git a/src/web/server.ts b/src/web/server.ts index a156ec11..43141fee 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -128,6 +128,7 @@ import { } from '../utils/index.js'; import type { EventLoopMonitorHandle } from '../utils/index.js'; import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js'; +import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js'; import { SseEvent } from './sse-events.js'; import { getLatestPlanUsage } from './plan-usage-latest.js'; import type { ScheduledRun } from './ports/index.js'; @@ -679,8 +680,8 @@ export class WebServer extends EventEmitter { // last byte (hard-coded \r\n offsets), and there was no part-count cap. await this.app.register(fastifyMultipart, { limits: { - fileSize: 10 * 1024 * 1024, // 10MB per file - files: 1, // paste-image only ever sends one file + fileSize: MAX_PASTE_IMAGE_BYTES, // per file (default 50MB) โ€” large phone photos / screenshots + files: 1, // paste-image sends one file per request (clients batch up to 20 requests) fields: 4, // small headroom for accompanying form fields }, });