mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(mobile): large + multi-image uploads from the camera-roll picker
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 <canvas> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <canvas> 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 <canvas> 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 <canvas> 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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-2
@@ -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
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user